⏱️ Раздел 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() для синхронизации тестов.

Связанные вопросы