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

Отказ от ответственности
I Love My Local Farmer — это вымышленная компания, вдохновленная взаимодействием клиентов с AWS Solutions Architects. Любые истории, рассказанные в этом блоге, не связаны с конкретным клиентом. Сходства с любыми реальными компаниями, людьми или ситуациями чисто случайны. Истории в этом блоге представляют точку зрения авторов и не одобрены AWS.

Этот блог основан на предыдущем посте. Проверьте, не читали ли вы его раньше.

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

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

Дырявая абстракция в SlotService

В идеале мы хотим, чтобы SlotService содержал только логику предметной области (или бизнес-логику). Логика домена является уникальной частью вашего приложения. В нашем случае это связано с управлением интервалами доставки. Другие части, такие как подключение к базе данных или внешние вызовы API, являются вспомогательным кодом.

Мы хотим, чтобы наши модульные тесты для SlotService были сосредоточены на правильности логики предметной области, а не на деталях реализации. Однако класс использует объект Connection, созданный DbUtil. Это означает, что он знает основные детали реализации SQL. Если бы мы тестировали этот метод, мы должны были бы имитировать все методы, используемые в библиотеке JDBC.

Можем ли мы сделать лучше? Один из вариантов — применить архитектуру портов и адаптеров (также известную как гексагональная архитектура). Основная идея этой архитектуры — разделить что и как при взаимодействии с внешними компонентами.

Например, я хочу получить данные слота из базы данных («что»). Меня не волнует, будет ли базовая база данных реализована через SQL (будь то JDBC или ORM) или даже через NoSQL. SlotService будет заботиться только о логике домена и ничего не знать о базовой базе данных.

Таким образом, я создаю интерфейс SlotRepository с методом retrieve() в качестве «что». Для реализации («как») я создаю еще один класс JDBCSlotRepository и перемещаю приведенные выше фрагменты в метод retrieve(). JDBCSlotRepository может повторно использовать уже существующий DbUtil.

Термин «Порт» — это «что», а термин «Адаптер» — «как». Разделив эти две пары, мы можем поменять адаптер, чтобы он подходил к тому же порту. Допустим, мы хотим перейти с AWS RDS на AWS DynamoDB, мы могли бы создать DynamoDbSlotRepository, реализующий интерфейс. Не должно быть никаких изменений в SlotService и его тестах, которые содержат только логику предметной области. Вот схема этой конструкции.

Со стороны вызывающей стороны сам обработчик фактически является еще одним «адаптером». Он преобразует события AWS Lambda в формат, понятный логике предметной области (по SlotService). Если мы решим переместить этот код в контейнер, мы можем создать новый «адаптер», который будет получать вызов от балансировщика нагрузки, преобразовывать входные данные и передавать их в SlotService.

С точки зрения испытаний, основным преимуществом является снижение хрупкости. Теперь мы можем передать макет с SlotRepository.retrieve(). Тесты могут просто вызвать функцию, чтобы получить фиктивный ответ и продолжить. Изменения базовой инфраструктуры (например, отказ от JDBC) не повлияют на SlotService и его модульные тесты, поскольку он содержит только логику предметной области.

SlotServiceTest может сосредоточиться на логике домена, а тест обработчика будет сосредоточен на возвращаемом результате, который соответствует контракту AWS Lambda со шлюзом API (например, код состояния). Это увеличивает легкость обнаружения ошибок. Тесты для каждого отдельного класса будут сосредоточены на конкретной ответственности.

Это звучит здорово! Но чем мы платим за эту гибкость?

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

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

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

Где провести черту и прекратить модульное тестирование

Я хотел бы обратить ваше внимание на эти строки в оригинальном методе getSlots().

Независимо от того, как мы проектируем наши классы, всегда будет класс, который содержит это низкоуровневое взаимодействие с внешними компонентами. Чтобы протестировать эту часть кода, мы можем создать фиктивный объект Connection и передать его через фиктивный DbUtil. Фиктивное соединение будет ожидать последующих вызовов.

Вас устраивает этот тест? Я не.

Мы хотим проверить, что getSlots() возвращает правильный результат, не вызывая корректно библиотеку JDBC. Возвращаемый результат на самом деле является поддельным, сгенерированным самой библиотекой. Объем «проверки системы», которую мы получили от этого, также очень низок. Этот тест в основном отражает реализацию, что делает его хрупким для изменения деталей реализации. В приведенном выше примере добавление поля в предложение WHERE потребует двух изменений в тесте. Первый обновляет переменную expectedQuery. Второй добавляет еще один метод setObject.

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

Пример здесь для JDBC, но вы столкнетесь с аналогичной ситуацией на уровне репозитория. Это может быть вызов внешнего API или AWS SDK (для DynamoDb, SQS или EventBridge и т. д.). Иногда имеет смысл имитировать и проверять правильность параметров этих внешних вызовов (особенно если при построении этих параметров используется сложная логика). Иногда это не так. Спросите себя, какие свойства мы улучшаем для тестов? Что мы уменьшаем? Стоит ли выигрыш затрат?

Обычно вы будете повышать уровень достоверности своих тестов («проверка системы») за счет скорости, хрупкости и надежности. Для модульного тестирования наиболее распространенным свойством, которым вы торгуете, является хрупкость. Лично я рассматриваю возможность использования интеграционных тестов, если мне нужно отразить реализацию с 3+ фиктивными вызовами методов. Каждая система имеет разные оптимальные точки, и каждая команда имеет разные предпочтения.

Вывод

В предыдущем посте мы реорганизовали обработчик Lambda, чтобы сделать его более тестируемым. Однако у SlotService все еще были некоторые детали реализации базы данных (JDBC). Это сделало модульные тесты SlotService хрупкими, так как они предполагают детали реализации. Таким образом, мы применили архитектуру портов и адаптеров, чтобы убрать детали реализации.

Мы также обсудили, когда прекратить писать модульные тесты. Хотя 100% покрытие ветвей модульным тестированием — это хорошо, оно может не стоить затрат на добавление очень хрупких тестов, которые не добавляют большой ценности. Мы пишем тесты, чтобы убедиться, что наша система работает, а не для повышения числа. Тем не менее, нет теста, который охватывает вызовы JDBC, и это важная часть нашего приложения. Таким образом, мы рассмотрим эту часть с интеграционным тестированием в следующем посте.

Мы рады услышать от вас

Самое захватывающее время для писателя — услышать отзывы читателей. Нравится ли вам, не нравится или вы можете предложить любую другую тему? Пожалуйста, дайте нам знать в комментариях или в нашем Twitter.