- Интеграционных должно быть больше чем модульных и сквозных
- Две проблемы: медленно и сложно
- Нужно расширять scope интеграционных тестов
- Аргументы противников подхода и их опровержение
- А иногда модульное выгоднее
В последние годы работы над сложными программными проблемами я понял одну вещь: лучше интеграционные тесты, чем модульные.
Идея: перевернуть
Вам, конечно, знакома диаграмма Фаулера, которую называют тестовой пирамидой:

Давайте уточним, что здесь:
- Юнит-тесты: Тесты модулей-юнитов. То есть классов и методов. Быстро выполняются, обычно связаны с деталями имплементации (белый ящик), легко настраиваются и имеют довольно узкую область (scope). Эти тесты в теории должны составлять большую часть всех тестов, около 80 %.
- Интеграционные: Тесты, направленные на оценку интеграции небольших/средних компонентов между собой. Эти тесты медленнее выполняются (по сравнению с юнит-тестами), слабо связаны с деталями реализации (черный ящик), не так легко настраиваются и имеют среднюю область применения (scope). Интеграционные не должны составлять большую часть всех тестов, а лишь примерно 15 %.
- Сквозные тесты: Тесты, направленные на оценку всего приложения. Такие тесты медленнее выполняются (по сравнению с интеграционными), слабо связаны с деталями реализации (черный ящик), трудно настраиваются и имеют область применения «всё приложение целиком».
Моя цель в этом посте — убедить вас, что существует другой подход, который я назвал Перевернутая тестовая пирамида:

Здесь мы расширили уровень интеграционных тестов до максимума; мы хотим иметь больше интеграционных тестов чем модульных; модульных тестов меньше чем даже сквозных тестов, так как мы считаем, что модульных тестов должно быть очень мало, по возможности. Однако в моем подходе есть проблемы, как минимум две (обозначены красными стрелками на рисунке выше).
Первая проблема
Интеграционные тесты выполняются медленно, и чем больше их, тем медленнее они будут выполняться.
Вторая проблема
Интеграционные тесты трудно настраивать, и, возможно, придется приспособить архитектуру и следовать особым принципам проектирования, чтобы облегчить настройку и поддержку, а также ускорить выполнение.
Позже я опишу область применения интеграционных тестов (синяя стрелка на рисунке выше), приведу аргументы в пользу существования минимального количества юнит-тестов, и продемонстрирую решения вышеуказанных проблем.
Расширяем область интеграционных тестов
Мы рассмотрели некоторые примеры интеграционных тестов в статье Юнит-тесты и интеграционные тесты, но сейчас я хочу призвать к расширению области применения (scope) интеграционных тестов.
Сколько компонентов нужно протестировать в стандартном интеграционном тесте? Несколько, а по возможности больше.
По моему опыту, наилучший результат достигается, когда интеграционные тесты проверяют все компоненты в потоке юз-кейсов в приложении. Позвольте мне объяснить это с помощью следующей диаграммы:

Здесь видим типичное приложение с шлюзами в качестве входов (очереди и API) и интеграциями с внешними системами, такими как хранилище, очереди и другие API. Поскольку мы хотим протестировать все компоненты, то наша цель — такая структура:

Цель вышеописанного подхода — протестировать все приложение, от шлюзов (запросы, API и другие) до границы приложения; в данном случае границы — это хранилище, очередь и другие API, однако для решения проблемы №2 выше — мы будем мокировать (имитировать моком-макетом) все эти границы.
В прошлом мы могли использовать реальные хранилища, очереди и API для тестирования приложения «по черному ящику», но такой подход увеличивает сложность. Сегодня существуют замечательные инструменты, которые помогают легко мокировать эти интеграции: Testcontainers и Wiremock.
Почему интеграционные тесты выгоднее юнит-тестов. Аргументы
Как мы убедились выше, существуют проблемы, связанные с идеей перевернутой пирамиды, далее обсудим, почему перевернутая пирамида до сих пор широко не используется (прим.перев.: еще как используется и активно обсуждается).
Классов/методов юнит-тестирования достаточно для моих юз-кейсов
Это не совсем так. Давайте вспомним, что такое юз-кейс (то есть сценарий использования): набор шагов, которые помогают пользователю решить его проблему («боль» в терминологии маркетологов и бизнес-аналитиков), причем эти шаги могут включать или не включать несколько приложений. Если пользователь остается только в одном приложении, мы предполагаем, что проблема пользователя может быть полностью или частично решена в этом одном приложении.
Теперь рассмотрим пример юз-кейса, в котором пользователь использует API (через пользовательский интерфейс или без) для получения информации о заработной плате (GET /payroll), следующим образом:

