⏱️ Розділ 19 · Питання #23

Як тестувати код з CompletableFuture

Тестування асинхронного коду відрізняється від синхронного тим, що тестовий фреймворк (JUnit) працює в головному потоці тесту. Якщо тестовий потік завершиться до того, як фонови...


🟢 Junior Level

Тестування асинхронного коду відрізняється від синхронного тим, що тестовий фреймворк (JUnit) працює в головному потоці тесту. Якщо тестовий потік завершиться до того, як фоновий CompletableFuture закінчить роботу, тест завершиться хибним успіхом, а перевірки (assert) не виконаються.

Базовий підхід: Очікування результату

У юніт-тестах для очікування завершення асинхронного завдання використовують метод .join() (не вимагає обробки перевірюваних винятків) або .get(timeout, unit):

@Test
void shouldCalculateDiscountSuccessfully() {
    CompletableFuture<Double> future = pricingService.calculateDiscountAsync(orderId);

    // Блокуємо тестовий потік до отримання результату:
    Double discount = future.join();

    // Перевіряємо результат:
    assertEquals(0.15, discount, 0.001);
}

Мокування залежностей за допомогою completedFuture

Якщо тестований сервіс викликає зовнішні асинхронні залежності (клієнти, репозиторії), у Mockito їх мокують через CompletableFuture.completedFuture():

@Test
void shouldLoadUserProfile() {
    User mockUser = new User("Alice", "alice@example.com");
    // Мокуємо асинхронний метод репозиторію:
    when(userRepository.findByIdAsync(1L))
        .thenReturn(CompletableFuture.completedFuture(mockUser));

    UserProfile profile = userFacade.getProfileAsync(1L).join();

    assertNotNull(profile);
    assertEquals("Alice", profile.getName());
}

🟡 Middle Level

1. Тестування винятків і перевірка CompletionException

Оскільки join() загортає будь-які помилки в CompletionException, перевіряти винятки необхідно з урахуванням вихідної причини (cause):

@Test
void shouldHandleErrorWhenUserNotFound() {
    // Мокуємо збій:
    when(userRepository.findByIdAsync(99L))
        .thenReturn(CompletableFuture.failedFuture(new UserNotFoundException("User 99 not found")));

    // З використанням AssertJ:
    assertThatThrownBy(() -> userFacade.getProfileAsync(99L).join())
        .isInstanceOf(CompletionException.class)
        .hasCauseInstanceOf(UserNotFoundException.class)
        .hasMessageContaining("User 99 not found");
}

2. Позбавлення від флакувальних тестів: заміна Executor на Runnable::run

Найелегантніший патерн тестування асинхронного коду — впровадження екзекутора через конструктор сервісу (Dependency Injection) і передача синхронного екзекутора в тестах:

@Test
void testAsyncPipelineSynchronously() {
    // У тесті передаємо синхронний екзекутор прямого виконання:
    Executor directExecutor = Runnable::run; // Виконує завдання прямо в поточному тестовому потоці!

    OrderService service = new OrderService(orderRepo, directExecutor);

    CompletableFuture<Receipt> future = service.processOrderAsync(order);

    // Ф'ючер завершено ОДРАЗУ, оскільки directExecutor відпрацював синхронно:
    assertTrue(future.isDone());
    assertEquals("PAID", future.join().getStatus());
}

Перевага: Тест стає на 100% детермінованим, виконується за мікросекунди і ніколи не флакує через затримки планувальника потоків у CI/CD.


🔴 Senior Level

Використання Awaitility для інтеграційних та подієвих тестів

Коли метод повертає CompletableFuture<Void> або тригерить асинхронний побічний ефект у розподіленій системі, блокуючий Thread.sleep() вважається грубим антипатерном. Для цього використовують бібліотеку Awaitility:

@Test
void shouldEventuallyUpdateOrderStatus() {
    orderService.submitOrderAsync(new Order(42L));

    // Опитуємо стан з експоненційною затримкою до 5 секунд:
    await()
        .atMost(5, TimeUnit.SECONDS)
        .pollInterval(100, TimeUnit.MILLISECONDS)
        .untilAsserted(() -> {
            OrderStatus status = database.findStatus(42L);
            assertThat(status).isEqualTo(OrderStatus.COMPLETED);
        });
}

