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

Для чего нужно интеграционное тестирование?

Время на прочтение 9 мин
Количество просмотров 55K

Эта статья является конспектом книги «Принципы юнит-тестирования». Материал статьи посвящен интеграционным тестам.

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

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

Что такое интеграционный тест?

Юнит-тест удовлетворяет следующим трем требованиям:

  • проверяет правильность работы одной единицы поведения;

  • делает это быстро; 

  • и в изоляции от других тестов. 

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

Если все внепроцессные зависимости заменить моками, никакие зависимости не будут совместно использоваться между тестами, благодаря чему эти тесты останутся быстрыми и сохранят свою изоляцию друг от друга и становятся юнит-тестами. Тем не менее во многих приложениях существует внепроцессная зависимость, которую невозможно заменить моком. Обычно это база данных — зависимость, не видимая другими приложениями.

Важно поддерживать баланс между юнит- и интеграционными тестами. Работа напрямую с внепроцессными зависимостями замедляет интеграционные тесты. Кроме того, их сопровождение также обходится дороже.

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

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

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

Какие из внепроцессных зависимостей должны проверяться напрямую

Все внепроцессные зависимости делятся на две категории.

  • Управляемые зависимости (внепроцессные зависимости, находящиеся под вашим полным контролем): эти зависимости доступны только через ваше приложение; взаимодействия с ними не видны внешнему миру. Типичный пример — база данных.

  • Неуправляемые зависимости (внепроцессные зависимости, которые не находятся под вашим полным контролем) — результат взаимодействия с такими зависимостями виден извне. В качестве примеров можно привести сервер SMTP и шину сообщений.

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

Рис. 1 – Взаимодействия с зависимостями
Рис. 1 – Взаимодействия с зависимостями

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

Требование о сохранении схемы взаимодействий с неуправляемыми зависимостями обусловлено необходимостью поддержания обратной совместимости с такими зависимостями.  Моки идеально подходят для этой задачи. Они позволяют обеспечить неизменность схемы взаимодействий при любых возможных рефакторингов. Поддерживать обратную совместимость во взаимодействиях с управляемыми зависимостями не обязательно, потому что никто, кроме вашего приложения, с ними не работает. Внешних клиентов не интересует, как устроена ваша база данных; важно только итоговое состояние вашей системы. Использование реальных экземпляров управляемых зависимостей в интеграционных тестах помогает проверить это итоговое состояние с точки зрения внешних клиентов.

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

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

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

Рис. 2 – БД, доступная для внешних приложений
Рис. 2 – БД, доступная для внешних приложений

Основные приемы интеграционного тестирования

Существует несколько общих рекомендаций, которые помогут извлечь максимальную пользу из интеграционных тестов:

  • явное определение границ доменной модели (модели предметной области);

  • сокращение количества слоев в приложении;

  • устранение циклических зависимостей.

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

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

Рис. 3 – Типичное корпоративное приложение с несколькими слоями
Рис. 3 – Типичное корпоративное приложение с несколькими слоями

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

«Все проблемы в программировании можно решить путем добавления нового уровня абстракции (кроме проблемы наличия слишком большого количества уровней абстракции)».
Дэвид Дж. Уилер

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

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

public class CheckOutService
{
  public void CheckOut(int orderId)
  {
    var service = new ReportGenerationService();
    service.GenerateReport(orderId, this);
    /* остальной код */
  }
}

public class ReportGenerationService
{
  public void GenerateReport(
    int orderId,
    CheckOutService checkOutService)
  {
  	/* вызывает checkOutService при завершении генерирования */
  }
}

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

Что же делать с циклическими зависимостями? Лучше всего совсем избавиться от них. Отрефакторить класс ReportGenerationService, чтобы он не зависел от CheckOutService и сделать так, чтобы ReportGenerationService возвращал результат работы в виде простого значения вместо вызова CheckOutService:

public class CheckOutService
{
  public void CheckOut(int orderId)
  {
    var service = new ReportGenerationService();
    Report report = service.GenerateReport(orderId);
    /* прочая работа */
  }
}

public class ReportGenerationService
{
  public Report GenerateReport(int orderId)
  {
  	/* ... */
  }
}

Использование нескольких секций действий в тестах

Как вы, возможно, помните из предыдущего конспекта «Анатомия юнит-тестов», наличие более одной секции подготовки, действий или проверки в тесте — плохой признак. Он указывает на то, что тест проверяет несколько единиц поведения, что, в свою очередь, ухудшает сопровождаемость теста. Например, если имеются два связанных сценария использования (допустим, регистрация и удаление пользователя).

  • подготовка — подготовка данных для регистрации пользователя;

  • действие — вызов UserController.RegisterUser();

  • проверка — запрос к базе данных для проверки успешного завершения регистрации;

  • действие — вызов UserController.DeleteUser();

  • проверка — запрос к базе данных для проверки успешного удаления.

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

Исключение из этой рекомендации составляют тесты, работающие с внепроцессными зависимостями, трудно приводимыми в нужное состояние. Например, регистрация пользователя приводит к созданию банковского счета во внешней банковской системе. Банк предоставил вашей организации тестовую среду, которую вы хотите использовать для сквозных тестов. К сожалению, тестовая среда работает слишком медленно; также возможно, что банк ограничивает количество обращений к этой тестовой среде. В таком сценарии удобнее объединить несколько действий в один тест, чтобы сократить количество взаимодействий с проблемной внепроцессной зависимостью.

Выводы

Интеграционные тесты проверяют, как ваша система работает в интеграции с внепроцессными зависимостями.

Интеграционные тесты покрывают контроллеры; юнит-тесты покрывают алгоритмы и доменную модель.

Интеграционные тесты обеспечивают лучшую защиту от багов и устойчивость к рефакторингу; юнит-тесты более просты в поддержке и дают более быструю обратную связь.

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

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

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

Взаимодействия с управляемыми зависимостями являются деталями имплементации; взаимодействия с неуправляемыми зависимостями являются частью наблюдаемого поведения вашей системы.

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

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

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

Циклические зависимости увеличивают когнитивную нагрузку при попытках разобраться в коде. Типичный пример — обратный вызов (когда вызываемая сторона уведомляет вызывающую о результате своей работы).

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

Ссылки на все части

Теги:
Хабы:
+2
Комментарии 3
Комментарии Комментарии 3

Публикации

Истории

Работа

Ближайшие события

Московский туристический хакатон
Дата 23 марта – 7 апреля
Место
Москва Онлайн
Геймтон «DatsEdenSpace» от DatsTeam
Дата 5 – 6 апреля
Время 17:00 – 20:00
Место
Онлайн
PG Bootcamp 2024
Дата 16 апреля
Время 09:30 – 21:00
Место
Минск Онлайн
EvaConf 2024
Дата 16 апреля
Время 11:00 – 16:00
Место
Москва Онлайн