Эта статья является конспектом книги «Принципы юнит-тестирования». Материал статьи посвящен интеграционным тестам.
Юнит-тесты прекрасно справляются с проверкой бизнес-логики, но проверять эту логику «в вакууме» недостаточно. Необходимо проверять, как разные ее части интегрируются друг с другом и внешними системами: базой данных, шиной сообщений и т. д.
В этой статье рассматривается роль интеграционных тестов, когда их следует использовать и когда лучше положиться на классические юнит-тесты. Также затронем эффективное написание интеграционных тестов.
Что такое интеграционный тест?
Юнит-тест удовлетворяет следующим трем требованиям:
проверяет правильность работы одной единицы поведения;
делает это быстро;
и в изоляции от других тестов.
Тест, который не удовлетворяет хотя бы одному из этих трех требований, относится к категории интеграционных тестов. На практике интеграционные тесты почти всегда проверяют, как система работает в интеграции с внепроцессными зависимостями.
Если все внепроцессные зависимости заменить моками, никакие зависимости не будут совместно использоваться между тестами, благодаря чему эти тесты останутся быстрыми и сохранят свою изоляцию друг от друга и становятся юнит-тестами. Тем не менее во многих приложениях существует внепроцессная зависимость, которую невозможно заменить моком. Обычно это база данных — зависимость, не видимая другими приложениями.
Важно поддерживать баланс между юнит- и интеграционными тестами. Работа напрямую с внепроцессными зависимостями замедляет интеграционные тесты. Кроме того, их сопровождение также обходится дороже.
С другой стороны, интеграционные тесты проходят через больший объем кода, что делает их более эффективными в защите от багов по сравнению с юнит-тестами. Они также более отделены от рабочего кода, а, следовательно, обладают большей устойчивостью к его рефакторингу.
Соотношение между юнит- и интеграционными тестами зависит от особенностей проекта, но общее правило выглядит так: проверьте как можно больше пограничных случаев бизнес-сценария юнит-тестами; используйте интеграционные тесты для покрытия одного позитивного пути, а также всех граничных случаев, которые не покрываются юнит-тестами.
Для интеграционного теста выберите самый длинный позитивный путь, проверяющий взаимодействия со всеми внепроцессными зависимостями. Если не существует одного пути, проходящего через все такие взаимодействия, напишите дополнительные интеграционные тесты — столько, сколько потребуется для отражения взаимодействий с каждой внешней системой.
Какие из внепроцессных зависимостей должны проверяться напрямую
Все внепроцессные зависимости делятся на две категории.
Управляемые зависимости (внепроцессные зависимости, находящиеся под вашим полным контролем): эти зависимости доступны только через ваше приложение; взаимодействия с ними не видны внешнему миру. Типичный пример — база данных.
Неуправляемые зависимости (внепроцессные зависимости, которые не находятся под вашим полным контролем) — результат взаимодействия с такими зависимостями виден извне. В качестве примеров можно привести сервер SMTP и шину сообщений.
Взаимодействия с управляемыми зависимостями относятся к деталям имплементации. И наоборот, взаимодействия с неуправляемыми зависимостями являются частью наблюдаемого поведения вашей системы.
Это различие приводит к тому, что такие зависимости по-разному обрабатываются в интеграционных тестах. Взаимодействия с управляемыми зависимостями являются деталями имплементации. Использовать их следует в исходном виде в интеграционных тестах. Взаимодействия с неуправляемыми зависимостями являются частью наблюдаемого поведения системы. Такие зависимости должны заменяться моками.
Требование о сохранении схемы взаимодействий с неуправляемыми зависимостями обусловлено необходимостью поддержания обратной совместимости с такими зависимостями. Моки идеально подходят для этой задачи. Они позволяют обеспечить неизменность схемы взаимодействий при любых возможных рефакторингов. Поддерживать обратную совместимость во взаимодействиях с управляемыми зависимостями не обязательно, потому что никто, кроме вашего приложения, с ними не работает. Внешних клиентов не интересует, как устроена ваша база данных; важно только итоговое состояние вашей системы. Использование реальных экземпляров управляемых зависимостей в интеграционных тестах помогает проверить это итоговое состояние с точки зрения внешних клиентов.
Иногда встречаются внепроцессные зависимости, обладающие свойствами как управляемых, так и неуправляемых зависимостей. Хорошим примером служит база данных, доступная для других приложений.
База данных — не лучший механизм для интеграции между системами, потому что она связывает эти системы друг с другом и усложняет дальнейшую их разработку. Используйте это решение только в случае, если других вариантов нет. Правильнее осуществлять интеграцию через API (для синхронных взаимодействий) или шину сообщений (для асинхронных взаимодействий).
В этом случае следует рассматривать таблицы, видимые для других приложений, как неуправляемую зависимость. Такие таблицы фактически выполняют функции шины сообщений, а их строки играют роль сообщений. Используйте моки, чтобы гарантировать неизменность схемы взаимодействий с этими таблицами. В то же время следует рассматривать остальные части базы данных как управляемую зависимость и проверять ее итоговое состояние, а не взаимодействия с ней.
Основные приемы интеграционного тестирования
Существует несколько общих рекомендаций, которые помогут извлечь максимальную пользу из интеграционных тестов:
явное определение границ доменной модели (модели предметной области);
сокращение количества слоев в приложении;
устранение циклических зависимостей.
Явное определение границ модели предметной области. Доменная модель представляет собой совокупность знаний о предметной области задачи, для решения которой предназначен ваш проект. Данная практика помогает с тестированием. Юнит-тесты должны ориентироваться на доменную модель и алгоритмы, тогда как интеграционные тесты — на контроллеры. Таким образом, четкое разграничение между доменными классами и контроллерами также помогает отделить юнит-тесты от интеграционных.
Сокращение количества слоев. Многие разработчики стремятся к абстрагированию и обобщению кода путем введения дополнительных уровней абстракции.
В некоторых приложениях находится столько уровней абстракции, что разработчик уже не может разобраться в коде и понять логику даже простейших операций.
«Все проблемы в программировании можно решить путем добавления нового уровня абстракции (кроме проблемы наличия слишком большого количества уровней абстракции)».
Дэвид Дж. Уилер
Лишние абстракции также затрудняют юнит- и интеграционное тестирование. Кодовые базы со слишком большим количеством слоев обычно не имеют четкой границы между контроллерами и моделью предметной области. Старайтесь ограничиться минимально возможным количеством уровней абстракции. В большинстве серверных систем можно обойтись всего тремя: слоем доменной модели, слоем сервисов приложения (контроллеров) и слоем инфраструктуры.
Исключение циклических зависимостей. Циклическая зависимость возникает в том случае, если два или более класса прямо или косвенно зависят друг от друга. Типичный пример циклической зависимости — обратный вызов:
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 и шина сообщений.
Взаимодействия с управляемыми зависимостями являются деталями имплементации; взаимодействия с неуправляемыми зависимостями являются частью наблюдаемого поведения вашей системы.
Иногда внепроцессная зависимость обладает свойствами как управляемых, так и неуправляемых зависимостей. Типичный пример — база данных, доступная для других приложений. Наблюдаемую часть такой базы следует интерпретировать как неуправляемую зависимость; заменяйте ее моками в тестах. Рассматривайте остальную часть зависимости как управляемую — проверяйте ее итоговое состояние, а не взаимодействия с ней.
Выделите явное место для модели предметной области в коде. Четкая граница между классами предметной области и контроллерами помогает отличать юниттесты от интеграционных.
Лишние уровни абстракции отрицательно влияют на вашу способность понимать код. Постарайтесь свести количество этих уровней к минимуму. В большинстве бэкенд-систем достаточно всего трех слоев: предметной области, сервисов приложения и инфраструктуры.
Циклические зависимости увеличивают когнитивную нагрузку при попытках разобраться в коде. Типичный пример — обратный вызов (когда вызываемая сторона уведомляет вызывающую о результате своей работы).
Множественные секции действий в тестах оправданны только в том случае, если тест работает с внепроцессными зависимостями, которые трудно привести в нужное состояние. Никогда не включайте несколько действий в юнит-тест, потому что юнит-тесты не работают с внепроцессными зависимостями. Многофазные тесты почти всегда принадлежат к категории сквозных.
Ссылки на все части
Анатомия юнит-теста
Аспекты хороших юнит-тестов
Для чего нужно интеграционное тестирование?