Перевернутая пирамида: интеграционные тесты важнее модульных

В последние годы работы над сложными программными проблемами я понял одну вещь: лучше интеграционные тесты, чем модульные.

Идея: перевернуть

Вам, конечно, знакома диаграмма Фаулера, которую называют тестовой пирамидой:

Та самая пирамида

Давайте уточним, что здесь:

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

Моя цель в этом посте — убедить вас, что существует другой подход, который я назвал Перевернутая тестовая пирамида:

Перевернутая пирамида — интеграционных больше всего

Здесь мы расширили уровень интеграционных тестов до максимума; мы хотим иметь больше интеграционных тестов чем модульных; модульных тестов меньше чем даже сквозных тестов, так как мы считаем, что модульных тестов должно быть очень мало, по возможности. Однако в моем подходе есть проблемы, как минимум две (обозначены красными стрелками на рисунке выше). 

Первая проблема

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

Вторая проблема

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

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

Расширяем область интеграционных тестов

Мы рассмотрели некоторые примеры интеграционных тестов в статье Юнит-тесты и интеграционные тесты, но сейчас я хочу призвать к расширению области применения (scope) интеграционных тестов. 

Сколько компонентов нужно протестировать в стандартном интеграционном тесте? Несколько, а по возможности больше.

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

Типичное приложение с интеграциями

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

Типичное приложение с интеграционными тестами и имитированными интеграциями

Цель вышеописанного подхода — протестировать все приложение, от шлюзов (запросы, API и другие) до границы приложения; в данном случае границы — это хранилище, очередь и другие API, однако для решения проблемы №2 выше — мы будем мокировать (имитировать моком-макетом) все эти границы.

В прошлом мы могли использовать реальные хранилища, очереди и API для тестирования приложения «по черному ящику», но такой подход увеличивает сложность. Сегодня существуют замечательные инструменты, которые помогают легко мокировать эти интеграции: Testcontainers и Wiremock

Почему интеграционные тесты выгоднее юнит-тестов. Аргументы

Как мы убедились выше, существуют проблемы, связанные с идеей перевернутой пирамиды, далее обсудим, почему перевернутая пирамида до сих пор широко не используется (прим.перев.: еще как используется и активно обсуждается).

Классов/методов юнит-тестирования достаточно для моих юз-кейсов

Это не совсем так. Давайте вспомним, что такое юз-кейс (то есть сценарий использования): набор шагов, которые помогают пользователю решить его проблему («боль» в терминологии маркетологов и бизнес-аналитиков), причем эти шаги могут включать или не включать несколько приложений. Если пользователь остается только в одном приложении, мы предполагаем, что проблема пользователя может быть полностью или частично решена в этом одном приложении.

Теперь рассмотрим пример юз-кейса, в котором пользователь использует API (через пользовательский интерфейс или без) для получения информации о заработной плате (GET /payroll), следующим образом:

Пример приложения для расчета заработной платы с использованием RESTful и PostgresDB

Здесь мы видим, как юзкейс проходит через 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. В этом тесте будет: 

  1. Ввод тестовых данных в БД Postgres: Проверяем, что база данных работает и что тестовые данные правильные с самого начала.
  2. Запуск сервера: Гарантия того, что сервер нормально запускается с тестовой конфигурацией, а еще лучше, если используется закрытая реальная продакшен-конфигурация.
  3. Вызов непосредственно RESTfulAPI: Проверяет фреймворк RESTful, HTTP-сервер, мапперы JSON, логику PayrollInformation, PostgresAdapter, SQL-запросы к Postgres.
  4. Проверка ответа 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.

Звучит разумно, но, на мой взгляд, является пустой тратой времени и усилий: 

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

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

Мне нужно писать юнит-тесты, чтобы знать тестовое покрытие

Я не сторонник 100%-го тестового покрытия, о чем говорил в статье Don’t Let the Unit Tests Coverage Cheat You, но, если ваши интеграционные тесты используют JUnit или любой другой фреймворк, который учитывает покрытие, плюс тесты встроены в само приложение, я бы считал, что это полезно для расчета покрытия.

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

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

Я использую Mockito или что-то подобное для имитации интеграций моками, и использовать sociable-тесты

Это разумный подход, если у вас нет фреймворка, который удобен для тестирования интеграций, но я предлагаю попробовать имитировать интеграции с помощью «реальных» компонентов:

  • Вы можете держать базу данных в CI-окружении и модифицировать ее 
  • Лучшим выходом будет использование контейнеров типа Testcontainers или подобных. 

Постарайтесь держаться как можно ближе к реальным интеграциям, балансируя между сложностью настройки и скоростью тестов.

Когда юнит-тесты необходимы

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

  1. Вы проверяете каждое ветвление с помощью юнит-тестов
  2. Вы проверяете несколько важных ветвлений с помощью интеграционных тестов

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

@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 перестановок, которые легче проверить юнит-тестами, чем интеграционными. Имея понятие о хорошем дизайне интеграционных тестов, мы могли бы правильно обработать этот тест, но он должен быть вам примером того, что юнит-тесты иногда действительно незаменимы. 

Итак

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

Coderstower


Дополнительный материал для интересующихся:

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

Уровни тестирования

42 разновидности пирамиды тестирования на все случаи жизни


Какой была ваша первая зарплата в QA и как вы искали первую работу?

Мега обсуждение в нашем телеграм-канале о поиске первой работы. Обмен опытом и мнения.

Подписаться
Уведомить о
guest

0 комментариев
Старые
Новые Популярные
Межтекстовые Отзывы
Посмотреть все комментарии

Мы в Telegram

Наш официальный канал
Полезные материалы и тесты
Готовимся к собеседованию
Project- и Product-менеджмент

? Популярное

? Telegram-обсуждения

Наши подписчики обсуждают, как искали первую работу в QA. Некоторые ищут ее прямо сейчас.
Наши подписчики рассказывают о том, как не бояться задавать тупые вопросы и чувствовать себя уверенно в новой команде.
Обсуждаем, куда лучше податься - в менеджмент или по технической ветке?
Говорим о конфликтных ситуациях в команде и о том, как их избежать
$1100*
медианная зарплата в QA в июне 2023

*по результатам опроса QA-инженеров в нашем телеграм-канале

Собеседование

19%*
IT-специалистов переехало или приняло решение о переезде из России по состоянию на конец марта 2022

*по результатам опроса в нашем телеграм-канале

live

Обсуждают сейчас