🏗️ Розділ 17 · Питання #25

Як тестувати мікросервіси

Тестування мікросервісів значно складніше за моноліт: застосунок розділений на десятки автономних процесів, що комунікують ненадійною мережею із зовнішніми базами даних та броке...


🟢 Junior Level

Тестування мікросервісів значно складніше за моноліт: застосунок розділений на десятки автономних процесів, що комунікують ненадійною мережею із зовнішніми базами даних та брокерами повідомлень.

Піраміда тестування мікросервісів:
                 / \
                /E2E\             ── 10% (Повільні наскрізні тести на Staging)
               /-----\
              /Contract\          ── 20% (Pact / Spring Cloud Contract)
             /-----------\
            / Integration \       ── 30% (Testcontainers: БД, Kafka, WireMock)
           /---------------\
          /      Unit       \     ── 40% (Швидкі ізольовані тести бізнес-логіки)
         /-------------------\

Основні рівні тестування:

  1. Unit-тести (Модульні): Перевіряють ізольовані класи й методи без завантаження повного Spring-контексту та мережевих адаптерів. Виконуються за мілісекунди.
  2. Інтеграційні тести (Integration): Перевіряють взаємодію сервісу з його інфраструктурними адаптерами (базою даних, Kafka, Redis) в ізольованому Docker-середовищі (Testcontainers).
  3. Контрактні тести (Contract Tests): Перевіряють сумісність протоколу між сервісом-споживачем та сервісом-постачальником без розгортання повної системи.
  4. Наскрізні тести (End-to-End / E2E): Перевіряють повний користувацький сценарій на інтеграційному стенді.

🟡 Middle Level

1. Інтеграційне тестування з Testcontainers та WireMock

Замість нестабільних In-Memory емуляторів (як-от вбудована H2) сучасним галузевим стандартом є запуск реальних продакшен-версій залежностей у Docker-контейнерах через Testcontainers:

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
class OrderServiceIntegrationTest {

    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");

    @Container
    static KafkaContainer kafka = new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.6.0"));

    @DynamicPropertySource
    static void dynamicProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", postgres::getJdbcUrl);
        registry.add("spring.datasource.username", postgres::getUsername);
        registry.add("spring.datasource.password", postgres::getPassword);
        registry.add("spring.kafka.bootstrap-servers", kafka::getBootstrapServers);
    }

    @Autowired
    private OrderService orderService;

    @Test
    void shouldCreateOrderAndPublishKafkaEvent() {
        OrderRequest request = new OrderRequest("user-123", BigDecimal.valueOf(1500));
        
        OrderResponse response = orderService.createOrder(request);

        assertNotNull(response.orderId());
        assertEquals(OrderStatus.CREATED, response.status());
    }
}

2. Мокування зовнішніх HTTP-викликів через WireMock

Якщо сервіс викликає зовнішній банківський шлюз, реальні виклики в банк у тестах неприпустимі. Застосовується WireMock:

@AutoConfigureWireMock(port = 0) // Випадковий вільний порт
class PaymentClientTest {

    @Test
    void shouldHandleBankTimeoutGracefully() {
        // Задаємо поведінку заглушки: відповісти із затримкою 3 секунди
        stubFor(post(urlEqualTo("/api/v1/charge"))
            .willReturn(aResponse()
                .withStatus(200)
                .withFixedDelay(3000)));

        assertThrows(CallNotPermittedException.class, () -> {
            paymentGatewayClient.charge(new ChargeDto(100));
        });
    }
}

🔴 Senior Level

1. Consumer-Driven Contract Testing (Pact / Spring Cloud Contract)

Типова проблема мікросервісів: розробники Payment Service перейменували поле userId на user_id, і під час релізу впали 5 клієнтських сервісів. E2E-тести виявляють це занадто пізно. Рішення — Контрактне тестування під проводом споживача (CDC):

1. Сервіс-споживач (Order Service) ──► Пише тест-очікування (Pact)
                                                 │
                                                 ▼ (Генерація pact.json)
2. Pact Broker (Реєстр контрактів)   ◄── Публікація контракту в CI/CD
        │
        ▼ (Завантаження контракту в CI постачальника)
3. Сервіс-постачальник (Payment Service) ──► Проганяє автотести на відповідність контракту
                                            (Якщо контракт зламано -> Build Failed ❌)

Перевага: Команда Payment Service у своєму pull request миттєво отримує звіт: «Ваш коміт ламає контракт сервісу Orders на рядку 42». Тести виконуються за секунди в ізоляції без запуску чужих мікросервісів.

2. Боротьба з нестійкими тестами (Flaky Tests) в асинхронних системах

Під час тестування черг Kafka/RabbitMQ прямий виклик Thread.sleep(2000) призводить до миготливих тестів (Flaky Tests): на швидкому комп’ютері розробника тест проходить, а на завантаженому CI в хмарі падає за таймаутом.

Рішення: Awaitility (періодичний полінг стану з предикатом):

@Test
void shouldProcessKafkaMessageAsynchronously() {
    kafkaTemplate.send("order-topic", new OrderCreatedEvent(42L));

    // Awaitility періодично опитує БД з експоненційною паузою
    await().atMost(5, TimeUnit.SECONDS)
           .pollInterval(100, TimeUnit.MILLISECONDS)
           .untilAsserted(() -> {
               Optional<Order> order = orderRepository.findById(42L);
               assertTrue(order.isPresent());
               assertEquals(OrderStatus.PROCESSED, order.get().getStatus());
           });
}

