
Привет, Хабр! С ростом количества микросервисов и их взаимосвязей может возникнуть потребность комплексной проверки работоспособности системы. Со временем API сервисов и их поведение может дорабатываться и изменяться, при этом хочется иметь уверенность, что система микросервисов в совокупности ведёт себя согласно ожиданиям. Мы разберём простой пример написания интеграционных тестов, которые в дальнейшем можно встроить в CI/CD-процесс для решения подобной проблемы.
Исходные данные

Наша система состоит из двух микросервисов Service А и Service B, представляющих собой Spring Boot-приложения. Исходный код сервисов хранится в монорепозитории. Service А содержит API для импорта данных из внешнего сервиса External Service. Service B хранит импортированные данные и предоставляет API для записи и доступа к ним. Для наглядности приведём API каждого из сервисов:
Service А
Метод отправки запроса на импорт данных POST /import/data Response: { id: 1}
Service B
Метод сохранения импортируемых данных POST /import/data BODY: { value: ‘data’ } Response: { id: 1 }
Метод получения импортированных данных GET /data/{id} Response: { value: ‘data’ }
External Service
Метод получения данных Request: GET /data Response: { value: ‘data’ }
Наша цель — покрыть интеграционными тестами взаимодействие микросервисов при импорте данных из внешнего источника. Для написания тестов будем использовать следующие инструменты:
-
https://www.testcontainers.org/ — библиотека, позволяющая поднимать внутри контейнеров необходимое для тестирования окружение;
-
https://serenity-bdd.info/ — библиотека, помогающая писать простые и структурированные тесты благодаря оперированию абстракциями;
-
https://rest-assured.io/ — библиотека, предоставляющая удобный DSL для тестирования REST-сервисов;
-
https://www.mock-server.com/ — библиотека для создания mock-серверов, позволяющая эмулировать ответы на заданные REST-запросы.
Подготовка сервисов и окружения
Внутри монорепозитория создаём отдельный модуль, в котором будут храниться и запускаться сценарии интеграционных тестов. Затем создаём файл Docker-compose и описываем в нём наши микросервисы, а также базу данных. Предварительно собираем и пушим образы микросервисов.
version: '3.9' networks: integration-network: driver: bridge services: a: image: a-image:v1 networks: - integration-network ports: - "8081:8080" - "18081:18080" b: image: b-image:v1 depends_on: - b-db networks: - integration-network ports: - "8082:8080" - "18082:18080" b-db: image: postgres:13.3 networks: - integration-network ports: - "5432:5432" environment: - POSTGRES_PASSWORD=password
Создаём в новом модуле базовый класс для интеграционного тестирования. В нём добавляем экземпляр класса DockerComposeContainer и передаём путь к файлу docker-compose. C помощью метода withExposedService описываем сервисы, которые необходимо поднять, указывая параметры serviceName, servicePort и waitStrategy. Здесь waitStrategy — критерий готовности сервиса к работе. В качестве критерия будем использовать HealthCheckStrategy. Указываем, что хотим мониторить доступность ручки health check, предоставляемой Spring Boot Actuator, и ожидаем, что она должна быть доступна в течение 60 секунд. Если этого не произойдёт, testcontainers выбросит ошибку и выполнение тестов будет остановлено. Также определяем mock-сервер, чтобы в дальнейшем иметь возможность эмулировать ответы на запросы к ExternalService.
abstract class BaseIntegrationTest { protected lateinit var externalService: ClientAndServer companion object { private const val DOCKER_COMPOSE_PATH = "src/test/resources/docker-compose.yml" private const val HEALTH_URL = "/actuator/health" private val DOCKER_COMPOSE: KDockerComposeContainer = KDockerComposeContainer(File(DOCKER_COMPOSE_PATH)) .withExposedService("a", 18080, HealthCheckStrategy().strategy()) .withExposedService("b", 18080, HealthCheckStrategy().strategy()) init { DOCKER_COMPOSE.start() } } @Before fun setUpExternalServer() { externalService = ClientAndServer.startClientAndServer(55555) } @After fun shutDownServer() { externalService.stop() } private class KDockerComposeContainer(file: File) : DockerComposeContainer<KDockerComposeContainer>(file) private class HealthCheckStrategy { fun strategy(): WaitStrategy = HttpWaitStrategy() .forPath(HEALTH_URL) .forStatusCode(200) .withStartupTimeout(Duration.ofSeconds(60)) } }
Написание сценариев тестирования
Библиотека serenity-bdd позволяет описывать шаги тестирования. Основное преимущество использования шагов заключается в инкапсуляция логики взаимодействия с сервисом внутри понятной и удобочитаемой абстракции, а также возможность их многократного переиспользования в тестах.
Для нашего примера мы будем использовать две ключевые аннотации: @Step и @Steps. @Step вешается на метод, внутри которого описан конкретный шаг тестирования, а @Steps используется для внедрения набора описанных шагов внутрь тестового класса.
Для описания шагов используем возможности библиотеки rest-assured:
-
Given позволяет определить спецификацию, натравленную на базовый URL вызываемого сервиса;
-
When — rest-запрос, который необходимо выполнить;
-
Then — указываем ожидания от выполнения запроса;
-
Extract — извлекаем из полученного ответа результат в нужном формате.
Для начала создадим класcы с описанием шагов тестирования, которыми в дальнейшем будем оперировать.
class ServiceASteps { @Step fun importDataFromExternalService() = Given { spec(aServiceSpec) } When { post("/import/data") } Then { spec(successResponseSpec) } Extract { `as`(Long::class.java) } // ... // Набор шагов сервиса А private val aServiceSpec = RequestSpecBuilder() .setBaseUri("http://localhost:8081") .setContentType(ContentType.JSON) .build() private val successResponseSpec = ResponseSpecBuilder() .expectStatusCode(200) .build() }
Шаг importDataFromExternalService описывает отправку post-запроса в Service A на импорт данных из External Service, с последующим сохранением преобразованных данных в Service B. Ожидаем, что получим от сервиса успешный ответ и сможем извлечь идентификатор созданной сущности.
Аналогично опишем шаги тестирования для Service B:
class ServiceBSteps { @Step fun getData(id: Long) = Given { spec(bServiceSpec) } When { get("/data/$id") } Then { spec(successResponseSpec) } Extract { `as`(Data::class.java) } // ... // Набор шагов сервиса B private val bServiceSpec = RequestSpecBuilder() .setBaseUri("http://localhost:8082") .setContentType(ContentType.JSON) .build() private val successResponseSpec = ResponseSpecBuilder() .expectStatusCode(200) .build() }
Теперь напишем простенький тест с использованием шагов, описанных выше. Для этого:
-
Mock-аем запрос к внешнему сервису, указывая требуемый ответ.
-
Внедряем шаги Service А и Service В внутрь нашего теста с помощью аннотации
@Steps. -
Описываем тестовый сценарий, вызывающий импорт данных и проверку их корректного получения.
@SerenityTest class TestExample : BaseIntegrationTest() { @Steps private lateinit var aSteps: ServiceASteps @Steps private lateinit var bSteps: ServiceBSteps @Before fun mockServiceResponses() { externalService .`when`( HttpRequest .request() .withMethod("GET") .withPath("/data"), Times.unlimited() ).respond( HttpResponse .response() .withStatusCode(200) .withBody(loadResource("external-data.json")) ) } @Test fun `import data from external service - happy path`() { val expectedDataValue = "data" val dataId = aSteps.importDataFromExternalService() val data = bSteps.getData(dataId) assertEquals(expectedDataValue, data.value) } }
Итог
Мы покрыли интеграционными тестами взаимодействие микросервисов в монорепозитории. Теперь мы можем внедрить их в CI/CD-процесс для непрерывной проверки корректного поведения системы на раннем этапе. При последующем развитии и доработках микросервисов мы с лёгкостью сможем увеличить объём сценариев тестирования благодаря заложенному фундаменту, а также будем спокойными за регресс.
Спасибо за внимание!
ссылка на оригинал статьи https://habr.com/ru/company/domclick/blog/658393/
Добавить комментарий