Рассмотрим функцию, подобную этой:
function savePeople(dataStore, people) {
people.forEach(person => dataStore.savePerson(person));
}
Это может быть использовано так:
myDataStore = new Store('some connection string', 'password');
myPeople = ['Joe', 'Maggie', 'John'];
savePeople(myDataStore, myPeople);
Давайте предположим , что Store
имеют свои собственные модульные тесты, или поставщик при условии. В любом случае мы верим Store
. И давайте далее предположим, что обработка ошибок - например, ошибок отключения базы данных - не является обязанностью savePeople
. В самом деле, давайте предположим, что само хранилище - это волшебная база данных, которая никак не может ошибаться. Учитывая эти предположения, вопрос заключается в следующем:
Должно ли savePeople()
быть модульное тестирование, или такие тесты будут равносильны тестированию встроенной forEach
языковой конструкции?
Мы могли бы, конечно, передать насмешку dataStore
и утверждать, что dataStore.savePerson()
это называется один раз для каждого человека. Вы, конечно, можете привести аргумент, что такой тест обеспечивает защиту от изменений реализации: например, если мы решили заменить forEach
традиционный for
цикл или какой-то другой метод итерации. Так что тест не совсем тривиален. И все же это кажется ужасно близко ...
Вот еще один пример, который может быть более плодотворным. Рассмотрим функцию, которая ничего не делает, кроме как координирует другие объекты или функции. Например:
function bakeCookies(dough, pan, oven) {
panWithRawCookies = pan.add(dough);
oven.addPan(panWithRawCookies);
oven.bakeCookies();
oven.removePan();
}
Как такая функция должна быть проверена модулем, если вы думаете, что она должна? Это трудно для меня , чтобы представить себе какое - либо модульное тестирование , который не просто издеваться dough
, pan
и oven
, а затем утверждать , что методы вызываются на них. Но такой тест не делает ничего, кроме дублирования точной реализации функции.
Означает ли эта неспособность проверить функцию в значимом «черном ящике» недостаток конструкции самой функции? Если так, как это могло быть улучшено?
Чтобы еще больше прояснить вопрос, мотивирующий bakeCookies
пример, я добавлю более реалистичный сценарий, с которым я столкнулся при попытке добавить тесты и реорганизовать унаследованный код.
Когда пользователь создает новую учетную запись, должен произойти ряд вещей: 1) новая запись пользователя должна быть создана в базе данных 2) приветственное электронное письмо должно быть отправлено 3) IP-адрес пользователя должен быть записан для мошенничества цели.
Итак, мы хотим создать метод, который связывает все шаги «нового пользователя»:
function createNewUser(validatedUserData, emailService, dataStore) {
userId = dataStore.insertUserRecord(validateduserData);
emailService.sendWelcomeEmail(validatedUserData);
dataStore.recordIpAddress(userId, validatedUserData.ip);
}
Обратите внимание, что если какой-либо из этих методов выдает ошибку, мы хотим, чтобы ошибка всплывала в вызывающем коде, чтобы он мог обработать ошибку так, как считает нужным. Если он вызывается кодом API, он может перевести ошибку в соответствующий код ответа http. Если он вызывается через веб-интерфейс, он может преобразовать ошибку в соответствующее сообщение для отображения пользователю и т. Д. Дело в том, что эта функция не знает, как обрабатывать ошибки, которые могут быть выданы.
Суть моего заблуждения состоит в том, что для модульного тестирования такой функции кажется необходимым повторить точную реализацию в самом тесте (указав, что методы вызываются на mocks в определенном порядке), и это кажется неправильным.
Ответы:
Должен ли
savePeople()
быть юнит-тест? Да. Вы не тестируете, чтоdataStore.savePerson
работает, или работает соединение с БД, или даже, чтоforeach
работает. Вы проводите тестирование, котороеsavePeople
выполняет обещание, которое оно дает по контракту.Представьте себе такой сценарий: кто-то делает большой рефакторинг базы кода и случайно удаляет
forEach
часть реализации, чтобы он всегда сохранял только первый элемент. Разве вы не хотите, чтобы юнит-тест поймал это?источник
Обычно такой вопрос возникает, когда люди занимаются разработкой «после испытания». Подходите к этой проблеме с точки зрения TDD, где тесты предшествуют реализации, и снова задайте себе этот вопрос в качестве упражнения.
По крайней мере, в моем приложении TDD, которое обычно находится снаружи, я бы не реализовывал функцию, подобную
savePeople
реализованнойsavePerson
. ФункцииsavePeople
иsavePerson
начинаются как единое целое и управляются тестом из одних и тех же модульных тестов; разделение между ними возникнет после нескольких испытаний на этапе рефакторинга. Этот способ работы также ставит вопрос о том, гдеsavePeople
должна быть функция - является ли она свободной функцией или ее частьюdataStore
.В конце концов, тесты не только проверить , если вы можете правильно сэкономить
Person
вStore
, но и многие люди. Это также привело бы меня к вопросу о необходимости других проверок, например: «Нужно ли мне убедиться, чтоsavePeople
функция атомарна, сохранять все или ничего?», «Может ли она просто как-то возвращать ошибки людям, которые не могли» не будут сохранены? Как будут выглядеть эти ошибки? »и так далее. Все это составляет гораздо больше, чем просто проверка на использование тойforEach
или иной формы итерации.Хотя, если требование сохранения более чем одного человека пришло только после того,
savePerson
как оно уже было доставлено, я бы обновил существующие тесты,savePerson
чтобы выполнить новую функциюsavePeople
, убедившись, что она все еще может спасти одного человека, просто сначала делегировав, затем протестируйте поведение более чем одного человека с помощью новых тестов, думая, необходимо ли сделать поведение атомарным или нет.источник
savePeople
? Как я описал в последнем абзаце ОП или каким-то другим способом?savePerson
функции, как вы предложили, вместо этого я протестирую ее через более общиеsavePeople
. Модульные тесты дляStore
будут изменены, чтобы проходить через,savePeople
а не напрямую вызыватьsavePerson
, поэтому для этого никакие макеты не используются. Но база данных, конечно, не должна присутствовать, так как мы хотели бы изолировать проблемы кодирования от различных проблем интеграции, которые возникают с реальными базами данных, поэтому здесь у нас все еще есть пример.Да, так и должно быть. Но попробуйте написать свои условия тестирования способом, который не зависит от реализации. Например, превращение вашего примера использования в модульный тест:
Этот тест делает несколько вещей:
savePeople()
savePeople()
savePeople()
Обратите внимание, что вы все еще можете издеваться / заглушить / подделать хранилище данных. В этом случае я бы проверял не явные вызовы функций, а результат операции. Таким образом, мой тест подготовлен для будущих изменений / рефакторинга.
Например, ваша реализация хранилища данных может предоставить
saveBulkPerson()
метод в будущем - теперь изменение в реализацииsavePeople()
для использованияsaveBulkPerson()
не будет нарушать модульный тест, пока онsaveBulkPerson()
работает, как ожидалось. И еслиsaveBulkPerson()
как - то не работает , как ожидалось, устройство тест будет расслышал.Как уже было сказано, попробуйте проверить ожидаемые результаты и интерфейс функции, а не реализацию (если вы не выполняете интеграционные тесты - тогда может понадобиться перехват определенных вызовов функций). Если существует несколько способов реализации функции, все они должны работать с вашим модульным тестом.
По поводу вашего обновления вопроса:
Тест на изменение состояния! Например, часть теста будет использоваться. В соответствии с вашей реализацией утверждают, что количество использованных средств
dough
соответствует,pan
или утверждают, чтоdough
использованное количество использовалось. Утверждают, чтоpan
после вызова функции в нем содержатся файлы cookie. Утверждают, чтоoven
пусто / в том же состоянии, что и раньше.Для дополнительных тестов проверьте крайние случаи: что произойдет, если
oven
перед вызовом не пусто? Что будет, если не хватитdough
? Еслиpan
уже заполнен?Вы должны быть в состоянии вывести все необходимые данные для этих испытаний из самих объектов теста, сковороды и духовки. Не нужно перехватывать вызовы функций. Рассматривайте функцию так, как если бы ее реализация была вам недоступна!
Фактически, большинство пользователей TDD пишут свои тесты до того, как пишут функцию, поэтому они не зависят от фактической реализации.
Для вашего последнего дополнения:
Для функции , как это я бы глумиться / стаб / подделка (что кажется более общим) в
dataStore
иemailService
параметров. Эта функция сама по себе не выполняет никаких переходов состояний ни по одному параметру, она делегирует их методам некоторых из них. Я хотел бы проверить, что вызов функции сделал 4 вещи:Первые 3 проверки могут быть сделаны с помощью макетов, заглушек или подделок
dataStore
иemailService
(вы действительно не хотите отправлять электронные письма при тестировании). Так как мне пришлось искать это для некоторых комментариев, вот различия:dataStore
просто реализующая подходящую версиюinsertUserRecord()
иrecordIpAddress()
.Ожидаемые исключения / ошибки являются действительными тестовыми примерами: вы подтверждаете, что в случае такого события функция ведет себя так, как вы ожидаете. Это может быть достигнуто путем добавления соответствующего объекта mock / fake / stub при желании.
Иногда это нужно делать (хотя вы в основном заботитесь об этом в интеграционных тестах). Чаще всего существуют другие способы проверки ожидаемых побочных эффектов / изменений состояния.
Проверка точных вызовов функций делает довольно хрупкие юнит-тесты: только небольшие изменения в исходной функции приводят к их сбою. Это может быть желательным или нет, но это требует изменения соответствующих модульных тестов всякий раз, когда вы меняете функцию (будь то рефакторинг, оптимизация, исправление ошибок, ...).
К сожалению, в этом случае модульный тест теряет часть своего доверия: так как он был изменен, он не подтверждает функцию после того, как изменение ведет себя так же, как и раньше.
Для примера рассмотрим добавление вызова
oven.preheat()
(оптимизация!) В ваш пример выпечки печенья:В моих модульных тестах я стараюсь быть как можно более общим: если реализация меняется, но видимое поведение (с точки зрения вызывающего) остается тем же, мои тесты должны пройти. В идеале, единственным случаем, когда мне нужно изменить существующий модульный тест, должно быть исправление ошибки (теста, а не тестируемой функции).
источник
myDataStore.containsPerson('Joe')
вы предполагаете существование базы функциональных тестов. Как только вы это сделаете, вы пишете интеграционный тест, а не юнит-тест.savePeople()
добавить этих людей в любое хранилище данных, которое вы предоставляете, если это хранилище данных реализует ожидаемый интерфейс. Интеграционный тест будет, например, проверять, что моя обертка базы данных действительно выполняет правильные вызовы базы данных для вызова метода.myDataStore.containsPerson('Joe')
, вы должны использовать какой-то функциональный db. Как только вы сделаете этот шаг, он больше не будет модульным тестом.myPeople
находятся ли тестовые данные (элементы ) в массиве. ИМХО, макет должен по-прежнему иметь то же наблюдаемое поведение, что и реальный объект, иначе вы тестируете на соответствие макету, а не реальному интерфейсу.Основное значение такого теста заключается в том, что он делает вашу реализацию рефакторизированной.
Раньше я делал много оптимизаций производительности в своей карьере и часто сталкивался с проблемами с точным шаблоном, который вы продемонстрировали: чтобы сохранить N сущностей в базе данных, выполнить N вставок. Обычно более эффективно выполнять массовую вставку с использованием одного оператора.
С другой стороны, мы также не хотим преждевременной оптимизации. Если вы обычно экономите только 1–3 человека за раз, тогда написание оптимизированной партии может оказаться излишним.
С надлежащим модульным тестом вы можете написать его так, как вы это реализовали выше, и если вы обнаружите, что вам нужно его оптимизировать, вы можете сделать это с помощью системы безопасности автоматического теста, чтобы отследить любые ошибки. Естественно, это зависит от качества тестов, так что тестируйте свободно и тестируйте хорошо.
Вторичное преимущество для модульного тестирования этого поведения состоит в том, чтобы служить документацией для его цели. Этот тривиальный пример может быть очевиден, но, учитывая следующий пункт ниже, он может быть очень важным.
Третье преимущество, на которое указывали другие, заключается в том, что вы можете тестировать скрытые детали, которые очень сложно проверить с помощью интеграционных или приемочных тестов. Например, если есть требование, что все пользователи должны быть сохранены атомарно, вы можете написать тестовый пример для этого, который даст вам возможность узнать, как он ведет себя, как ожидалось, а также послужит документацией для требования, которое может быть неочевидным. новым разработчикам.
Я добавлю мысль, которую я получил от инструктора TDD. Не проверяйте метод. Проверьте поведение. Другими словами, вы не проверяете
savePeople
работоспособность, вы проверяете, что несколько пользователей могут быть сохранены за один вызов.Я обнаружил, что моя способность проводить качественное модульное тестирование и TDD улучшилось, когда я перестал думать о модульных тестах как о проверке работоспособности программы, а скорее о том, что единица кода выполняет то, что я ожидаю . Это разные. Они не подтверждают, что это работает, но они подтверждают, что это делает то, что я думаю. Когда я начал так думать, моя точка зрения изменилась.
источник
savePerson
вызывал его для каждого человека в списке, - однако, не справился с рефакторингом массовой вставки. Что для меня означает, что это плохой юнит-тест. Однако я не вижу альтернативного варианта, который бы проходил как массовую реализацию, так и реализацию по одному на одного человека, без использования реальной тестовой базы данных и утверждения против этого, что кажется неправильным. Не могли бы вы предоставить тест, который работает для обеих реализаций?savePerson
метод вызывался для каждого ввода, и если вы замените цикл массовой вставкой, вы больше не будете вызывать этот метод. Так что твой тест сломается. Если у вас есть что-то еще, я открыт для этого, но пока не вижу. (И не видя, что это была моя точка зрения.)Должен
bakeCookies()
быть проверен? Да.На самом деле, нет. Посмотрите внимательно на то, ЧТО должна делать функция - она должна установить
oven
объект в определенное состояние. Глядя на код , представляется , что государства этогоpan
иdough
объекты на самом деле не имеет большого значения . Поэтому вы должны передатьoven
объект (или смоделировать его) и подтвердить, что он находится в определенном состоянии в конце вызова функции.Другими словами, вы должны утверждать, что
bakeCookies()
испекли печенье .Для очень коротких функций модульные тесты могут показаться немного больше, чем тавтология. Но не забывайте, что ваша программа будет длиться намного дольше, чем вы заняты ее написанием. Эта функция может измениться или не измениться в будущем.
Модульные тесты выполняют две функции:
Он проверяет, что все работает. Это наименее полезная функция, которую выполняет модульный тест, и кажется, что вы, кажется, учитываете эту функцию только при задании вопроса.
Он проверяет, чтобы увидеть, что будущие модификации программы не нарушают функциональность, которая была ранее реализована. Это самая полезная функция модульных тестов, которая предотвращает появление ошибок в больших программах. Это полезно в обычном кодировании при добавлении функций в программу, но более полезно при рефакторинге и оптимизации, когда основные алгоритмы, реализующие программу, кардинально изменяются без изменения какого-либо наблюдаемого поведения программы.
Не проверяйте код внутри функции. Вместо этого проверьте, что функция делает то, что говорит. Если вы посмотрите на модульные тесты таким образом (тестирование функций, а не кода), то поймете, что вы никогда не тестируете языковые конструкции или даже логику приложения. Вы тестируете API.
источник
bakeCookies
тестируются таким образом, они имеют тенденцию к поломке во время рефакторинга, что не повлияет на наблюдаемое поведение приложения.Да. Но вы могли бы сделать это так, чтобы просто протестировать конструкцию.
Здесь следует обратить внимание на то, как эта функция ведет себя при
savePerson
сбое на полпути? Как это должно работать?Это то тонкое поведение, которое обеспечивает функция, которую вы должны применять с помощью модульных тестов.
источник
savePeople
не должен отвечать за обработку ошибок. Чтобы пояснить еще раз, предполагая, чтоsavePeople
он отвечает только за итерацию по списку и делегируя сохранение каждого элемента другому методу, должен ли он все еще проверяться?foreach
конструкцией, а не какими-либо условиями, побочными эффектами или поведением вне ее, то вы правы; новый модульный тест на самом деле не так уж интересен.Ключевым моментом здесь является ваша точка зрения на конкретную функцию как тривиальную. Большая часть программирования тривиальна: присвойте значение, сделайте некоторую математику, примите решение: если это потом, продолжайте цикл до ... В изоляции все тривиально. Вы только что прошли первые 5 глав любой книги, преподающей язык программирования.
Тот факт, что написание теста так просто, должно быть признаком того, что ваш дизайн не так уж и плох. Вы бы предпочли дизайн, который нелегко протестировать?
«Это никогда не изменится». так начинается большинство неудачных проектов. Юнит-тест определяет только то, работает ли юнит, как ожидалось, при определенных обстоятельствах. Получите его, и тогда вы сможете забыть о деталях его реализации и просто использовать его. Используйте это пространство мозга для следующей задачи.
Знание того, что работает, как и ожидалось, очень важно и не тривиально в больших проектах и особенно в больших командах. Если у программистов есть что-то общее, так это то, что нам всем приходилось иметь дело с чужим ужасным кодом. Самое меньшее, что мы можем сделать, - это провести несколько тестов. Если сомневаетесь, напишите тест и двигайтесь дальше.
источник
На это уже ответил @BryanOakley, но у меня есть несколько дополнительных аргументов (наверное):
Сначала модульный тест предназначен для проверки выполнения контракта, а не реализации API; тест должен установить предварительные условия, затем вызвать, затем проверить наличие эффектов, побочных эффектов, любых инвариантов и условий после. Когда вы решаете, что тестировать, реализация API не имеет (и не должна) иметь значение .
Во-вторых, ваш тест будет там, чтобы проверить инварианты при изменении функции . Дело в том , что не меняется в настоящее время не означает , что вы не должны иметь испытание.
В-третьих, имеет смысл внедрить тривиальный тест, как в подходе TDD (который его обязывает), так и вне его.
При написании C ++ для своих классов я склонен писать тривиальный тест, который создает экземпляр объекта и проверяет инварианты (присваиваемые, обычные и т. Д.). Мне показалось удивительным, сколько раз этот тест нарушался во время разработки (например, путем добавления неподвижного члена в класс по ошибке).
источник
Я думаю, что ваш вопрос сводится к:
Как выполнить модульное тестирование пустой функции, не являясь интеграционным тестом?
Например, если мы изменим вашу функцию выпечки файлов cookie, чтобы они возвращали файлы cookie, сразу становится очевидным, каким должен быть тест.
Если после вызова функции нам нужно вызвать pan.GetCookies, мы можем спросить, действительно ли это «интеграционный тест» или «но разве мы просто не тестируем объект pan?»
Я думаю, что вы правы в том, что наличие модульных тестов со всем проверенным и просто проверка функций xy и z были названы отсутствующими значениями.
Но! Я бы сказал, что в этом случае вам следует провести рефакторинг ваших void-функций, чтобы вернуть тестируемый результат ИЛИ использовать реальные объекты и сделать интеграционный тест
--- Обновление для примера createNewUser
ОК, так что на этот раз результат функции не легко вернуть. Мы хотим изменить состояние параметров.
Это где я становлюсь немного спорным. Я создаю конкретные реализации макета для параметров с состоянием
Пожалуйста, дорогие читатели, постарайтесь контролировать свою ярость!
так...
Это отделяет детали реализации тестируемого метода от желаемого поведения. Альтернативная реализация:
Все равно будет проходить юнит-тест. Кроме того, у вас есть преимущество в том, что вы можете повторно использовать фиктивные объекты в тестах, а также внедрять их в свое приложение для UI или интеграционных тестов.
источник
bakeCookies
возврате испеченного печенья является правильной, и у меня была некоторая мысль после публикации. Так что я думаю, что это снова не очень хороший пример. Я добавил еще одно обновление, которое, я надеюсь, дает более реалистичный пример того, что мотивирует мой вопрос. Буду признателен за ваш вклад.Вы должны также проверить
bakeCookies
- что даст / долженbakeCookies(egg, pan, oven)
привести ... Жареное яйцо или исключение? Сами по себе ни те,pan
ни другиеoven
ингредиенты не будут заботиться о них, так как ни один из них не должен, ноbakeCookies
обычно должен давать печенье. В более общем плане это может зависеть от того, какdough
получается , и есть ли это какой -то шанс , что становится простоegg
или , например ,water
вместо этого.источник