Тестирование Trainer Advisor: сетап набора тестов (test suite)/ тестируемой системы (system under test)
April 4, 2024
Введение
Это второй пост о тестировании Trainer Advisor. Перед прочтением этого поста стоит ознакомиться с общими идеями и принципами, описанными в первом посте. А в этом посте я начал описывать работу с самой замороченной частью тестирования — фикстурой в самом общем смысле этого слова - запуск и приведение system under test (sut) к предопределённому состоянию (этот пост) и создание тестовых данных и наполнение ими БД (следующий пост).
На самом верхнем уровне весь процесс состоит из следующих шагов:
- Запустить инфраструктуру (сейчас - Postgres и Minio) или сделать для предзапущенной инфраструктуры "factory reset";
- Запустить приложение;
- Проинициализировать инфраструктуру (схему и бакеты);
- Для каждого тест кейса:
- Привести данные в БД к эталонным;
- Сформировать специфичные данные, которые sut загружает из хранилищ неявно в процессе испытания (вызова метода в блоке Then);
- Вставить эти данные в хранилища;
- Подготовить тестовые дубли.
Запуск инфраструктуры
Исходя из принципа "тестирование системы в конфигурации максимально приближенной к боевой" всю инфраструктуру, которой я сам управляю в проде (в случае TA - это Postgres и Minio, но в общем случае это могут быть очереди сообщений, управляемые сервисы и т.д.) я запускаю для тестов в Docker-контейнерах.
Однако так как я работаю по TDD и запускаю тесты по нескольку раз в минуту, мне надо чтобы на запуск теста уходило не более 10 секунд, поэтому пришлось изобрести несколько трюков, сокращающих время инициализации инфраструктуры.
Например — в отличие от (как мне кажется) общепринятой практики, я не использую правила JUnit-а, а создаю контейнеры руками.
Запуск контейнеров с помощью testcontainers
val pgContainer: PostgreSQLContainer<*> by lazy {
PostgreSQLContainer("postgres:15.2")
.withExposedPorts(5432)
.withUsername("postgres")
.withPassword("password")
.withDatabaseName("postgres")
.withTmpFs(mapOf("/var" to "rw"))
.withEnv("PGDATA", "/var/lib/postgresql/data-no-mounted")
.withReuse(true)
.withInitScript("db/qyoga-db-init.sql")
.apply {
start()
// Сначала подключаемся к postgres, пересоздаём qyoga для обнуления фикстуры и подключаемся к ней
this.withDatabaseName("qyoga")
}
}
val minioContainer: MinIOContainer by lazy {
MinIOContainer("minio/minio")
.withExposedPorts(9000)
.withUserName("user")
.withPassword("password")
.withTmpFs(mapOf("/tmp" to "rw"))
.withReuse(true)
.withEnv("MINIO_VOLUMES", "/tmp/minio")
.withCommand("server")
.apply {
start()
}
}
Я так делаю, потому что это единственный известный мне способ использовать один контейнер во всех тестах Test Suite-а. А это позволяет существенно (на вскидку - от двух секунд на тест кейс) сэкономить время за счёт экономии на запуске контейнера и Spring-контекста, который косит на этот контейнер.
Следующая пара оптимизаций реализуется с помощью этих параметров контейнеров:
- withTmpFs - создаёт в файловой системе контейнера директорию, которая мапится на оперативную память;
- withEnv - настраивает хранилище, на хранение данных в этой директории. Вместе эти две опции превращают реальный боевой Postgres, например, в фактически in-memory БД. И благодаря этому выполнение 22 текущих миграций проекта выполняется за 45 миллисекунд, а самый быстрый тест, выполняющий по 3 SELECT-а и INSERT-а, проходит за 20 +/- 5 миллисекунд.
- withReuse - настраивает testcontainers на переиспользование контейнер между запусками Test Suite. Эта настройка изначально позволяла сэкономить несколько секунд на запуске контейнеров, но сейчас стала пережитком и по большому счёту её можно удалить.
Потому что даже с ней просто инициализация testcontainers, чтобы посмотреть, что контейнер уже запущен, занимает порядка 0.5 секунды (или 5% всего временнОго бюджета на тест) на моей машине. И чтобы срезать эти 0.5 секунды при девелопменте, инфраструктуру для тестов на рабочей машине я запускаю руками.
Предзапуск контейнеров руками
Для этого у меня есть специализированный Docker Compose проект, который создаёт сервисы на RAM-дисках:
# Проект test-инфраструктуры
# test-инфраструктура хранит данные на docker tmpfs volume (данные не переживают перезапуск контейнера)
# Тесты настроены таким образом, что на старте пытаются подключиться к тестовой инфраструктуре и
# запускают контейнеры через testcontainers только если не получается
version: '3.8'
name: qyoga-tests-infra
services:
postgres:
extends:
file: docker-compose-infra-base.yml
service: postgres
container_name: qg-pg-tests
environment:
PGDATA: /tmp/pgdata
tmpfs:
- /tmp
ports:
- "54502:5432"
minio:
extends:
file: docker-compose-infra-base.yml
service: minio
container_name: qg-minio-tests
environment:
MINIO_VOLUMES: /tmp/minio
tmpfs:
- /tmp
ports:
- "50001:9000"
- "9020:9020"
command: server --console-address ":9020"
Запускается он командой:
docker compose -f deploy/qyoga/docker-compose-infra-tests.yml up --detach
Для которой у меня в проекте хранится IDEA-вская Shell Script Run Configuration:

