Тестирование Trainer Advisor: сетап набора тестов (test suite)/ тестируемой системы (system under test)

April 4, 2024

Введение

Это второй пост о тестировании Trainer Advisor. Перед прочтением этого поста стоит ознакомиться с общими идеями и принципами, описанными в первом посте. А в этом посте я начал описывать работу с самой замороченной частью тестирования — фикстурой в самом общем смысле этого слова - запуск и приведение system under test (sut) к предопределённому состоянию (этот пост) и создание тестовых данных и наполнение ими БД (следующий пост).

На самом верхнем уровне весь процесс состоит из следующих шагов:

  1. Запустить инфраструктуру (сейчас - Postgres и Minio) или сделать для предзапущенной инфраструктуры "factory reset";
  2. Запустить приложение;
  3. Проинициализировать инфраструктуру (схему и бакеты);
  4. Для каждого тест кейса:
    1. Привести данные в БД к эталонным;
    2. Сформировать специфичные данные, которые sut загружает из хранилищ неявно в процессе испытания (вызова метода в блоке Then);
    3. Вставить эти данные в хранилища;
    4. Подготовить тестовые дубли.

Запуск инфраструктуры

Исходя из принципа "тестирование системы в конфигурации максимально приближенной к боевой" всю инфраструктуру, которой я сам управляю в проде (в случае TA - это Postgres и Minio, но в общем случае это могут быть очереди сообщений, управляемые сервисы и т.д.) я запускаю для тестов в Docker-контейнерах.

Однако так как я работаю по TDD и запускаю тесты по нескольку раз в минуту, мне надо чтобы на запуск теста уходило не более 10 секунд, поэтому пришлось изобрести несколько трюков, сокращающих время инициализации инфраструктуры.

Например — в отличие от (как мне кажется) общепринятой практики, я не использую правила JUnit-а, а создаю контейнеры руками.

Запуск контейнеров с помощью testcontainers

Создание контейнеров. Смотреть на GitHub
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-контекста, который косит на этот контейнер.

Следующая пара оптимизаций реализуется с помощью этих параметров контейнеров:

  1. withTmpFs - создаёт в файловой системе контейнера директорию, которая мапится на оперативную память;
  2. withEnv - настраивает хранилище, на хранение данных в этой директории. Вместе эти две опции превращают реальный боевой Postgres, например, в фактически in-memory БД. И благодаря этому выполнение 22 текущих миграций проекта выполняется за 45 миллисекунд, а самый быстрый тест, выполняющий по 3 SELECT-а и INSERT-а, проходит за 20 +/- 5 миллисекунд.
  3. withReuse - настраивает testcontainers на переиспользование контейнер между запусками Test Suite. Эта настройка изначально позволяла сэкономить несколько секунд на запуске контейнеров, но сейчас стала пережитком и по большому счёту её можно удалить.

Потому что даже с ней просто инициализация testcontainers, чтобы посмотреть, что контейнер уже запущен, занимает порядка 0.5 секунды (или 5% всего временнОго бюджета на тест) на моей машине. И чтобы срезать эти 0.5 секунды при девелопменте, инфраструктуру для тестов на рабочей машине я запускаю руками.

Предзапуск контейнеров руками

Для этого у меня есть специализированный Docker Compose проект, который создаёт сервисы на RAM-дисках:

docker-compose-infra-tests.yml. Смотреть на Github
# Проект 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:

2024 03 31 16 31 06

Совсем переходить на предзапущенную инфраструктуру я не хочу потому что:

  1. Придётся что-то выдумывать на CI;
  2. Это усложнит опыт новых разработчиков — сейчас разработку можно начать буквально за три действия — зачекаутить проект, открыть его в идее, запустить main-метод или нужный тест.

Поэтому у меня есть ещё кусочек специализированного кода, для определения URL-ов подключения к инфраструктуре.

Поиск контейнера в рантайме

Например, определение URL подключения к Postgres выглядит так:

Выбор URL Postgres. Смотреть на GitHub
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 схема практически такая же:

Выбор контейнера Minio. Смотреть на GitHub
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-контекст, как и в проде, но с некоторыми приседаниями во имя скорости запуска.

Конфигурация приложения для тестов

Конфигурация приложения для тестов выглядит так:

Конфигурация приложения для тестов. Смотреть на GitHub
@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-овой ручки:

