company_banner

Если вы используете моки, то вы хоть что-то тестируете?

Автор оригинала: Nicolas Carlo
  • Перевод

Было ли у вас ощущение, что ради тестирования вы делаете код труднее для чтения? Допустим, у вас есть код, который ещё не тестировался. У него есть ряд побочных эффектов, и вас просят сначала прогнать тесты. Вы начинаете следовать советам вроде передачи глобальных переменных в виде параметров или извлечения проблемных побочных эффектов, чтобы сделать вместо них заглушки в тестах.

Но чем дальше вы заходите, тем больше чувствуете себя не в своей тарелке. У вас скапливается куча заглушек для состояний и данных, которые позволяют писать быстрые и надёжные модульные тесты. В результате создаётся ощущение, что вы создали совершенно другой класс, который неточно воспроизводит реальный код.

Вы останавливаетесь и задумываетесь: «Допустимо ли менять сигнатуры кода ради тестирования? Тестирую ли я реальный код или совершенно другой класс, в котором не происходит то, что нужно?» Перед вами может возникнуть дилемма. Вы уверены, что стоит и дальше придерживаться этого подхода? Или это потеря времени?

Вопрос на миллион: для устаревшего кода нужно писать модульные тесты или интеграционные?

Парадокс


Вы могли оказаться в одной из этих ситуаций:



Не знаю, как вам, а мне эти ситуации оооооочень близки. Они смешные, поскольку правдивые (и это неприятно).

Вам говорят, что нужно делать заглушки для проблемных зависимостей и писать модульные тесты. Но в какой-то момент возникает ощущение, что в ваших тестах растёт слепая зона. Какие у вас есть гарантии, что код будет вести себя корректно при использовании настоящей БД в бою? Не станет ли у вас ещё больше проблем, если вы интегрируетесь с настоящей базой данных?

Я тоже сталкивался с этой дилеммой! Долгое время я продвигал Testing Pyramid. Там утверждают, что нужно писать больше модульных тестов, потому что они быстрые и более надёжные, чем интеграционные тесты.

Также я много занимался фронтендом. В последние годы набирает популярность мантра «Пишите тесты. Не слишком много. В основном, интеграционные», которую продвигает Кент Доддс. Он очень умён и является авторитетом в тестировании фронтенд-приложений. Есть даже инструменты вроде Cypress для тестирования большинства веб-приложений с помощью сквозных тестов! Ещё ближе к пользователю!

Как вы разрешите этот парадокс? Как будет правильно? Стоит ли делать заглушки, чтобы тесты были быстрыми и надёжными? Или лучше писать интеграционные тесты, которые менее надёжны, но вылавливают больше проблем?

Прежде чем мы продолжим, позвольте сказать: замечательно, что перед вами встала эта дилемма. Она представляется вам проблемой, но это означает, что вы движетесь в правильном направлении! Это естественное препятствие на пути, и вы его преодолеете. У этой загадки есть решение.

Практичный взгляд на тестирование


Я на некоторое время погрузился в эту проблему и попробовал разные подходы. Решение пришло после памятной дискуссии с J. B. Rainsberger.

Я понял, что никакого парадокса нет. Есть разные определения «модуля».

Мне кажется, слово «модуль» вносит путаницу. У людей разное понимание, что это такое. Обычно новички в тестировании считают модулем функцию или метод. Потом они понимают, что это может быть целый класс или модуль. Однако истина, как и многое другое в жизни, зависит от ситуации.


Изолированные тесты


Я считаю, что деление на «модульные» и «интеграционные» тесты недостаточно понятно. Такая категоризация приводит к проблемам и спорам, хотя цель у нас одна: облегчить изменение ПО с помощью быстрой и точной обратной связи, когда вы что-то ломаете.

Поэтому я предпочитаю говорить об «изолированных» тестах.

Не уверен, что этот термин лучше. Но он мне пока нравится, потому что заставляет задаваться крайне важным вопросом: изолированные от чего?

Мой ответ: изолированные от того, что сложно тестировать.

Что сложно тестировать


Случайность, время, сеть, файловую систему, базы данных и т.д.

Всё это я называю «инфраструктурой», а остальное — «доменом». Сочетание домена и инфраструктуры выглядит очень полезным и практичным взглядом на проектирование ПО. И не только для меня. Такое разделение заложено в основу многих удобных для сопровождения архитектур, таких как гексагональная архитектура (Hexagonal Architecture) (известная как «порты и адаптеры» (Ports and Adapters)), луковая архитектура (Onion Architecture), чистая архитектура (Clean Architecture), функциональное ядро / императивная оболочка (Functional Core / Imperative Shell) и т.д.