Здесь мы видим, как юзкейс проходит через RESTfulController, логику домена PayrollInformation, PostgresAdapter и базу данных Postgres. Если мы будем использовать стандартный подход «много юнит-тестов», то создадим такие юнит-тесты:
- RESTfulControllerTest: Мок вызова компонента PayrollInformation
- PayrollInformationTest: Мок вызова компонента PostgtresAdapter
- PostgresAdapterTest: Мок вызова реального компонента Postgres
- Если вы используете «общительные» тесты в терминологии Мартина Фаулера, они могут быть сведены к одному тесту:
- RESTfulControllerTest: Инжектируете все ниже лежащие реальные объекты, такие как PayrollInformation и PostgresAdapter, и имитируете вызов реальной Postgres, или имитируете PostgresAdapter и тестируете его отдельно.
Как думаете, достаточно ли этих тестов, чтобы дать уверенность в правильном поведении приложения и юз-кейса? Я считаю, что этого недостаточно, потому что юнит-тесты не могут проверить, например, следующие кейсы:
- Произошла ошибка, порт HTTP-сервера был изменен с 8080 на 8081, теперь клиенты API не могут подключиться.
- Вы обновили версию Spring Boot и некоторые конфигурации API изменились, что нарушило работу приложения при запуске.
- В коде появилась синтаксическая ошибка SQL, которая привела к сбою при вызове БД.
- API и компонент PayrollInformation упали во время выполнения, когда пытались вызвать друг друга с помощью отсутствующей конфигурации.
- Сообщение в очереди работало нормально в юнит-тестах, но во время выполнения сообщение было неправильно сформировано в текущей версии очереди.
- Неправильная конфигурация какой-то переменной окружения, необходимой в продакшене, привела к сбою при запуске приложения.
- Клиент отправляет JSON, который плохо преобразуется в объекты, поскольку отсутствует конфигурация маппинга.
- И подобное.
Я считаю, что приложение — это нечто большее, чем просто логика. Чтобы получить полностью функциональный юзкейс, нам нужно проверить фреймворки, серверы, базы данных, очереди, трансформеры и так далее, а они не проверяются юнит-тестами.
Мой подход состоит в том, чтобы в данном случае создать интеграционный тест GetPayrollInformationTest. В этом тесте будет:
- Ввод тестовых данных в БД Postgres: Проверяем, что база данных работает и что тестовые данные правильные с самого начала.
- Запуск сервера: Гарантия того, что сервер нормально запускается с тестовой конфигурацией, а еще лучше, если используется закрытая реальная продакшен-конфигурация.
- Вызов непосредственно RESTfulAPI: Проверяет фреймворк RESTful, HTTP-сервер, мапперы JSON, логику PayrollInformation, PostgresAdapter, SQL-запросы к Postgres.
- Проверка ответа RESTful.
Конечно, будет не один тест, а по одному тест-кейсу на ветку/поток юзкейсов, но фокус всего GetPayrollInformationTest сосредоточен на юзкейсе информации о заработной плате.
Я хочу подготовить приложение к переносу домена с его юнит-тестами в другое приложение, поскольку в будущем мне может понадобиться миграция.
Это прекрасная мысль, но довольно наивная, и позвольте мне сказать, что я пробовал это: если бы мы разделили приложение адаптерами и отделили домен от деталей, мы могли бы извлечь домен и перенести его в другое место. Я писал об этом в книгах Plugins: Software as a set of interchangeable parts и Open Close Principle By Example.
По моему опыту, я не видел, чтобы такое случалось, например:
У нас было приложение Spring Boot, и нам нужно было перенести логику в Quarkus, поэтому мы скопировали все классы домена и его юнит-тесты, вставили код в приложение Quarkus, и все прекрасно работало из коробки
Я не говорю, что это невозможно, я говорю, что это маловероятно. Я видел, что происходит примерно так:
У нас было приложение на Python, оно использовалось в качестве первого этапа создания ресурсов прямой трансляции во внешнем провайдере, приложение было «грязным», но оно делало свою работу. В конце концов, мы решили перенести приложение на Spring Boot, так как появлялись новые юзкейсы.
Я могу сказать, что такое случается, но, как видите, не так уж много юзкейсов отсоединенного домена в Python и его юнит-тестах. Будьте внимательны, я не говорю, что вы не должны разделять свой домен, на самом деле я советую это делать, но не думайте, что главная цель — использовать его полностью из коробки, этого может никогда не произойти.
Если в домене произойдет небольшое изменение, нужно будет только обновить юнит-тесты домена
Это кажется преимуществом, но я не согласен, давайте посмотрим на следующую диаграмму:

