Введение
Когда мы что-то читаем о тестировании, часто встречаем упоминание о пирамиде тестирования.

Ее идея, как известно, в том, что модульные тесты должны формировать основу стратегии тестирования — они быстрые, многочисленные и покрывают самые мелкие части системы. Над ними расположен слой менее многочисленных и более медленных интеграционных тестов, а на самой вершине — наименьшая по количеству часть: сквозные (E2E).
Логично? Давайте разбираться.
Модульное
Это подход, при котором отдельные, самые маленькие тестируемые части программы (называемые модулями, а чаще юнитами, например отдельные программные функции, методы и классы) тестируются изолированно, чтобы убедиться, что они по отдельности работают так, как положено.
Интеграционное
Это уровень, на котором отдельные программные компоненты и модули логически «объединяются» и тестируются вместе, чтобы убедиться, что они корректно работают как группа и что взаимодействия между ними ведут себя ожидаемым образом.
Сквозное (E2E)
Это подход, при котором вы проверяете весь пользовательский путь (воркфлоу) приложения от начала до конца, имитируя пользовательские действия.
Проблемы пирамиды
Определения модульного и интеграционного тестирования не дают нам четких указаний, как их разграничить. Используются довольно-таки расплывчатые определения, что такое модуль/юнит и что такое компонент — и эти определения недостаточно конкретны.
Итак, зададим себе несколько вопросов:
- В микросервисе — нужно ли тестировать каждую отдельную сущность отдельно, например каждый геттер или сеттер (ведь это же «самая маленькая тестируемая часть»)?
- Если один класс или функция вызывает другой, сделает ли это автоматически интеграционный тест?
- Поскольку приватные методы нельзя надежно протестировать, стоит ли сосредоточиться только на публичных?
- Если мы сосредоточимся только на публичных методах, то публичный интерфейс API — это просто его конечные точки?
- Если мы тестируем только через публичный интерфейс API, должны ли мы мокировать (здесь подробно) приватные части или подключаться к реальной базе данных?
- Если мы мокируем базу данных, как мы можем быть уверены, что тестируем фактический поток приложения, а не просто мокированное поведение?
- Если схема базы данных принадлежит сервису, делает ли это ее частью модуля?
Если вас интересуют подобные сложные вопросы, я рекомендую статьи Мартина Фаулера, где он, в частности, вводит такие концепции, как:
- Поверхностное / узкое интеграционное тестирование (Shallow / narrow integration testing)
- Широкое интеграционное тестирование (Broad integration testing)
- Обособленное модульное тестирование (Solitary unit testing)
- Совместное модульное тестирование (Sociable unit testing)
Эти статьи вот:
Написание тестов
Если мы сосредоточимся на модульном подходе (unit-based approach) к тестированию потока «Создания пользователя», то можем с ходу придумать несколько тестов, которые проверяют, что:
- Конечная точка зарегистрирована по правильному URL.
- Привязка модели (model binding) корректна.
- Модель правильно валидируется.
- Валидатор вызывается бизнес-логикой или классом контроллера.
- Конечная точка вызывает класс бизнес-логики.
- Класс бизнес-логики вызывает слой данных.
- Из репозитория возвращаются корректные данные.
- Данные корректно трансформируются (например из сущности (entity) в ответ).
Однако в этом сценарии каждый шаг зависит от предыдущего. Вот почему моим первым шагом было бы написать тест черного ящика, который вызывает API в модели и проверяет, возвращается ли правильный ответ. Таким образом, один тест уже охватывает большую часть потока.
Что здесь важно: потребительский поток (consumer flow) — если API развернут, это именно то как создается пользователь (будь то внешним сервисом или веб-приложением). Мы хотим, чтобы этот тест работал нормально без частых изменений.
Если вам не нужно постоянно дорабатывать такие тесты, вы обретаете уверенность в том, что в вашем API не произошло ломающих изменений.
Как только ваш основной поток протестирован через API, вам также следует проверить покрытие кода для нового кода и задать себе вопросы:
- Стоит ли мне тестировать это с помощью вызовов API?
- Следует ли мне удалить строки кода, которые никогда не могут быть достигнуты через обычный поток?
Пример: Если мы изменим процесс создания пользователя так, чтобы валидатор проверял существование пользователя до обращения к репозиторию, то метод репозитория возвращает FirstOrDefault. В этой настройке он никогда не может вернуть null в нашем измененном потоке, потому что валидатор сначала выдает ошибку. Единственный способ воспроизвести null — это использование заглушек (mocking), что может указывать на ненужность кода.
- Стоит ли мне создать заглушенную среду (mocked environment) для тестирования труднодоступного поведения?
Пример: Если мы вызываем внешний сервис для получения некоторых данных и хотим протестировать логику повторных попыток (retry logic), вызов фактической конечной точки API может скрыть то что мы хотим протестировать. Имеет больше смысла протестировать конкретный код, отвечающий за повторные попытки, напрямую.
Единственные вещи, которые действительно важны — это то, как ваши тесты:
- точно тестируют то, для чего они предназначены
- отражают реальные сценарии
- легко читаются и понимаются
- могут адаптироваться к изменениям в коде, не требуя чрезмерных доработок, — если только это не настоящее критическое изменение.
Как подойти к тестированию?
Вернемся к пирамиде тестирования и пересмотрим то, что мы узнали до сих пор. Лучшие тесты (на вершине) — это сквозные (E2E) тесты, потому что они:
- Отражают реальные сценарии.
- Легко запускаются с точки зрения пользователя.
- Требуют изменений только в случае изменения потока, с которым сталкивается пользователь.
Однако E2E-тесты также медленные, нестабильные (flaky), требуют полной инфраструктуры и окружения для запуска и могут быть запущены поздно в процессе разработки.
Учитывая это — и следуя принципу «сдвиг влево» (Shift Left) (перенос обратной связи и обнаружения проблем на более ранние этапы процесса разработки) — большинство тестов должно быть на уровне сервиса.
Неважно, называются ли они модульными, интеграционными или поверхностными интеграционными (Shallow Integration) — важно то, что они могут быть запущены:
- На уровне микросервиса.
- Локально или в CI-конвейере, без сложной настройки.
- Без зависимости от полноценной инфраструктуры, похожей на продакшен.
Если тест подключается к базе данных, это нормально — это не ваша продакшен-база данных, и она не содержит продакшен-данных, которые могли бы вызвать конфликты. Мы можем протестировать эти части позже на этапах, более специфичных для окружения.
Что нельзя надежно протестировать на этапе сервисного тестирования, так это внешнее взаимодействие — но именно здесь «сияет» контрактное тестирование. Если ваш сервисный тест мокирует внешний вызов, это взаимодействие также должно быть определено в контрактном тесте, чтобы гарантировать, что оно соответствует реальному API или сервису.
Другие аспекты — такие как вызовы внешних ресурсов, подключение к базе данных или интеграция с другими сервисами в вашей среде — могут быть проверены в E2E тестах. Эти тесты проверяют фактическое поведение от начала до конца, и, в отличие от некоторых других сценариев E2E, они, как правило, более стабильны и имеют меньше недостатков. Например, если один вызов к базе данных работает в деплой-среде, велика вероятность, что и остальные тоже будут работать.