Всё это нюансы, которые говорят об одном: домен и инфраструктуру лучше разделять. Эту идею продвигает и функциональное программирование: изолированные побочные эффекты на периметре вашей системы. Побочные эффекты — это инфраструктура. А её трудно тестировать.

Да, но чем это поможет?


Если коротко, это подсказывает вам, что именно нужно извлекать и заменять заглушками. Не переносите бизнес-логику в инфраструктуру, иначе намучаетесь с тестированием.

Изолированный от инфраструктуры домен тестировать легко. Вам не нужно делать заглушки для других объектов домена. Можно использовать тот же код, что используется в проде. Нужно «всего лишь» избавиться от взаимодействия с инфраструктурой.

«Но ведь в коде инфраструктуры могут быть баги!»

Если держать её достаточно компактной, то риски снижаются. Но в основном вы уменьшаете размер кода, который необходимо протестировать при интеграции с реальной базой данных.

Суть вот в чём: у вас должна быть возможность тестировать все виды поведения вашего приложения без сохранения данных в PostgreSQL. Нужна in-memory БД. Вам всё ещё нужно проверять работоспособность интеграции с PostgreSQL, но для этого достаточно всего несколько тестов, а это заметное отличие!

Всё это была теория, давайте вернёмся к реальности: у нас есть код, который ещё не протестирован.

Возвращаемся к устаревшему коду


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

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

Затем сделайте так, чтобы инфраструктура зависела от домена, а не наоборот. В бизнес-логике должно быть как можно меньше инфраструктуры. У вас получатся быстрые, надёжные, изолированные тесты, покрывающие большую часть логики приложения, и несколько интеграционных тестов для проверки работы инфраструктурного кода с реальной базой данных, внешним API, системными часами и т.д.

Останутся ли у вас заглушки? И да, и нет.

У вас будут альтернативные реализации инфраструктуры, поэтому вы всё ещё сможете исполнять production-логику без использования настоящей БД.

Как узнать, что реализация БД работает корректно? Напишите несколько интеграционных тестов. Как раз столько, чтобы хватило для проверки корректности поведения. J. B. Rainsberger называет это «Тестами контракта». Думаю, лучше всего пояснить на этом примере.

Можете следовать этому рецепту


Вот как можно работать с устаревшим кодом:

  1. Используйте методику Extend & Override для ликвидации неприятных зависимостей. Это хороший первый шаг. Вырежьте инфраструктурную часть из оставшегося кода, чтобы можно было начать писать тесты.
  2. Инвертируйте зависимость между кодом и извлечённой инфраструктурой. Для этого используйте внедрение зависимостей (Dependency Injection). Придётся принять архитектурные решения: можно ли сгруппировать некоторые извлечённые методы в согласованный класс? Например, сгруппировать то, что относится к сохранению и извлечению сохранённых данных. Это ваш инфраструктурный адаптер.
  3. Если вы пишете на статически типизированном языке, то извлеките интерфейс из созданного класса. Это простой и безопасный рефакторинг. Он позволит завершить инверсию зависимости: сделает код зависимым от интерфейса, а не от конкретной извлечённой реализации.
  4. В тестах сделайте фальшивую реализацию этого интерфейса. Скорее всего, она будет хранить информацию в памяти. Она должна воспроизводить такой же интерфейс и вести себя так же, как и боевая реализация, которую вы вытащили.
  5. Напишите тест контракта. Это гарантирует, что фальшивая и боевая реализации ведут себя так, как вы ожидаете! Тест контракта проверит корректность работы интерфейса. Поскольку вы прогоните одинаковые серии тестов на обеих реализациях, вы будете знать, что можно доверять своим изолированным тестам. Вы в безопасности! Отличное руководство для начинающих.
  6. Сделайте так, чтобы интерфейс выражал потребности бизнеса, а не подробности реализации. Рекомендую развивать архитектуру в этом направлении. Но это приходит с практикой и не должно быть помехой.

После того как вы это сделаете, тесты покроют 80 % вашего кода, потому что в нём не будет сложных мест. Останется написать несколько тестов для проверки корректности подключения, но не тратьте на это силы, пока тестируете чистую логику. Больше никаких слепых зон!

Также можете улучшить архитектуру и удобство сопровождения кода. Сначала выясните, сколько зависимостей на самом деле присутствует в коде. Чем больше из них вам будет известно, тем легче будет определить отсутствующие абстракции, которые ещё больше упростят код.

Наконец, удобный для тестирования код можно легко использовать повторно. Для вашего опыта это имеет приятный побочный эффект… однажды вы его распробуете.

