Комментарии 12
а как обстоит дело с логированием сгенерированных параметров? Чтобы можно было в будущем воспроизвести падение теста
как и прежде, всё логгируется - это продемонстрировано в скриншотах в статье
Не надо логировать сгенерированные параметры. Вообще не надо генерировать случайным образом тестовые данные, если не хочется получить набор тестов, которые магическим образом то проходят, то нет. Если лень готовить руками данные для похожих тест-кейсов, можно их сгенерировать однажды и сохранить.
Странно, что автор назвал статью "Искусство Unit-тестирования", и в ней упоминает имя Роя Ошерова (RoyOsherove), но ничего не сказал о хорошей книге "Искусство юнит-тестирования" этого самого Роя Ошерова. В ней Ошеров как раз очень популярно расписывает, почему использовать случайно сгенерированные данные для тестов — так себе затея.
Решаю похожую проблему, через инициализацию happy path в конструкторе. Далее набрасываю в Arrange через _mock.Setup() разные кейсы для проверок. Тем самым тесты получаются детерминированными и с небольшой секцией Arrange
Я полагаю, что ни у кого не вызывает сомнений необходимость писать юнит тесты.
Ну, у меня вызывает - в случае произвольных программ, особенно - приложений, имеющих набор сценариев использования: лично мне кажется, что там лучше писать интеграционные тесты на эти сценарии.
Но в случае библиотек - где пользователи (прикладные программисты) действительно используют отдельные модули, а потому, код, проверяющий эти модули фактически по отдельности, писать все равно надо, смысл писать модульные тесты таки есть - потому что они удобно запускаются из IDE. Поэтому я для своей библиотеки ActiveSession их написал.
По сути, при написании тестов узкое место одно — это Arrange этап, поскольку Act и Assert в среднем занимают 2 строчки. А вот Arrange самый интересный и объёмный. Подготовка объектов, коллекций, настройка интеграций, конфигураций — всё там.
Мой ответ на это - создание TestSetup-класса. В его конструкторе производится вся эта нудная подготовка, которую нужно делать в тестоовых методов много раз. А в тестовых методах просто создаются экземпляры этого класса, при необходимости - подстраиваются их свойства и потом уже выполняются шаги Act/Assert. А ещё, если для одного модуля есть группы схожих тестов (например, несколько тестов одного метода для разных путей его выполнения) удобно использовать наследование: создать производный TestSetup-класс, унаследованный от базового? и сделать в его конструкторе дополнительную настройку для этой группы тестов. Примеры, как я это делаю, можно найти в репозитории по ссылке, в тестовом проекте ActiveSessions.Tests.
лично мне кажется, что там лучше писать интеграционные тесты на эти сценарии.
Интеграционные тесты дороже при создании и поддержке, поэтому их лучше использовать для проверки верхне-уровневых пользовательских сценариев (типа — пользователь залогинился, положил товар в корзину, нажал кнопку "Оплатить" и заказ сформировался), а мелкие аспекты (типа — кнопка "Оплатить" после нажатия поменяла цвет) покрывать юнит-тестами.
Гонять интеграционные тесты для мелочёвки нецелесообразно, т.к. они а) идут сильно дольше по времени (то есть мы не получаем быстрого фидбэка при большом количестве тестов), и б) не дают нужной детализации проблемы. Например, если случай "кнопка "Оплатить" после нажатия поменяла цвет" мы будем проверять в интеграционном тесте, и у нас отвалился платёжный терминал на тестовом стенде, то этот тест на цвет кнопки тоже упадёт, хотя с кнопкой на самом деле проблем нет. То есть, прохождение тестов начинает зависеть от разных факторов, и доверие к тестам снижается.
Интеграционные тесты дороже при создании и поддержке
Зато их нужно меньше - сценариев обычно не так много, как компонентов и вариантов их поведения. И меньше риска упустить что-нибудь между тестами. А то вот я недавно в своей библиотеке - для нее я использую модульные тесты, потому как к библиотеке интеграционные тесты придумывать сложно - поменял тип переменной для интерфейса, который запихиваетcя в DI-конейнер обобщенным методом, на тип, унаследованный от ожидаемого, который был раньше - и всё сломалось, хотя все тесты зелёные. В замоканный контейнер всё помещается обобщенным методом для уоторого тип сервиса (реальный) выводится автоматически, и потом запихнутое достается с ожидаемым типом (ибо реальный интерфейс сервиса - унаследованный от того, который ожидается, а потому в него всё автоматически пребразуется, а Moq просто так заставить ловить эту разницу типов непросто). В другом же тесте, где надо доставать, из замоканного контейнера всё достается, потому что там тип сервиса замокан правильный. А вот в тестовом приложении вылезло исключение, что сервис в контейнере не найен - ибо ищется другой тип. Пришлось явно указать тип сервиса при помещении в контейнер и, заодно, написать-таки специальный модульный тест, чтобы в следующий раз это ловилось.
Например, если случай "кнопка "Оплатить" после нажатия поменяла цвет" мы будем проверять в интеграционном тесте, и у нас отвалился платёжный терминал на тестовом стенде, то этот тест на цвет кнопки тоже упадёт,
Нормальный цвет тестов - зеленый, именно он ожидается в большинстве случаев, когда задача теста - проверить, что в существующем поведении ничего не сломалось. А если что-то сломалось, то на то, чтбы разобраться, что именно, можно и времени немного потратить. Особенно - если тестовый фрейворк этому помогает: xUnit для C#, например - помогает: он выбрасывает на месте не прошедшей проверки исключение, а обработчик потом показывает стек вызовов, где оно возникло.
Ну, а ещё интеграционный тест должен, по моему мнению, проверять не всю user story (типа той, что вы написали), а по отдельности ее слабо связаанные части: пользователь залогинился - проверить что токен (или что там) он получил; положил в корзину товар - проверить, что товар есть в корзине (а если бэк должен об этом знать - что и бэк оповещён), нажал кнопку оплатить - кнопка окрасилась в тот цвет, в который она себя окрасила, и запрос в платежный терминал ушел - и так далее. Правда, называть ли такой тест интеграционным или ещё как - это вопрос.
PS А ещё я это пишу со своей C#овской точки зрения, где я избалован статической типизацией, которая при компиляции ловит множество дурацких ошибок, которые в языках с динамической типизацией приходится ловить тестами.
Но ведь и статья, кстати - она тоже про C#.
Правда, называть ли такой тест интеграционным или ещё как - это вопрос.
В целом, вполне похоже на интеграционный тест (т.к. проверяется связь между несколькими компонентами). Ни или e2e — если количество задействованных компонентов системы по максимуму приближено к промышленной конфигурации
Ну, а ещё интеграционный тест должен, по моему мнению, проверять не всю user story (типа той, что вы написали), а по отдельности ее слабо связаанные части: пользователь залогинился - проверить что токен (или что там) он получил; положил в корзину товар - проверить, что товар есть в корзине
Ну я имел в виду, что всё равно надо прогнать всю историю, чтобы сделать эти отдельные проверки. Т.е. всё равно чтобы юзер положил товар в корзину — он должен сначала залогиниться. И тут как раз если допустим он не смог залогиниться — то остальные проверки просто не пройдут. Т.е. мы видим, что проблема с логином. Но мы не знаем наверняка, только ли с логином проблема, или и с корзиной тоже — до корзины просто дело не дошло.
Но вообще таки да, небольшие приложения удобно покрыть интеграционными тестами и не заморачиваться. Юнит-тестами можно покрывать те части, которые логически можно проверить отдельно, чтобы можно было в моменте этот тест прогнать и посмотреть всё ли в порядке. Если всё в порядке — тогда уже коммит, сборка, и гонять интеграционные тесты. Ну это как я это понимаю.
Библиотеки и тулы это хорошо конечно. Но они не решение, а средство, при чём зачастую ограниченное. Ещё, как это ни печально, иногда некоторые средства перестают поддерживаться, устаревают.
Поговорим о решении.
Используем настройку Arrange по happy path, т.е. на идеальный сценарий, прям в конструкторе теста. Таким образом основной успешный тест будет самым простым, буквально в пару строк.
А вот все отклонения вносятся уже в настроенный идеальный сценарий, в код каждого теста. Добавляется ещё несколько строк для каждого отклонения.
Это позволяет всегда оценивать все тесты с учётом изменений в идеальном сценарии, отказаться от кучи бойлерплейта, просто вносим точечные мутации в Arrange и проверяем ровно то, что нам интересно.
К каждому из трех представленных "абстрагированных от рутины" тестов есть вопросы:
Handle_HappyPath_DoesNotThrow
Все зависимости класса подменены. В какой ситуации этот метод может бросить исключение? Я вижу 2 варианта:
Ошибка валидации входных данных - см. ниже.
NRE от мока. Следовательно, тест проверяет не бизнес-логику, а вашу реализацию моков, насколько качественно они имитируют поведение сервисов в сценарии happy path. Что не имеет практической ценности.
await Assert.ThrowsAsync<SomeException>(
() => handler.Handle(request with { Field1 = -1 }, ct: default));
Если вынести проверку входных данных в валидатор, данный тест заменяется легковесным юнит-тестом валидатора.
response.ExternalDataCollection.Should().BeEmpty();
Что проверяет этот тест, если провайдер данных замокан?
Искусство Unit-тестирования: сокращаем Arrange до нуля