Здесь мы видим, как два различных юзкейса (получение Payroll и обновление Payrolll) проходят через приложение, используя компонент PayrollInformation. Если вам нужно изменить компонент PayrollInfromation и если вы используете юнит-тесты, вам нужно изменить следующий тест:
- PayrollInformationTest
Вы можете решить, что это замечательно, но нет, небольшое изменение в домене скрывает большую проблему: есть несколько юзкейсов, затронутых этим изменением, и с текущими юнит-тестами вы не знаете, какие из них.
В случае с интеграционными тестами у вас могут быть следующие тесты:
- PayrollInformationTest
- PayrollInformationUpdateTest
- BullkPayrollInformationTest
Как видите, при изменении компонента PayrollInformation эти три юзкейса упадут, и это прекрасно: теперь вы знаете, что небольшое изменение влияет на 3 юзкейса, несмотря на то, что изменить код нужно только в одном из них. Поэтому вы должны тщательно оценить, является ли это небольшое изменение правильным.
ПРИМЕЧАНИЕ: Что, если я изменю утилитарный класс, который используется каждым юзкейсом в моем приложении? Ну, да, каждый интеграционный тест упадет, и да, если у вас их 1000, это кошмар, но подумайте о позитиве: это говорит вам о том, что небольшое изменение в утилите может сломать все ваше приложение.
Нужно быстро продвигаться, а интеграционные тесты слишком медленные
Это да, интеграционные тесты не будут такими же быстрыми как юнит-тесты, но я не согласен с тем, что это замедляет работу в целом. Все упирается в уровень доверия, который дают интеграционные тесты по сравнению с юнит-тестами: вы можете быстро продвигаться с юнит-тестами, потому что они выполняются быстро, но юнит-тесты не могут протестировать все функции, используемые в юзкейсах, а интеграционные тесты это делают. Я готов подождать, пока выполнится интеграционный тест, и иметь уверенность 8 из 10, чем ждать 1 секунду, пока выполнится юнит-тест, и иметь уверенность 5 из 10.
Наш язык/фреймворк не поддерживает интеграционные тесты
Может быть, но почти в каждом языке/фреймворке есть утилиты для интеграционных тестов. Если в языке/фреймворке их точно нет, можно прибегнуть к так называемым общительным (социабельным) тестам: попытаться протестировать как можно больше компонентов для юзкейсов, приложить усилия, и двигаться дальше.
Я применяю свой фреймворк для тестирования полностью готовых и настроенных слоев приложения
Одним из аргументов в пользу интеграционных тестов является возможность протестировать то, что не могут юнит-тесты, например, преобразования JSON в Controller. Вы можете думать так:
Я знаю, что мой фреймворк позволяет мне тестировать Controller изолированно от логики домена и остального приложения, поэтому я должен иметь тесты, проверяющие преобразования JSON.
Звучит разумно, но, на мой взгляд, является пустой тратой времени и усилий:
- Проход по уровням тестирования может привести к созданию тестов, подобных юнит-тестам, но со сложностью настройки и медленным выполнением интеграционных тестов.
- Если вы все равно хотите писать интеграционные тесты, вы рискуете дублировать свои тесты: разные уровни тестирования, проверяющие одни и те же части приложения.
Вы можете подумать: ок, но пункт 2 противоречит моему утверждению выше, что Если в домене произойдет небольшое изменение, мне нужно будет только обновить раздел юнит-тестов этого домена. Ну, не совсем: я предлагаю создавать только один уровень тестирования — интеграционные тесты; и да, некоторые юзкейсы могут перекрываться с уже протестированной логикой в приложении; но это не дублирование, это разные юзкейсы, которые могут развиваться по-разному с течением времени, поэтому их изоляция — это хорошо.
Мне нужно писать юнит-тесты, чтобы знать тестовое покрытие
Я не сторонник 100%-го тестового покрытия, о чем говорил в статье Don’t Let the Unit Tests Coverage Cheat You, но, если ваши интеграционные тесты используют JUnit или любой другой фреймворк, который учитывает покрытие, плюс тесты встроены в само приложение, я бы считал, что это полезно для расчета покрытия.
В моем приложении слишком много интеграций, таких как очереди, базы данных, облака и т. д.
Юнит-тесты — это легкий путь, конечно, вам не придется иметь дело с интеграциями, но я предлагаю думать иначе: наличие большого количества интеграций — это признак того, что вам следует делать упор на интеграционные тесты, поскольку в основном ваши юзкейсы связаны с интеграциями, а не с логикой домена как таковой.
Я использую Mockito или что-то подобное для имитации интеграций моками, и использовать sociable-тесты
Это разумный подход, если у вас нет фреймворка, который удобен для тестирования интеграций, но я предлагаю попробовать имитировать интеграции с помощью «реальных» компонентов:
- Вы можете держать базу данных в CI-окружении и модифицировать ее
- Лучшим выходом будет использование контейнеров типа Testcontainers или подобных.
Постарайтесь держаться как можно ближе к реальным интеграциям, балансируя между сложностью настройки и скоростью тестов.
Когда юнит-тесты необходимы
Я думаю, что модульные тесты должны быть, но их должно быть очень немного: если у вас достаточно сложная логика домена, с большим количеством ветвлений, матана, логики и так далее, и вы видите, что интеграционные тесты «этого всего» приведут к множеству лишних ветвлений, которые нужно будет проверить. Юнит-тесты — незаменимая вещь, если:
- Вы проверяете каждое ветвление с помощью юнит-тестов
- Вы проверяете несколько важных ветвлений с помощью интеграционных тестов
По моему опыту, типичное веб-приложение не имеет сложной доменной логики, поэтому я и считаю, что юнит-тестов должно быть немного, и они должны быть исключительным явлением, а не везде. Я предлагаю вам по умолчанию использовать интеграционные тесты, а если вы видите, что тестирование выходит из-под контроля из-за большого количества кейсов, которые необходимо проверять, пишите юнит-тесты. Давайте посмотрим на пример юнит-тестов, которые мы используем по умолчанию, поскольку при использовании интеграционных тестов пришлось бы много дублировать и настраивать:
@ParameterizedTest(name = "Given a transcoder that was {0}, when calculates the final state, the pipeline state must be {3}") @CsvSource( "PROVISION_FAILED, PROVISIONED, PROVISION_FAILED, PROVISION_FAILED", "REMOVE_FAILED, PROVISIONED, REMOVE_FAILED, REMOVE_FAILED", "REMOVE_FAILED, STOPPED, REMOVE_FAILED, REMOVE_FAILED", "START_FAILED, PROVISIONED, START_FAILED, START_FAILED", "START_FAILED, STOPPED, START_FAILED, START_FAILED", "STOP_FAILED, STARTED, STOP_FAILED, STOP_FAILED", "PROVISIONED, PROVISIONED, PROVISIONED, PROVISIONED", "STOPPING, STOPPING, STOPPING, STOPPING", "STOPPED, STOPPED, STOPPED, STOPPED", "STARTED, STARTED, STARTED, STARTED", "STARTING, STARTING, STARTING, STARTING", "REMOVED, REMOVED, REMOVED, REMOVED" ) fun calculateResourceStateTranscoderMultipleStates( initialTranscoderState: ResourceState.State, initialPipelineState: ResourceState.State, finalTranscoderState: ResourceState.State, finalPipelineState: ResourceState.State ) { .... }
Здесь мы видим ParameterizedTest, который проверяет множество перестановок различных состояний в логике приложения; как видите, 12 перестановок, которые легче проверить юнит-тестами, чем интеграционными. Имея понятие о хорошем дизайне интеграционных тестов, мы могли бы правильно обработать этот тест, но он должен быть вам примером того, что юнит-тесты иногда действительно незаменимы.
Итак
Надеюсь, что этот пост показал вам альтернативную точку зрения на юнит-тесты и интеграционные тесты, в которой интеграционные тесты могут быть более приоритетными.
Дополнительный материал для интересующихся:
Практически полный гайд по тестовым пирамидам
42 разновидности пирамиды тестирования на все случаи жизни