Всё будет отлично


Это непросто.

У меня ушло несколько лет на размышления. И я всё ещё читаю, слушаю и пробую этот подход в разных ситуациях (пока что работает).

Но всё это важно: тестирование, архитектура и устаревший код. Все эти навыки взаимосвязаны. Практикуясь в одном, вы растёте и в другом.

Вам может показаться, что потребуется много работы и у вас нет сейчас на это времени. Но я говорил, что для этого нужно много практики. Если хотите, можете использовать эти упражнения.

И последнее: вам не обязательно сразу выполнять все 6 этапов. Можете заниматься этим итеративно. Нужно верить в этот процесс (вот почему он хорошо подходит для практики), но он работает. Если вас беспокоит потеря времени на излишнее количество заглушек, то это потому, что вы всё ещё на первом этапе. Идите дальше! Заглушек станет меньше, а у вас появится больше уверенности в точности тестов.

Инфраструктурные адаптеры + тесты контрактов = недостающие части пилы для устаревшего кода.

Возвращайтесь к работе и продолжайте писать эти тесты. А затем идите дальше.
Mail.ru Group
Строим Интернет

Похожие публикации

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

    +3
    Есть неплохая книжка на тему написания тестов к легаси коду — Working Effectively with Legacy Code.
      +8
      у вас должна быть возможность тестировать все виды поведения вашего приложения без сохранения данных в PostgreSQL. Нужна in-memory БД

      В теории выглядит хорошо, но на практике sqlite и postrgesql отличаются значительно. Если ваш проект не использует ORM, то тогда вы рискуете построить свой, чтобы тестировать «in memory db», а в проде работать в postgresql.
        0

        Вы не путаете ORM и DBAL?


        И вообще, in memory db вовсе не означает, что какой-то sqlite нужно тянуть. Это может быть просто массив или хэшмэп в памяти

        +1

        но что мешает в качестве In-Memory БД для PostgreSQL использовать PostgreSQL на базе testcontainers? крайне мало у какого нынешнего разработчика не найдётся Docker на компе

          +1
          Так и живём. Только я слышал мнение, что это нельзя назвать юнит тестированием. Хотя по мне хоть мартышкой назови, главное, чтобы работало и делало своё дело.
            0

            так, с in-memory на sqlite тоже не будет юнит-тестом...

          +1
          Суть вот в чём: у вас должна быть возможность тестировать все виды поведения вашего приложения без сохранения данных в PostgreSQL. Нужна in-memory БД. Вам всё ещё нужно проверять работоспособность интеграции с PostgreSQL, но для этого достаточно всего несколько тестов, а это заметное отличие!

          Я пробовал писать тесты для Entity Framework именно с in-memory тестов. Оказалось, что с ним куча ограничений и майкрософт рекомендует переходить на более похожие на настоящие базы типа sqlite, посидев на них пришлось честно уползти на реальный MS SQL localdb.


          Когда оказывается, что in-memory не ловит ограничения внешних ключей — вы потом сами захотите более медленные интеграционные тесты, чем какие-то быстрые, которые ничего не диагностируют.


          В общем, у меня другой опыт использования.


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

            +1

            Так в том и дело, что если вам нужны ограничения внешних ключей то вы пишете уже интеграционный тест а не юнит, и не важно с инмемори он базой или нет.

            0
            На словах то все это понимают… Вот бы реальные примеры, а не бла бла… Но зато такую статью можно показать манагеру который топит за скорость.
              0
              Вопрос к автору: не считаете ли вы, что куча интерфейсов на каждый чих лишь усложнит систему, а не упростит?
                0
                Моки (как и все) имеют место на жизнь, если их грамотно использовать, в случае модульных или тестов, на сложные сервисы. Главное чтобы замоканный компонент должен быть 100% покрыт юнит тестами. А еще лучше, когда мокается самый нижний уровень, например в случае тестирования сервисов, которые отправляют запросы на внешний сервис, можно подставить сам ответ (json, XML), словно мы выполнили запрос и получили ее в ответ.
                  0

                  Немного не понял про интерфейсы. В динамически типизированных их выделять не надо? Как по мне очень удобно с ними моки делать прямо в тестах анонимными классами. И на архитектуру хорошо влияет, если следование SOLID называть хорошим. Заметный такой звоночек о нарушении ISP, если постоянно в тестах мокаешь интерфейс, в котором большую часть методов совсем тупыми заглушками имплементируешь и нигде не проверяешь, просто чтобы зарабатало.

                  Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                  Самое читаемое