Как стать автором
Обновить

Моки и мокизмы, программистский идеализм, правильная изоляция в тестировании

Уровень сложностиСредний
Время на прочтение6 мин
Количество просмотров3.3K
Всего голосов 4: ↑2 и ↓20
Комментарии10

Комментарии 10

А почему, позвольте, должно быть или то, или другое?! Меня учили как-то так:


  • Юнит-тесты тестируют функциональность класса изолированно, зависимости мокаются. Потому что у меня должна быть хоть какая-то уверенность что класс ведет себя нужным образом ПЕРЕД тем, как я его буду выпускать "в люди" — соединять с другими классами. Есть виртуозы отладчика, которые умеют писать код без тестов — а я не умею (и в жизни я встречался с ситуациями когда отладчик только консольный или его вообще нет — так вот, тесты дешевле...).
  • Интеграционные тесты тестируют совокупности классов. При этом могут существовать "ни-рыба-ни мясо" тесты, которые в отличие от интеграционных не собирают всю структуру приложения, но инстанциируют целые графы классов и проверяют какие-то пользовательские истории. Это тоже совершенно необходимый этап тестрования, без которого у меня не поднимается рука нажать 'Create MR'. Потому что: чем я докажу, что моя реализация корректна ?

Дополнительно, отмечу что для написания корректных тестов (и для решения проблемы "хрупких" тестов) — в мире придумана идеология 'Standard test environment'. Которая подразумевает что вы к своему проекту подключаете test-dependency, в которых специально обученные люди (BA или тест-инженеры) вам приготовили стандартные объекты из которых вы будете складывать окружение ваших тестов. То есть, если у вас зависимость "платежный шлюз", то вы не мокаете ее сами — а достаете готовый мок из тестовой библиотеки и запускаете. Поскольку этот мок настроен только на определенные запросы и ответы — у вас есть библиотека стандартных тестовых объектов (платежи, документы, и т.п.). И если вы именно эти объекты используете в своих тестах, то гарантируется во-первых, что всё со всем совместимо — и во-вторых, не надо лазить по кодам всех проектов и менять устаревшие данные в test/resources чтобы оживить тесты когда новые стори меняют существующую функциональность. Новую версию стандартной среды зарелизят, вы ее обновите в качестве зависимости в проекте — и если ваш код не сломался, значит все работает. А если сломался — ну значит надо чинить. А не так как обычно в проектах — в десяти местах мокается одно и то же, но разными способами. Потом оно ломается — и поди-пойми где правильно теперь, а где — нет...


И еще одна мысль — не тестируйте код. Тестируйте поведение. Если вы закладываетесь на определенное (нетривиальное — не надо тестить геттеры и сеттеры, а также стандартную библиотеку: их тестировали другие люди не глупее вас!) поведение кода — закрепите его тестом. Это помогает в разработке — легче собирать систему из заведомо работающих компонентов (и разбираться только с проблемами интеграции), чем из заведомо неработающих (и иметь геморрой и головную боль одновременно). Это помогает в понимании системы — тест явно показывает примеры, какие входные данные мы ждем, как примерно ведет себя окружение, и какой результат получается. Иногда просмотр тестов позволяет вообще не смотреть внутрь класса (если только не интересны детали реализации).

Добрый день! Это хорошие рассуждения.
Полагаю, можно начать с того, что единственное что вы "должны" - это в контракте с работодателем или с заказчиком, а остальное решается между разрабами, и решается очень по разному.

Описанное вами есть сугубо "мокистский" (очень не люблю это слово) подход. Только заместо "интеграционных" тестов я предпочитаю что-нибудь, что заходило бы через эндпоинты или через event listener, а проверяло бы HTTP Response, сайд эффекты в базе, события, или прочие внешние проявления поведения кода. Это ещё иногда называется фуникциональными тестами, системными, E2E, зависит от команды.

То, что вы называете "не рыба и не мясо" - как раз юнит тестирование по Фаулеру и Хорикову. У них "юнит" это не единица кода, а единица бизнес требований. Если вы почитаете их труды, то они вполне детально объясняют, в чём видят пользу последнего, что я и обсуждаю.

Соответственно, возникает вопрос, что выбрать. Вы можете выбрать всё сразу, но это может быть черезчур. Хотя и такое бывает. Я предпочитаю искать компромисс.

Что касается практик внутри конкретного бизнеса, может доходить до крайнего. Например, весьма непринято погружаться в тестирование у некоторых консалтеров. Такие бизнесы могут ставить своей задачей выдать готовый прототип за 2-3 месяца и отдать его кому-либо на дальнейшее поддержание. Предложение "давайте лучше тестировать" тут могут встретить в штыки. Хотя, повторюсь, крайность.

E2E тесты я оставляю за скобками — их обычно пишут другие люди с другим инструментарием. Меня больше интересуют тесты, которые можно запустить из IDE или которые запускаются автоматически как часть любого билд-пайплайна (а-ля mvn verify). В больших приложениях (где микросервисы, message-oriented-middleware, и проч) — E2E это отдельная песня...