3. Антипатерн: E2E Ice Cream Cone («Перевернута піраміда»)

Коли команда не використовує контрактні тести та Testcontainers, вона скочується в антипатерн «Ріжок морозива E2E»:

  • Створюється 500 важких Selenium / Playwright / E2E тестів, які вимагають одночасного розгортання 40 мікросервісів у спільному тестовому контурі (Staging).
  • Наслідки:
    1. Тести тривають по 4 години.
    2. Якщо в тестовому середовищі моргнула мережа або впав сервіс авторизації, червоніють усі 500 тестів.
    3. Розробники припиняють довіряти тестам і вливають зміни з прапорцем skipTests.
  • Senior-рішення: Скорочення E2E до 5–10 критичних Smoke-сценаріїв («Користувач успішно пройшов флоу оплати»). 90% перевірок переносяться на рівень Unit, Testcontainers та Pact.

4 Tricky Questions

1. Чому використання бази даних H2 в пам’яті для інтеграційних тестів Spring Data JPA вважається небезпечною практикою?

Відповідь: H2 емулює базовий SQL-синтаксис, проте істотно відрізняється від реальних рушіїв PostgreSQL або MySQL:

  1. Діалекти та типи даних: H2 не підтримує нативні типи PostgreSQL: JSONB, діапазони, мережеві типи INET, масиви UUID[].
  2. Специфічний SQL: Запити з операторами PostgreSQL (->>, ILIKE, віконні функції, ON CONFLICT DO UPDATE) падають в H2 із синтаксичною помилкою.
  3. Блокування та транзакції: H2 по-іншому обробляє рівні ізоляції (REPEATABLE READ), не відтворює поведінку дедлоків та конструкцій SELECT ... FOR UPDATE SKIP LOCKED. Стандарт: Використання Testcontainers з офіційним образом цільової СУБД (PostgreSQL 16) гарантує 100% ідентичність тестів робочому середовищу.

2. Як запустити 50 інтеграційних тестів із Testcontainers за 30 секунд замість 5 хвилин?

Відповідь: За замовчуванням Testcontainers стартує та зупиняє Docker-контейнери для кожного тестового класу наново, що забирає хвилини. Оптимізація: Singleton Containers Pattern:

  1. Створюється спільний базовий абстрактний клас AbstractIntegrationTest, де контейнери оголошуються як статичні поля без анотації @Container і стартують вручну в статичному блоці ініціалізації:
    static {
        postgres.start();
        kafka.start();
    }
    
  2. Усі інтеграційні тести успадковуються від цього класу. Контейнери запускаються рівно один раз на весь запуск тестового набору і повторно використовуються всіма тестами, скорочуючи тривалість білду на порядок.
  3. Очищення даних між тестами виконується через @Sql або бібліотеку DatabaseRider.

3. Як перевірити зворотну сумісність схем даних Kafka (Schema Evolution) в автотестах?

Відповідь: Використовується Confluent Schema Registry Testcontainers: У CI/CD пайплайн інтегрується плагін Maven/Gradle schema-registry:test-compatibility. Він порівнює новий файл схеми Avro/Protobuf із попередньою версією з репозиторію Git за правилами сумісності (BACKWARD, FULL). Тест падає, якщо розробник видалив обов’язкове поле без значення за замовчуванням або невалідно змінив тип даних.

4. У чому різниця між тестуванням із замоканим Clock та використанням Thread.sleep() для перевірки протермінування TTL?

Відповідь: Використання фізичного очікування Thread.sleep(5000) сповільнює збірку та робить тести нестабільними під навантаженням. Правильний підхід: Впровадження java.time.Clock як Spring-біна: У бізнес-коді виклик Instant.now(clock) спирається на інжектований годинник. У тестах застосовується MutableClock або Clock.offset(), що дозволяє миттєво перевести внутрішній час застосунку на годину вперед в один рядок коду:

TestClock.forward(Duration.ofHours(1));
assertTrue(sessionService.isExpired(sessionId));

🎯 Шпаргалка для інтерв’ю

Головне:

  • Піраміда тестування: Unit (швидко, багато) $\to$ Integration (Testcontainers) $\to$ Contract (Pact) $\to$ E2E (мінімум).
  • Testcontainers — промисловий стандарт: запуск реальних PostgreSQL, Kafka, Redis у Docker-контейнерах.
  • Застосовуйте Singleton Containers Pattern для повторного використання одного контейнера всіма тестовими класами.
  • Consumer-Driven Contract Testing (Pact / Spring Cloud Contract): Споживач формує контракт $\to$ постачальник валідує його у своєму CI. Унеможливлює поломки API при незалежному деплої.
  • WireMock — для ізоляції зовнішніх HTTP-сервісів та перевірки таймаутів/Circuit Breaker.
  • Awaitility — для надійного тестування асинхронних черг Kafka без Thread.sleep().

Червоні прапорці (Чого говорити НЕ МОЖНА):

  • ❌ «Для інтеграційних тестів із БД ми завжди запускаємо H2 In-Memory базу» (H2 не підтримує фічі PostgreSQL і маскує помилки SQL).
  • ❌ «Ми тестуємо інтеграцію мікросервісів, піднімаючи 50 сервісів одночасно на стенді й проганяючи E2E тести» (Крихкий «ріжок морозива»; має замінюватися контрактними тестами).
  • ❌ «Для перевірки асинхронної обробки ми ставимо Thread.sleep(5000)» (Призводить до Flaky-тестів та повільних збірок).

Пов’язані теми: