Привет, Хабр! С ростом количества микросервисов и их взаимосвязей может возникнуть потребность комплексной проверки работоспособности системы. Со временем 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-процесс для непрерывной проверки корректного поведения системы на раннем этапе. При последующем развитии и доработках микросервисов мы с лёгкостью сможем увеличить объём сценариев тестирования благодаря заложенному фундаменту, а также будем спокойными за регресс.
Спасибо за внимание!
Fuud
А мы используем nanocloud
Он позволяет запускать микросервисы каждый со своим класспасом. И не надо предварительно собирать образы, пушить их и т.п. Таким образом можно встраивать написание интеграционных тестов прямо в процесс разработки, запускать их прямо из идеи, использовать их там, где юнит тесты уже не подходят.
eseniy_anavrin Автор
Спасибо за то, что поделились опытом! Посмотрю библиотечку на досуге.
Как правило, для интеграционных тестов, помимо запуска микросервисов, требуется поднимать необходимое окружение (бд, брокеры очередей и тд). Подскажите, как вы решаете подобный вопрос?
В статье описывал кейс внедрения интеграционных тестов в CI/CD. Предполагается, что их запуск - отдельный шаг, который может быть обязательным или опциональным. На этом этапе у нас образы уже собраны и запушены, поэтому ничего дополнительно делать не нужно.
По поводу запуска из идеи - вопрос можно решить локальным файлом docker-compose, в котором вместо указания образа, можно описать как собирать и запускать наши приложения.
Fuud
Про окружение: сейчас мы используем Кафку - ее мы поднимаем также, как и остальные микросервисы. Оракл заменяем на h2. Можно использовать testcontainers - не вижу проблем (мы не используем по бюрократическим причинам)
Про запуск из идеи и docker-compose. Можно так. Я не уверен, но мне кажется, что тогда не получится сделать написание тестов частью разработки. Перед каждым прогоном придется заново паковать. Тут идея в том, что запуск интеграционных тестов с только что сделанными изменениями становится таким же простым, как и запуск юнит тестов.
Но есть и сложности: например, надо сформировать правильный класспас.
eseniy_anavrin Автор
Спасибо за ответ!
Вероятно, просто разный подход.
В описанном в статье кейсе предполагается, что интеграционные тесты идут отдельным шагом. На момент их запуска все юнит-тесты пройдены и приложение уже собрано. Интеграционные тесты не такие легковесные, как юнит-тесты и требуют больше времени на запуск. Поэтому они вынесены из этапа сборки в отдельный шаг, чтобы опционально была возможность быстро собирать приложение локально / на фича-ветках без них. На ветках уровня release / master можно сделать данный шаг обязательным.