В современной разработке программного обеспечения ключевую роль в обеспечении надежности и стабильности приложений играет эффективное тестирование. В этой статье предлагаются практические рекомендации по написанию интеграционных тестов, демонстрирующие, как сосредоточиться на спецификациях взаимодействия с внешними сервисами, сделав тесты более читабельными и простыми в сопровождении. Такой подход не только повышает эффективность тестирования, но и способствует лучшему пониманию процессов интеграции внутри приложения. Через призму конкретных примеров будут рассмотрены различные стратегии и инструменты, такие как оболочки DSL, JsonAssert и Pact, предлагающие читателю исчерпывающее руководство по повышению качества и наглядности интеграционных тестов. В статье представлены примеры интеграционных тестов, выполненных с использованием Spock Framework в Groovy для проверки HTTP-взаимодействий в приложениях Spring. В то же время предложенные основные техники и подходы могут эффективно применяться к различным типам взаимодействий, выходящим за рамки HTTP. описание проблемы В статье описан подход к написанию тестов с четким разделением на отдельные этапы, каждый из которых выполняет свою конкретную роль. Опишем тестовый пример по этим рекомендациям, но с мокингом не одного, а двух запросов. Стадия Act (Execution) для краткости будет опущена (полный тестовый пример можно найти в ). «Написание эффективных интеграционных тестов в Spring: стратегии организованного тестирования для мокинга HTTP-запросов» репозитории проекта Представленный код условно разделен на части: «Поддерживающий код» (выделен серым цветом) и «Спецификация внешних взаимодействий» (выделен синим цветом). Вспомогательный код включает механизмы и утилиты для тестирования, включая перехват запросов и эмуляцию ответов. Спецификация внешних взаимодействий описывает конкретные данные о внешних сервисах, с которыми система должна взаимодействовать во время теста, включая ожидаемые запросы и ответы. Вспомогательный код закладывает основу для тестирования, а Спецификация напрямую связана с бизнес-логикой и основными функциями системы, которую мы пытаемся протестировать. Спецификация занимает незначительную часть кода, но представляет значительную ценность для понимания теста, тогда как вспомогательный код, занимающий большую часть, представляет меньшую ценность и повторяется для каждого макетного объявления. Код предназначен для использования с MockRestServiceServer. Обращаясь к , можно увидеть ту же картину: спецификация практически идентична, а Supporting Code различается. примеру на WireMock Цель этой статьи — предложить практические рекомендации по написанию тестов таким образом, чтобы основное внимание уделялось спецификации, а вспомогательный код отходил на второй план. Демонстрационный сценарий Для нашего тестового сценария я предлагаю гипотетического бота Telegram, который пересылает запросы к OpenAI API и отправляет ответы обратно пользователям. Контракты взаимодействия с сервисами описаны в упрощенном виде, чтобы выделить основную логику работы. Ниже приведена диаграмма последовательности, демонстрирующая архитектуру приложения. Я понимаю, что дизайн может вызвать вопросы с точки зрения архитектуры системы, но, пожалуйста, подойдите к этому с пониманием — основная цель здесь — продемонстрировать подход к повышению наглядности в тестах. Предложение В данной статье рассматриваются следующие практические рекомендации по написанию тестов: Использование оберток DSL для работы с макетами. Использование JsonAssert для проверки результатов. Хранение спецификаций внешних взаимодействий в файлах JSON. Использование файлов Pact. Использование оболочек DSL для насмешек Использование оболочки DSL позволяет скрыть шаблонный макет кода и предоставляет простой интерфейс для работы со спецификацией. Важно подчеркнуть, что предлагается не конкретный DSL, а общий подход, который он реализует. Исправленный пример теста с использованием DSL представлен ниже ( ). полный текст теста setup: def openaiRequestCaptor = restExpectation.openai.completions(withSuccess("{...}")) def telegramRequestCaptor = restExpectation.telegram.sendMessage(withSuccess("{}")) when: ... then: openaiRequestCaptor.times == 1 telegramRequestCaptor.times == 1 Где метод , например, описывается следующим образом: restExpectation.openai.completions public interface OpenaiMock { /** * This method configures the mock request to the following URL: {@code https://api.openai.com/v1/chat/completions} */ RequestCaptor completions(DefaultResponseCreator responseCreator); } Наличие комментария к методу позволяет при наведении курсора на имя метода в редакторе кода получить помощь, в том числе увидеть URL-адрес, который будет осмеян. В предлагаемой реализации объявление ответа из макета производится с использованием экземпляров , что позволяет создавать собственные, такие как: ResponseCreator public static ResponseCreator withResourceAccessException() { return (request) -> { throw new ResourceAccessException("Error"); }; } Ниже показан пример теста для неудачных сценариев с указанием набора ответов: import static org.springframework.http.HttpStatus.FORBIDDEN setup: def openaiRequestCaptor = restExpectation.openai.completions(openaiResponse) def telegramRequestCaptor = restExpectation.telegram.sendMessage(withSuccess("{}")) when: ... then: openaiRequestCaptor.times == 1 telegramRequestCaptor.times == 0 where: openaiResponse | _ withResourceAccessException() | _ withStatus(FORBIDDEN) | _ Для WireMock всё то же самое, за исключением того, что немного отличается формирование ответа ( , ). тестовый код код фабричного класса ответа Использование аннотации @Language("JSON") для лучшей интеграции IDE При реализации DSL можно аннотировать параметры метода с помощью чтобы включить поддержку языковых функций для определенных фрагментов кода в IntelliJ IDEA. Например, при использовании JSON редактор будет обрабатывать строковый параметр как код JSON, обеспечивая такие функции, как подсветка синтаксиса, автозаполнение, проверка ошибок, навигация и поиск по структуре. Вот пример использования аннотации: @Language("JSON") public static DefaultResponseCreator withSuccess(@Language("JSON") String body) { return MockRestResponseCreators.withSuccess(body, APPLICATION_JSON); } Вот как это выглядит в редакторе: Использование JsonAssert для проверки результатов Библиотека JSONAssert предназначена для упрощения тестирования структур JSON. Он позволяет разработчикам легко сравнивать ожидаемые и фактические строки JSON с высокой степенью гибкости, поддерживая различные режимы сравнения. Это позволяет перейти от такого описания проверки openaiRequestCaptor.body.model == "gpt-3.5-turbo" openaiRequestCaptor.body.messages.size() == 1 openaiRequestCaptor.body.messages[0].role == "user" openaiRequestCaptor.body.messages[0].content == "Hello!" что-то вроде этого assertEquals("""{ "model": "gpt-3.5-turbo", "messages": [{ "role": "user", "content": "Hello!" }] }""", openaiRequestCaptor.bodyString, false) На мой взгляд, главное преимущество второго подхода в том, что он обеспечивает согласованность представления данных в различных контекстах — в документации, журналах и тестах. Это значительно упрощает процесс тестирования, обеспечивая гибкость сравнения и точность диагностики ошибок. Таким образом, мы не только экономим время на написании и сопровождении тестов, но и улучшаем их читабельность и информативность. При работе в Spring Boot, начиная как минимум с версии 2, для работы с библиотекой не нужны дополнительные зависимости, поскольку уже включает зависимость от . org.springframework.boot:spring-boot-starter-test org.skyscreamer:jsonassert Сохранение спецификации внешних взаимодействий в файлах JSON Мы можем сделать одно наблюдение: строки JSON занимают значительную часть теста. Должны ли они быть скрыты? Да и нет. Важно понять, что приносит больше пользы. Их скрытие делает тесты более компактными и упрощает понимание сути теста с первого взгляда. С другой стороны, при тщательном анализе часть важной информации о спецификации внешнего взаимодействия будет скрыта, что потребует дополнительных переходов по файлам. Решение зависит от удобства: делайте так, как вам удобнее. Если вы решите хранить строки JSON в файлах, один из простых вариантов — хранить ответы и запросы отдельно в файлах JSON. Ниже приведен тестовый код ( ), демонстрирующий вариант реализации: полная версия setup: def openaiRequestCaptor = restExpectation.openai.completions(withSuccess(fromFile("json/openai/response.json"))) def telegramRequestCaptor = restExpectation.telegram.sendMessage(withSuccess("{}")) when: ... then: openaiRequestCaptor.times == 1 telegramRequestCaptor.times == 1 Метод просто считывает строку из файла в каталоге и не несет в себе какой-либо революционной идеи, но по-прежнему доступен в репозитории проекта для справки. fromFile src/test/resources Для переменной части строки предлагается использовать замену на и передавать набор значений при описании макета, например: org.apache.commons.text.StringSubstitutor setup: def openaiRequestCaptor = restExpectation.openai.completions(withSuccess(fromFile("json/openai/response.json", [content: "Hello! How can I assist you today?"]))) Где часть с заменой в файле JSON выглядит так: ... "message": { "role": "assistant", "content": "${content:-Hello there, how may I assist you today?}" }, ... Единственная задача для разработчиков при внедрении подхода к хранению файлов — разработать правильную схему размещения файлов в тестовых ресурсах и схему именования. Легко допустить ошибку, которая может ухудшить качество работы с этими файлами. Одним из решений этой проблемы может быть использование спецификаций, например, из Pact, которые будут обсуждаться позже. При использовании описанного подхода в тестах, написанных на Groovy, можно столкнуться с неудобством: в IntelliJ IDEA нет поддержки перехода к файлу из кода, но . В тестах, написанных на Java, это прекрасно работает. в будущем ожидается добавление поддержки этого функционала Использование файлов контрактов Pact Начнем с терминологии. Контрактное тестирование — это метод тестирования точек интеграции, при котором каждое приложение тестируется изолированно, чтобы подтвердить, что сообщения, которые оно отправляет или получает, соответствуют взаимопониманию, задокументированному в «контракте». Такой подход гарантирует, что взаимодействие между различными частями системы соответствует ожиданиям. Контракт в контексте контрактного тестирования — это документ или спецификация, в которой зафиксировано соглашение о формате и структуре сообщений (запросов и ответов), которыми обмениваются приложения. Он служит основой для проверки того, что каждое приложение может правильно обрабатывать данные, отправленные и полученные другими участниками интеграции. Контракт устанавливается между потребителем (например, клиентом, желающим получить некоторые данные) и поставщиком (например, API на сервере, предоставляющим данные, необходимые клиенту). Тестирование, управляемое потребителями, — это подход к контрактному тестированию, при котором потребители создают контракты во время автоматизированных тестовых запусков. Эти контракты передаются поставщику, который затем запускает набор автоматических тестов. Каждый запрос, содержащийся в файле контракта, отправляется провайдеру, а полученный ответ сравнивается с ожидаемым ответом, указанным в файле контракта. Если оба ответа совпадают, это означает, что потребитель и поставщик услуг совместимы. Наконец, Пакт. Pact — это инструмент, реализующий идеи тестирования контрактов, ориентированного на потребителя. Он поддерживает тестирование как HTTP-интеграций, так и интеграций на основе сообщений, уделяя особое внимание разработке тестов с упором на код. Как я упоминал ранее, для нашей задачи мы можем использовать спецификации и инструменты контрактов Pact. Реализация может выглядеть так ( ): полный тестовый код setup: def openaiRequestCaptor = restExpectation.openai.completions(fromContract("openai/SuccessfulCompletion-Hello.json")) def telegramRequestCaptor = restExpectation.telegram.sendMessage(withSuccess("{}")) when: ... then: openaiRequestCaptor.times == 1 telegramRequestCaptor.times == 1 Файл контракта . доступен для просмотра Преимущество использования файлов контрактов заключается в том, что они содержат не только тело запроса и ответа, но и другие элементы спецификации внешних взаимодействий — путь запроса, заголовки и статус ответа HTTP, что позволяет полностью описать макет на основе такого контракта. Важно отметить, что в этом случае мы ограничиваемся контрактным тестированием и не распространяемся на тестирование, ориентированное на потребителя. Однако кто-то может захотеть изучить Pact дальше. Заключение В этой статье были рассмотрены практические рекомендации по повышению наглядности и эффективности интеграционных тестов в контексте разработки с помощью Spring Framework. Моя цель состояла в том, чтобы сосредоточиться на важности четкого определения спецификаций внешних взаимодействий и минимизации шаблонного кода. Для достижения этой цели я предложил использовать DSL-обертки и JsonAssert, хранить спецификации в файлах JSON и работать с контрактами через Pact. Описанные в статье подходы направлены на упрощение процесса написания и сопровождения тестов, улучшение их читаемости и, самое главное, повышение качества самого тестирования за счет точного отражения взаимодействий между компонентами системы. Ссылка на репозиторий проекта, демонстрирующий тесты — . sandbox/bot Спасибо за внимание к статье и удачи в написании эффективных и наглядных тестов!