Совсем переходить на предзапущенную инфраструктуру я не хочу потому что:
- Придётся что-то выдумывать на CI;
- Это усложнит опыт новых разработчиков — сейчас разработку можно начать буквально за три действия — зачекаутить проект, открыть его в идее, запустить main-метод или нужный тест.
Поэтому у меня есть ещё кусочек специализированного кода, для определения URL-ов подключения к инфраструктуре.
Поиск контейнера в рантайме
Например, определение URL подключения к Postgres выглядит так:
const val PROVIDED_DB_URL = "jdbc:postgresql://localhost:54502/qyoga"
object TestDb
private val log = LoggerFactory.getLogger(TestDb::class.java)
private const val DB_USER = "postgres"
private const val DB_PASSWORD = "password"
val jdbcUrl: String by lazy {
try {
log.info("Checking for provided db")
val con = DriverManager.getConnection(
PROVIDED_DB_URL.replace("qyoga", DB_USER),
DB_USER,
DB_PASSWORD
)
log.info("Provided db found, recreating it")
con.prepareStatement(
"""
DROP DATABASE IF EXISTS qyoga;
CREATE DATABASE qyoga;
""".trimIndent()
)
.execute()
log.info("Provided db found, recreated")
PROVIDED_DB_URL
} catch (e: SQLException) {
log.info("Provided Db not found: ${e.message}")
pgContainer.jdbcUrl
}
}
val testDataSource by lazy {
val config = HikariConfig().apply {
this.jdbcUrl = pro.qyoga.tests.infra.db.jdbcUrl
this.username = DB_USER
this.password = DB_PASSWORD
}
HikariDataSource(config)
}
Здесь определяется несколько констант подключения к предзапущенному Postgres и две ленивых переменных - jdbcUrl
и testDataSource
.
Вся магия происходит при инициализации jdbcUrl
.
Сначала выполняется попытка подключения к предзапущенному Postgres.
Если подключение проходит - в этом Postgres-е пересоздаётся БД и далее для подключения к БД используется URL предзапущенного Postgres.
Если нет - идёт обращение к URL тест-контейнера через также ленивую переменную pgContainer
.
В этот момент фактически запускается контейнер, и Postgres в нём инициализируется скриптом, создающим пустую БД qyoga.
В итоге, обратившись к jdbcUrl
, мы получаем URL, который косит на запущенный Postgres с пустой БД qyoga.
Далее этот URL используется для создания экземпляра DataSource, который через специальный Spring-конфиг будет подложен бином в контекст приложения, запущенного в тестах.
В случае Minio схема практически такая же:
const val MINIO_URL = "http://localhost:50001"
object TestMinio
private val log = LoggerFactory.getLogger(TestMinio::class.java)
private const val MINIO_USER = "user"
private const val MINIO_PASSWORD = "password"
val minioUrl: String by lazy {
try {
log.info("Checking for provided minio")
val con = MinioClient.builder()
.endpoint(MINIO_URL)
.credentials(MINIO_USER, MINIO_PASSWORD)
.build()
con.listBuckets()
log.info("Provided minio found, cleaning it")
dropBuckets(con)
log.info("Provided minio cleaned")
MINIO_URL
} catch (e: ConnectException) {
log.info("minio container not found: ${e.message}")
log.info("http://" + minioContainer.host + ":" + minioContainer.firstMappedPort)
log.info("Cleaning testcontainers minio")
dropBuckets(
MinioClient.builder()
.endpoint(minioContainer.s3URL)
.credentials(minioContainer.userName, minioContainer.password)
.build()
)
log.info("Minio cleaned")
"http://" + minioContainer.host + ":" + minioContainer.firstMappedPort
}
}
val testMinioClient: MinioClient by lazy {
MinioClient.builder()
.endpoint(minioUrl)
.credentials(MINIO_USER, MINIO_PASSWORD)
.build()
}
fun dropBuckets(client: MinioClient) {
client.listBuckets().forEach { bucket ->
client.listObjects(ListObjectsArgs.builder().bucket(bucket.name()).build()).forEach {
client.removeObject(
RemoveObjectArgs.builder().bucket(bucket.name()).`object`(it.get().objectName())
.build()
)
}
}
}
Единственное что, для Minio удаление бакетов выполняется руками в обоих случаях.
Фух… Это было сложнее, чем мне казалось.
Запуск приложения
С запуском приложения примерно та же история, что и с инфраструктурой - я запускаю практически такой же Spring-контекст, как и в проде, но с некоторыми приседаниями во имя скорости запуска.
Конфигурация приложения для тестов
Конфигурация приложения для тестов выглядит так:
@Import(
QYogaApp::class,
BackgroundsConfig::class,
TestPasswordEncoderConfig::class,
TestDataSourceConfig::class,
TestMinioConfig::class,
FailingController::class
)
@Configuration
class TestsConfig
Здесь:
- QYogaApp - собственно та самая продовая конфигурация приложения;
- BackgroundsConfig - конфиг, создающий бины бэкграундов (теория);
- TestPasswordEncoderConfig - конфиг, переопределяющий PasswordEncoder в продовом контексте на Noop, так как продовый BCrypt хэширует пароли при логине по 300мс, а у меня практически каждый тест начинается с логина;
TestDataSourceConfig и TestMinioConfig - конфиги, переопределяющие DataSource и MinioClient в продовом контексте, на объекты, созданные руками.
FailingController - определяет контроллер, который всегда 500-ит, используется для теста конфигурации обработчика ошибок.
Запуск приложения
Запуск приложения выполняется с помощью стандартной Boot-овой ручки:
val context: ConfigurableApplicationContext by lazy {
SpringApplicationBuilder(TestsConfig::class.java)
.profiles("test")
.build()
.run()
}
Контекст создаётся и переменная context
инициализируется при инициализации первого теста, наследующего QYogaAppBaseTest:
open class QYogaAppBaseTest {
private val dataSource: DataSource = context.getBean(DataSource::class.java)
protected val port: Int = context.getBean(ServerProperties::class.java).port
protected val backgrounds: Backgrounds = context.getBean(Backgrounds::class.java)
inline fun <reified T> getBean(): T =
context.getBean(T::class.java)
@BeforeEach
fun setupTestData() {
dataSource.setupDb()
}
}
Инициализация хранилищ
При запуске приложения аналогичным продовому образом выполняется инициализация хранилищ - создание схемы БД Postgres и бакетов Minio. Создание схемы БД выполняется автомагически.
А бакеты инициализируют бины, отвечающие за эти бакеты в своих @PostConstruct-методе:
open class MinioFilesStorage(
// ...
private val bucket: String
) : FilesStorage {
@PostConstruct
fun init() {
if (!minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucket).build())) {
minioClient.makeBucket(MakeBucketArgs.builder().bucket(bucket).build())
}
}
// ...
}
Итого перед началом выполнения первого метода тест кейса мы имеем:
- Запущенные контейнеры Postgres и Minio, состояние которых сброшено до исходного (как минимум перед запуском текущего набора тестов);
- Запущенное приложение, в конфигурации, максимально приближенной к боевой;
- Заранее детерминированное и всегда идентичное исходное наполнение БД.
Теперь остался последний этап сетапа фикстуры — создание и, при необходимости, вставка данных, специфичных для теста. Его я рассмотрю в следующем посте.
Бонус-трэк
В Trainer Advisor всей это инфраструктуры (пока?) нет, но 2024 году пост об интеграционном тестировании, без описания тестирования интеграций с внешними сервисами и брокерами сообщений нельзя считать полноценным. Поэтому в качестве бонус-трека, я расскажу о том, как мы тестировали такие интеграции в Проекте Э.
Запуск WireMock
В Проекте Э, как и всех других своих проектах за последние четыре года, для тестирования кода, работающего с внешними системами по HTTP, я использовал WireMock.
C JUnit 5 он интегрируется достаточно просто.
В первую очередь, необходимо настроить sut на обращение к localhost:<wireMockPort>
.
В Spring Boot это можно сделать с помощью application-test.yaml
-файла - файла конфигурации, который будет загружен при запуске приложения с профилем test и переопределит настройки из основного application.yaml
:
smtp:
base-url: http://localhost:8081
from: no-reply@project-e.com
api-key: api-key
А затем, с помощью стандартного расширения для JUnit надо настроить запуск WireMock-сервера на том же порту, что прописан в тестовой конфигурации:
@WireMockTest(httpPort = 8081)
@ActiveProfiles("test")
class SmtpBzTest {
// ...
}
После чего, к методам тест-кейсов можно будет добавить параметр типа WireMockRuntimeInfo
и использовать его для сетапа моков запросов:
@Test
fun `EmailSender should send correct http request to SmtpBz`(wm: WireMockRuntimeInfo) {
// Given
wm.register(SMTPBZ_POST_EMAIL_URL, HttpStatus.OK)
// ...
}
Запуск RabbitMQ для тестов
В Проекте Э по историческим причинам значительная часть общения внутри приложения шла через RabbitMQ. Соответственно, многим тестам для работы нужен Кролик и он запускается практически так же, как и Postgres в Trainer Advisor:
val rabbitMqContainer by lazy {
with(RabbitMQContainer("rabbitmq:3-management-alpine")) {
withAdminPassword("password")
withExposedPorts(5672)
withReuse(reuse)
withEnv(
mapOf(
// Это не получается задать через АПИ
"RABBITMQ_DEFAULT_USER" to "eventbus",
"RABBITMQ_DEFAULT_VHOST" to "eventbus",
)
)
}
.apply { start() }
}
Тут контейнер создаётся не на RAM-диске. Сейчас наверняка не помню - было ли это осознанное решение, или контейнер сетапил юниор в мыле старта реинжиниринга и так и осталось.
Далее используется Spring Context Initializer, который прокидывает параметры подключения (в первую очередь - случайный порт) в контекст:
class TestContainerMqContextInitializer : ApplicationContextInitializer<ConfigurableApplicationContext> {
override fun initialize(applicationContext: ConfigurableApplicationContext) {
applicationContext.environment.propertySources.addFirst(
MapPropertySource(
"Integration rabbit mq test properties",
mapOf(
"spring.rabbitmq.port" to rabbitMqContainer.getMappedPort(5672),
"spring.rabbitmq.host" to rabbitMqContainer.host,
"spring.rabbitmq.password" to "password",
"spring.rabbitmq.virtual-host" to "eventbus",
"spring.rabbitmq.username" to "eventbus",
)
)
)
}
}
При этом Кролик будет запущен при первом обращении к rabbitMqContainer
.
Далее, этот инициалайзер прописывается в базовом классе тестов:
@ContextConfiguration(
classes = [ProjectEApp::class, /* ... */],
initializers = [TestContainerDbContextInitializer::class, TestContainerMqContextInitializer::class]
)
@SpringBootTest(
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT
)
class ProjectEBaseTest(/* ... */) { /* ... */ }
После чего тесты, которым нужен Кролик, наследуются от него, регистрируют своего слушателя и через него смотрят, что SUT отправил то, что надо - подробнее об этом в следующем посте.
Запуск Kafka для тестов
Фичу, которая включала отправку данных в Кафку внешней системы, делал юниор и под давлением сроков, поэтому в тестировании этой фичи есть нюанс: в отличие от тестов с Кроликом, тестовый слушатель сделан Spring Bean-ом и, соответственно, один общий на все тесты.
Сама Кафка запускается в контейнере практически так же, как и Кролик:
val kafkaContainer by lazy {
KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:6.2.1"))
.apply {
withReuse(true)
start()
}
}
Далее в дело также вступает Context Initializer:
class TestContainerKafkaContextInitializer : ApplicationContextInitializer<ConfigurableApplicationContext> {
override fun initialize(applicationContext: ConfigurableApplicationContext) {
applicationContext.environment.propertySources.addFirst(
MapPropertySource(
"Integration kafka test properties",
mapOf(
"external-system.client.kafka.broker" to kafkaContainer.bootstrapServers.substringAfterLast("//"),
)
)
)
AdminClient.create(mapOf(AdminClientConfig.BOOTSTRAP_SERVERS_CONFIG to kafkaContainer.bootstrapServers)).use {
it.deleteTopics(listOf(applicationContext.environment.getProperty("external-system.client.kafka.topic")))
}
}
}
Здесь, помимо проброса параметров подключения к Кафке, перед каждым запуском набора тестов удаляется единственный топик, в который отправляются сообщения для сброса состояния.
А вот дальше начинаются отличия от сетапа тестов с Кроликом.
Во-первых, в тестах определяется общий бин слушателя:
@Component
class TestKafkaListener(
private val objectMapper: ObjectMapper
) {
private val log = LoggerFactory.getLogger(javaClass)
private val events = ConcurrentLinkedQueue<ExternalSystemEventMessage>()
@KafkaListener(topics = ["\${external-system.client.kafka.topic}"])
fun externalSystemEvents(@Payload message: String) {
assertThat(message, matchesJsonSchemaInClasspath("kafka-message-scheme.json"))
val event = objectMapper.readValue(message, ExternalSystemEventMessage::class.java)
log.info("Consuming {}", event)
events.add(event)
}
fun getEvents() = events.toList()
fun clearEvents() {
log.info("Clearing events")
events.clear()
}
}
Во-вторых, определяется конфиг, который добавляет в контекст Кафковую инфраструктуру и этот слушатель:
@EnableKafka
@ComponentScan
class KafkaTestConfig(
private val kafkaClientProps: ExternalSystemKafkaClientProps
) {
@Bean
fun kafkaListenerContainerFactory(
consumerFactory: ConsumerFactory<String, String>
): KafkaListenerContainerFactory<ConcurrentMessageListenerContainer<String, String>> {
val factory = ConcurrentKafkaListenerContainerFactory<String, String>()
factory.setConcurrency(1)
factory.consumerFactory = consumerFactory
factory.containerProperties.pollTimeout = 1000
return factory
}
@Bean
fun consumerFactory(map: Map<String, Any>): ConsumerFactory<String, String> {
return DefaultKafkaConsumerFactory(map)
}
@Bean
fun consumerConfigs(): Map<String, Any> {
val props: MutableMap<String, Any> = java.util.HashMap()
props[ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG] = kafkaClientProps.broker
props[ConsumerConfig.GROUP_ID_CONFIG] = "the-only-group"
props[ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG] = false
props[ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG] = StringDeserializer::class.java
props[ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG] = StringDeserializer::class.java
return props
}
}
Далее, контейнер запускается так же, как и в Кролике - через регистрацию Context Initializer в базовом тесте, а в конкретных тестах дополнительно прописывается конфиг Кафки и инжектится тестовый лисенер:
@ContextConfiguration(
classes = [KafkaTestConfig::class],
)
class SendEventsTest(
@Autowired private val testKafkaListener: TestKafkaListener
) : ExternalSystemAppBaseTest() {
}
Запуск Mqtt-брокера для тестов
Запуск MQTT-брокера практически не отличается от запуска Кролика.
Лениво запускаем контейнер:
val mqttServerContainer by lazy {
HiveMQContainer(DockerImageName.parse("hivemq/hivemq-ce:latest").withTag("2021.3"))
.withReuse(true)
.apply { start() }
}
Прокидываем параметры подключения в контекст с помощью Context Initializer:
class TestContainerMqttServerContextInitializer : ApplicationContextInitializer<ConfigurableApplicationContext> {
override fun initialize(applicationContext: ConfigurableApplicationContext) {
applicationContext.overrideProperties(
"project-e.integrations.external-system.address" to "tcp://${mqttServerContainer.host}:${mqttServerContainer.mqttPort}",
"project-e.integrations.external-system.enabled" to "true"
)
}
}
И прописываем его в базовом классе тестов:
- ProjectEIntegrationTest
@ContextConfiguration(
classes = [
ProjectEApp::class, /* ... */,
],
initializers = [
TestContainerDbContextInitializer::class,
TestContainerMqttServerContextInitializer::class
]
)
@SpringBootTest(
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
)
@ActiveProfiles("test")
class ProjectEIntegrationTest(/* ... */)
Запуск внутреннего сервиса
По историческим причинам одна из фич в ядре Проекта Э зависит от другого внутреннего сервиса, написанного на Node.js. Сам сервис собирается в контейнер и публикуется в приватный реестр.
Поэтому для тестов этой фичи он запускается по стандартной уже схеме:
val internalServiceContainer by lazy {
GenericContainer("project-docker-registry/project-e/internal-service:latest")
.withExposedPorts(5000)
.withReuse(true)
.apply { start() }
}
class TestContainerInternalServiceContextInitializer : ApplicationContextInitializer<ConfigurableApplicationContext> {
override fun initialize(applicationContext: ConfigurableApplicationContext) {
applicationContext.overrideProperties(
"project-e.internal-service.url" to internalServiceContainer.serviceUrl()
)
}
}
fun GenericContainer<*>.serviceUrl() =
"http://${internalServiceContainer.host}:${getMappedPort(5000)}"
@ContextConfiguration(initializers = [TestContainerInternalServiceContextInitializer::class])
class ReportsServiceTest(/* ... */) : ProjectEAppWithFakeInfraBaseTest()