Запуск приложения. Смотреть на Github
val context: ConfigurableApplicationContext by lazy {
    SpringApplicationBuilder(TestsConfig::class.java)
        .profiles("test")
        .build()
        .run()
}

Контекст создаётся и переменная context инициализируется при инициализации первого теста, наследующего QYogaAppBaseTest:

QYogaAppBaseTest. Смотреть на GitHub
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-методе:

Инициализация бакетов Minio. Смотреть на GitHub
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())
        }
    }

    // ...

}

Итого перед началом выполнения первого метода тест кейса мы имеем:

  1. Запущенные контейнеры Postgres и Minio, состояние которых сброшено до исходного (как минимум перед запуском текущего набора тестов);
  2. Запущенное приложение, в конфигурации, максимально приближенной к боевой;
  3. Заранее детерминированное и всегда идентичное исходное наполнение БД.

Теперь остался последний этап сетапа фикстуры — создание и, при необходимости, вставка данных, специфичных для теста. Его я рассмотрю в следующем посте.

Бонус-трэк

В Trainer Advisor всей это инфраструктуры (пока?) нет, но 2024 году пост об интеграционном тестировании, без описания тестирования интеграций с внешними сервисами и брокерами сообщений нельзя считать полноценным. Поэтому в качестве бонус-трека, я расскажу о том, как мы тестировали такие интеграции в Проекте Э.

Запуск WireMock

В Проекте Э, как и всех других своих проектах за последние четыре года, для тестирования кода, работающего с внешними системами по HTTP, я использовал WireMock.

C JUnit 5 он интегрируется достаточно просто.

В первую очередь, необходимо настроить sut на обращение к localhost:<wireMockPort>.

В Spring Boot это можно сделать с помощью application-test.yaml-файла - файла конфигурации, который будет загружен при запуске приложения с профилем test и переопределит настройки из основного application.yaml:

application-test.yaml
smtp:
  base-url: http://localhost:8081
  from: no-reply@project-e.com
  api-key: api-key

А затем, с помощью стандартного расширения для JUnit надо настроить запуск WireMock-сервера на том же порту, что прописан в тестовой конфигурации:

Запуск WireMock-сервера
@WireMockTest(httpPort = 8081)
@ActiveProfiles("test")
class SmtpBzTest {
    // ...
}

После чего, к методам тест-кейсов можно будет добавить параметр типа WireMockRuntimeInfo и использовать его для сетапа моков запросов:

Передача 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:

Запуск контейнера RabbitMQ в тестах Проекта Э
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, который прокидывает параметры подключения (в первую очередь - случайный порт) в контекст:

RabbitMQ 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-ом и, соответственно, один общий на все тесты.

Сама Кафка запускается в контейнере практически так же, как и Кролик:

Запуск Kafka в тестах в Проекте Э
val kafkaContainer by lazy {
    KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:6.2.1"))
        .apply {
            withReuse(true)
            start()
        }
}

Далее в дело также вступает Context Initializer:

Kafka Spring 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")))
        }
    }

}

Здесь, помимо проброса параметров подключения к Кафке, перед каждым запуском набора тестов удаляется единственный топик, в который отправляются сообщения для сброса состояния.

А вот дальше начинаются отличия от сетапа тестов с Кроликом.

Во-первых, в тестах определяется общий бин слушателя:

TestKafkaListener
@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()
    }

}

Во-вторых, определяется конфиг, который добавляет в контекст Кафковую инфраструктуру и этот слушатель:

KafkaTestConfig
@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 в базовом тесте, а в конкретных тестах дополнительно прописывается конфиг Кафки и инжектится тестовый лисенер:

SendEventsTest
@ContextConfiguration(
    classes = [KafkaTestConfig::class],
)
class SendEventsTest(
    @Autowired private val testKafkaListener: TestKafkaListener
) : ExternalSystemAppBaseTest() {
}

Запуск Mqtt-брокера для тестов

Запуск MQTT-брокера практически не отличается от запуска Кролика.

Лениво запускаем контейнер:

Запуск контейнера MQTT-брокера в тестах Проекта Э
val mqttServerContainer by lazy {
    HiveMQContainer(DockerImageName.parse("hivemq/hivemq-ce:latest").withTag("2021.3"))
        .withReuse(true)
        .apply { start() }
}

Прокидываем параметры подключения в контекст с помощью Context Initializer:

MQTT-брокер Spring 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"
        )
    }
}

И прописываем его в базовом классе тестов:

  1. 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() }
}
ContextInitializer
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()