Что касается локальных тестов, то я вижу три их вида (мы активно используем spring-boot):


  • Юнит-тесты — не требуют поднятия контекста спринга и не требуют спринг-раннера для запуска тестов, зависимости мокаются и инжектятся через конструктор. Работают очень быстро, добавляют уверенности что мы не ошиблись реализуя логику нашего класса.


  • Semi-integration (ни-рыба ни-мясо) тесты. Поднимают ограниченный контекст спринга через бины @TestConfiguration, запускаются через спринговый раннер. Тестируют совокупности классов на реализацию сторей (или существенных их частей). Это тоже довольно дешево.


  • Интеграционные тесты — используя testcontainters, поднимают некоторые зависимости в реальности (база данных, middleware), а некоторые мокают на уровне API (wiremock). Данные для теста подаются через штатные точки входа в приложение (rest, middleware), и контролируются преимущественно через штатные же выходы. Дополнительно проверяются side-эффекты в БД. Это по времени существенно дороже из-за запуска тестовых контейнеров (но все-равно дешевле чем отлаживать вживую на серверах).



Ну и E2E (как четвертый вид, к которому я имею мало отношения) — это запуск всего созвездия сервисов (втч проверка правильности конфигураций их соединения между собой), и отработка целых сценариев взаимодействия. Такое обычно запускают в ночь, потому что оно работает от получаса до нескольких часов (смотря какой набор сценариев заказываешь у E2E спецов). Плюс эти самые спецы сейчас могут быть заняты чужой заявкой, и встанешь в очередь… В общем, если в твоем коде выявили ошибку на E2E — это уже "не первый класс, не чистая работа..." и тебе должно быть стыдно — потому что это твои локальные тесты должны такое ловить, а не последние safety check перед продакшеном… Позорнее может быть, только если с ручного тестирования бага на тебя прилетит!

У вас хороший сетап тестов, и я бы сделал так же. Но с чем именно вы спорите? 🙂

Я не согласен с подходом, что юнит-тесты не нужны. Проблема в том, что их обычно не умеют готовить (тестируют тривиальную функциональность типа геттера-сеттера или тривиальной математики, пытаются добиться 100% test coverage выворачивая наружу кишки класса, и так далее). Если же тестировать нетривиальное поведение, то таких проблем не возникает. Например:


  • В проекте мы пишем класс, который проверяет правильность считанного кода EAN-13 (там примитивный алгоритм контрольной цифры mod 10).
  • Первый метод у нас считает для заданной строки 12 знаков — контрольный разряд. Как мы его проверяем? Идем и смотрим на продукте из супермаркета код. Подаем на вход метода, сверяем с заведомо корректной контрольной цифрой. Если надо — добавляем еще пару крайних случаев (ну, например, чтобы контрольный разряд был 0, а не, случайно, 10).
  • Второй метод у нас проверяет корректность кода, рассчитывая разряд по 12 цифрам и сверяет с 13-ым. Метод проверки тот же — подаем на вход заранее корректный (снятый с упаковки) код, и заведомо некорректный. Убеждаемся что поведение соответствует.
  • После этих тестов я уверен, что контроль кодов EAN13 у нас реализован правильно. Дальше я могу этот класс подключать к тестам более высокого уровня.
  • Утверждение что этот функционал можно было бы проверить в тестах интеграционных или E2E — верное. Этот код наверняка будет вызван, и ошибка в нем приведет к падению тестов. Но вот удовольствие разбираться в них — сомнительное. Если код не опознан как корректный EAN13 он может быть дальше автоматически распознан как другое семейство кодов (без контрольного разряда но такой же длины). Это приведет к неожиданному поведению системы и будет замечено тестами, но искать вот такое вот — можно и пол-дня и день.
  • Что касается предлагаемого запуска тестов в строгом порядке — я скорее против этого. Чем независимее тесты друг от друга (и от порядка запуска) — тем проще их поддерживать. В идеале, каждый тест должно быть можно запускать в одиночку и в любой момент.
  • Не совсем понятно, как при запуске тестов на поднятом инстансе приложения подряд учитывать сайд-эффекты в БД. То-ли ресетить ее после каждого слоя тестов (время!), то ли учитывать состояние БД после N предыдущих тестов в последующем (ужасно хрупкая структура при увеличении числа тестов).

В целом, я пришел к мысли что не надо искать новые технологии или серебряные пули — надо просто аккуратно использовать то, что имеем. И ситуация улучшится.

Вы так и не поняли суть фрактальности. Компонентный тест модуля без зависимостей ничем не отличается от модульного. А без кишок наружу вы зависимости не замочите.

На проде у вас никогда не будет чистого состояния, так что тестирование на чистом состоянии просто, но не релеватно.

А кто сказал, что тесты ведутся на чистом состоянии? Из объектов standard test environment можно перед тестом создать такое состояние, которое нужно. Можно и вообще произвольное, но это грозит проблемами с поддержкой тестов в будущем.

Продолжайте рассуждать, выходит годно, статье «+».

Оба тезиса объединяет одна идея: код меняется при изменении требований, а требования чаще всего меняются в терминах вашего бизнеса (в смысле - предметной области, а не денег).

Поэтому если код и тесты писать (читай - производить декомпозицию) в бизнесовых терминах, то потом изменения будут даваться легче. Ну и читать проще, самим же разработчикам - менеджеры тут не причем.

На мой взгляд есть сильное заблуждение, что unit в OOP это обязательно отдельный класс. Класс может быть просто деталью реализации, которая сама по себе не несёт особой ценности в терминах вашей предметной области (DTO, например). Т.е. вы не можете про него сказать что-то вразумительное с точки зрения бизнес требований, не скатываясь на уровнь технических терминов - деталей реализации. Здесь тесты / моки в самом деле это пустая трата времени. Тестировать в изоляции нужно самостоятельные единицы, кторые что-то значат в вашей модели. А из скольки классов они состоят - одного или нескольких - это вопрос второстепенный.

Зарегистрируйтесь на Хабре, чтобы оставить комментарий

Публикации