Перед прочтением этого поста рекомендую ознакомиться хотя бы с постами об общих идеях тестирования и запуском инфраструктуры и тестов, если ещё не сделали этого.
При том что в целом я стараюсь минимизировать использование тестовых дублей, я вполне допускаю их использование в случаях, когда это целесообразно/экономически обосновано.
Напомню, на самом верхнем уровне процесс подготовки теста в Trainer Advisor (и в целом по Эргономичному подходу) состоит из следующих шагов:
Один раз на запуск набора тестов:
Запустить или сбросить состояние запущенной инфраструктуры (сейчас - Postgres и Minio);
Запустить приложение;
Проинициализировать инфраструктуру (схему и бакеты);
Для каждого тест-кейса из набора:
Подготовить хранилища;
Привести данные в БД к эталонным;
Сформировать специфичные данные, которые sut загрузит из хранилищ неявно в процессе испытания (вызова метода в блоке Then);
Вставить эти данные в хранилища.
Подготовить тестовые дубли.
И в этом посте я рассматриваю последний шаг — подготовку тестовых дублей.
В TA сейчас практически нигде нет особого смысла в какой бы то ни было обработке ошибок инфраструктуры, кроме отображения пользователю сообщения об ошибке.
Поэтому общая стратегия обработки ошибок инфраструктуры заключается в их игнорировании:)
Они улетают в Spring и он отображает человеческую страницу об ошибке.
Но чтобы контролировать, что Spring продолжает себя так вести, я написал на это тест:
Как спровоцировать NOT FOUND, я думаю, очевидно, а вот как спровоцировать неожиданную ошибку - не совсем, поэтому поясню.
Для этого я в тестовых исходниках завёл контроллер, который всегда выбрасывает исключение:
И импортирую его в конфиге тестов:
Плюс ещё пришлось немного подправить продовые настройки Spring Security:
И хотя подавляющее большинство ошибок улетает в пользователя напрямую, есть один случай где это не так.
Речь идёт о случае отказа Minio при удалении изображений шагов упражнения, после успешного удаления самого упражнения из Postgres-а.
В этом случае отказ будет для пользователя совершенно незаметен и незачем портить пользователю жизнь сообщением об ошибке.
Симулировать отказ реального Minio в принципе возможно с помощью ToxiProxy - однако это долго и дорого и в смысле разработки, и в смысле времени прогона тестов.
Поэтому я решил, что этот тот случай, когда использование моков является экономически целесообразным.
Логика по обработке этой ошибки находится в классе ExercisesService:
И для тестирования этой логики мне надо замокать exerciseStepsImagesStorage в этом классе.
Однако с моками в Spring есть нюанс.
С одной стороны, в Spring Test есть чудесная автомагия в виде @MockBean.
С другой стороны, это автомагия приводит к тому, что каждый тест-кейс (метод) с этой аннотацией запускает контекст заново.
А у нас контекст практически продовый и запускается по несколько секунд - неприемлемо долго.
Другим очевидным способом подсунуть мок в sut (ExercisesService в данном случае) является создание sut руками, вообще без Spring.
Однако в этом конфигурация sut отдалится от боевой дальше, чем требуется - мы потеряем всю автомагию Spring, в этом примере - создание транзакции через @Transactional.
Это, наверное, небольшая потеря, но я нашёл способ, как её избежать:
Что здесь происходит:
Я определяю бин-бэкгрануд для работы со Spring;
У которого есть доступ к Spring-контексту приложения;
В бэкграунде определён метод createExercisesService;
Который на вход получает зависимости бина ExerciseService (которые можно не указывать, если надо использовать продовый бин из контекста);
Далее я создаю BeanDefinition;
Для которого руками создаю экземпляр ExercisesService;
Передавая в качестве зависимостей либо тот объект, что пришёл в параметрах (мок), либо настоящий бин из конекста;
Затем я регистрирую этот бин дефинишн в контексте тестов;
И получаю из контекста бин - в этот момент Spring навернёт свою автомагию, на объект созданный на шаге 6.
Сейчас отправка писем (со сгенерированным системой паролем для пользователя и нотификацией для админа) в Trainer Advisor выполняется с помощью почты Yandex-а, взаимодействие с которой проходит по SMTP.
И исходя из принципа, что тест должен выполнять те же проверки, что и тестировщик-человек, в тесте регистрации надо получить отправленное письмо, достать из него пароль, залогиниться с этим паролем и убедиться, что логин прошёл.
В принципе, это всё можно провернуть поверх другого ящика на той же почте Yandex-а.
И это будет конфигурацией, максимально приближенной к боевой.
Но у такого подхода есть серия существенных минусов:
Тест начнёт зависеть от сети и не будет работать в офлайне (или если вдруг почта Yandex-а упадёт);
Тест начнёт ходить по сети и работать намного дольше, чем все остальные тесты, не покидающие пределов localhost;
Тесту надо будет дополнительно чистить ящик, что потребует дополнительного времени и на разработку теста и на запуск.
Поэтому в данном случае начинают работать соображения эргономики разработки и тестирования и в тестах вместо SMTP сервера Yandex-а, я использую локальный SMTP сервер GreenMail.
Это позволяет тестировать систему в конфигурации достаточно близкой к боевой (используется реальный код, который отправляет реальные сообщения по SMTP), но без всех недостатков использования реального сервера Yandex.
Для того чтобы это провернуть, нам надо настроить приложение при запуске в тестах на работу с локальным сервером и запустить локальный SMTP-сервер.
Настройка приложения выполняется с помощью конфигурации профиля:
Затем сервер запускается с помощью JUnit 5 расширения:
А дальше письмо "получается" тривиальным вызовом АПИ:
В своей практике я стараюсь избегать моков, так как они снижают устойчивость к рефакторингу и надёжность тестов.
Но ни то ни другое не является проблемой самой по себе.
Проблема в том, что и лишний рефаторинг и лишний цикл исправления ошибок стоят дополнительных денег.
Однако в некоторых случаях (симуляция ошибок, работа с внешними системами) сетап и использование настоящих зависимостей будет стоить ещё дороже, чем лишний рефакторинг и лишние циклы исправления ошибок.
В этих случаях можно и нужно использовать тестовые дубли.
В Trainer Advisor всей это инфраструктуры (пока?) нет, но 2024 году пост об интеграционном тестировании, без описания тестирования интеграций с внешними сервисами и брокерами сообщений нельзя считать полноценным.
Поэтому в качестве бонус-трека, я расскажу о том, как мы тестировали такие интеграции в Проекте Э.
Как я писал в посте о сетапе инфраструктуры, тестирование кода, работающего с внешними сервисами, у меня проработано намного меньше, чем тестирование кода, работающего с БД, но, тем не менее, кое-какие наработки есть.
А именно:
Я всегда выделяю сетап WireMock-а во вспомогательные методы;
Для каждого эндпоинта внешней системы я завожу по отдельному методу на комбинацию структуры тела запроса и тела ответа.
Зачастую это значит, что на каждый эндпоинт необходимо заводить по два метода - на успешный и неуспешный ответ;
Так же как и с доменными объектами, я никогда не хардкожу значения параметров и полей;
Практически всегда для полей ответа я указываю дефолтное случайное реалистичное значение.
URL-ы, тела и т.д. я матчу, как регулярные выражения, а не точные совпадения.
Для тестирования эффектов публикации сообщений в RabbitMQ в Проекте Э используется пара наколенных хелперов.
Первый - фейковый лисенер:
В этот лисенер передаётся очередь и кол-во сообщений для получения, после чего он запускает отдельный поток, который, собственно, и получает заданное кол-во сообщений из заданной очереди.
Далее есть второй хелпер, который собирает тестовый маршрут и тестовый лисенер:
Этот хелпер умеет создавать лисенров, которые будут получать сообщения по определённому ключу маршрутизации.
И далее он используется для проверки того, что в результате вызова тестируемой операции, требуемые события публикуются в Кролика.
Например:
В этом тесте мы проверяем, что после добавления событий дневника через АПИ приложения пациента, для каждого события дневника в Кролика публикуется доменное событие.
С учётом того, что тестовый слушатель Кафки определён как Spring-бин, тесты отправки сообщений в Кафку состоят из четырёх шагов:
Заавтовайрить бин слушателя;
Сбросить состояние слушателя;
Вызвать тестируемый метод;
Дождаться (с таймаутом) появления сообщения в тестовом слушателе.
Такого реального теста без лишних сложностей (сетапа фикстуры и Wiremock-ов внешней системы) в Проекте Э нет, поэтому тут приведу схематичный пример теста: