Привет, Хабр! С ростом количества микросервисов и их взаимосвязей может возникнуть потребность комплексной проверки работоспособности системы. Со временем 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-процесс для непрерывной проверки корректного поведения системы на раннем этапе. При последующем развитии и доработках микросервисов мы с лёгкостью сможем увеличить объём сценариев тестирования благодаря заложенному фундаменту, а также будем спокойными за регресс.


Спасибо за внимание!

Комментарии (4)


  1. Fuud
    06.04.2022 09:27
    +1

    А мы используем nanocloud

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


    1. eseniy_anavrin Автор
      06.04.2022 09:53

      Спасибо за то, что поделились опытом! Посмотрю библиотечку на досуге.
      Как правило, для интеграционных тестов, помимо запуска микросервисов, требуется поднимать необходимое окружение (бд, брокеры очередей и тд). Подскажите, как вы решаете подобный вопрос?

      В статье описывал кейс внедрения интеграционных тестов в CI/CD. Предполагается, что их запуск - отдельный шаг, который может быть обязательным или опциональным. На этом этапе у нас образы уже собраны и запушены, поэтому ничего дополнительно делать не нужно.
      По поводу запуска из идеи - вопрос можно решить локальным файлом docker-compose, в котором вместо указания образа, можно описать как собирать и запускать наши приложения.


      1. Fuud
        06.04.2022 10:25

        Про окружение: сейчас мы используем Кафку - ее мы поднимаем также, как и остальные микросервисы. Оракл заменяем на h2. Можно использовать testcontainers - не вижу проблем (мы не используем по бюрократическим причинам)

        Про запуск из идеи и docker-compose. Можно так. Я не уверен, но мне кажется, что тогда не получится сделать написание тестов частью разработки. Перед каждым прогоном придется заново паковать. Тут идея в том, что запуск интеграционных тестов с только что сделанными изменениями становится таким же простым, как и запуск юнит тестов.

        Но есть и сложности: например, надо сформировать правильный класспас.


        1. eseniy_anavrin Автор
          06.04.2022 13:25

          Спасибо за ответ!
          Вероятно, просто разный подход.
          В описанном в статье кейсе предполагается, что интеграционные тесты идут отдельным шагом. На момент их запуска все юнит-тесты пройдены и приложение уже собрано. Интеграционные тесты не такие легковесные, как юнит-тесты и требуют больше времени на запуск. Поэтому они вынесены из этапа сборки в отдельный шаг, чтобы опционально была возможность быстро собирать приложение локально / на фича-ветках без них. На ветках уровня release / master можно сделать данный шаг обязательным.