Интеграционное тестирование микросервисов Spring Boot в монорепозитории

от автора

Привет, Хабр! С ростом количества микросервисов и их взаимосвязей может возникнуть потребность комплексной проверки работоспособности системы. Со временем 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/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *