Как тестировать код с 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) выполняться строго синхронно в том же самом потоке теста, не переключаясь на пулы потоков ОС.
Это даёт три преимущества:
- Абсолютная детерминированность: полностью исключаются гонки потоков (Race Conditions) и флакующие тесты (Flaky Tests).
- Мгновенная скорость: тест выполняется за пару микросекунд без ожидания планировщика потоков.
- Сохранение контекста:
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 в тестах категорически запрещено на код-ревью?
Ответ:
- Ложные падения в CI/CD: на перегруженных агентах сборки (Jenkins / GitHub Actions) времени в 1 секунду может не хватить из-за дефицита CPU, и тест упадет ложным сбоем.
- Замедление билда: если в проекте 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()для синхронизации тестов.