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