Тестування таймаутів (orTimeout)

Щоб перевірити, що метод коректно переривається за таймаутом при зависанні стороннього шлюзу:

@Test
void shouldTimeoutWhenGatewayHangs() {
    // Завислий ф'ючер, який ніколи не завершується:
    CompletableFuture<Response> hangingFuture = new CompletableFuture<>();
    when(externalGateway.callAsync()).thenReturn(hangingFuture);

    // Тестуємо сервіс із коротким таймаутом у 100 мс:
    CompletableFuture<Response> result = clientService.executeWithTimeout(Duration.ofMillis(100));

    assertThatThrownBy(result::join)
        .isInstanceOf(CompletionException.class)
        .hasCauseInstanceOf(TimeoutException.class);
}

4 Tricky Questions

1. Чому передача Runnable::run (Direct Executor) в якості Executor у юніт-тестах вважається найкращою практикою?

Відповідь: Передача Runnable::run змушує методи supplyAsync(supplier, exec) і thenApplyAsync(fn, exec) виконуватися суворо синхронно в тому самому потоці тесту, не перемикаючись на пули потоків ОС. Це дає три переваги:

  1. Абсолютна детермінованість: повністю виключаються стани гонитви (Race Conditions) та флакувальні тести (Flaky Tests).
  2. Миттєва швидкість: тест виконується за пару мікросекунд без очікування планувальника потоків.
  3. Збереження контексту: ThreadLocal, моки Mockito і транзакції Spring залишаються в контексті тестового потоку.

2. Якщо тестований асинхронний метод викидає MyCustomException, чому assertThrows(MyCustomException.class, () -> service.call().join()) провалить тест?

Відповідь: Тому що метод .join() оголошує і викидає java.util.concurrent.CompletionException, а не MyCustomException напряму. Перевірюваний виняток упаковується всередину поля cause. Коректний тест повинен або очікувати CompletionException і потім перевіряти e.getCause() instanceof MyCustomException, або використовувати AssertJ: assertThatThrownBy(...).hasCauseInstanceOf(MyCustomException.class).

3. Чому використання Thread.sleep(1000) для очікування завершення CompletableFuture в тестах категорично заборонено на код-рев’ю?

Відповідь:

  1. Хибні падіння в CI/CD: на перевантажених агентах збірки (Jenkins / GitHub Actions) часу в 1 секунду може не вистачити через дефіцит CPU, і тест впаде хибним збоєм.
  2. Уповільнення білда: якщо в проєкті 500 тестів і кожен чекає по 1 секунді, час збірки збільшиться на 8+ хвилин марно (навіть якщо реальна операція займає 5 мс). Замість sleep потрібно використовувати блокуючий .get(timeout) або бібліотеку Awaitility.

4. Як протестувати гілку whenComplete або exceptionally, якщо залежний сервіс повертає помилку?

Відповідь: Замість мокування реального падіння потоку використовують фабричний метод CompletableFuture.failedFuture(new MyException()) (Java 9+) або new CompletableFuture<>() з completeExceptionally(). Це дозволяє подати готовий ф’ючер з помилкою миттєво і синхронно перевірити, як тестований метод обробляє fallback-логіку.


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

Головне за 60 секунд

  • У юніт-тестах завжди дочікуйтеся результату через future.join() або future.get(5, SECONDS) щоб уникнути раннього виходу з тесту.
  • Завжди задавайте таймаут очікування (JUnit @Timeout або get(timeout)), щоб завислий тест не блокував увесь CI-білд.
  • Мокування асинхронних відповідей: CompletableFuture.completedFuture(data) (успіх) та CompletableFuture.failedFuture(ex) (помилка).
  • Найкраща практика для ізоляції потоків у тестах: інжектувати Executor і в тестах передавати Runnable::run (синхронне виконання на місці).
  • Для очікування асинхронних сайд-ефектів використовуйте Awaitility.await(), а не Thread.sleep().

Типові помилки на інтерв’ю

  • Забувати викликати .join() у тесті, через що асерти взагалі не виконуються.
  • Очікувати вихідний бізнес-виняток напряму з join() замість CompletionException.
  • Використовувати Thread.sleep() для синхронізації тестів.

Пов’язані питання