Як тестувати код з 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()для синхронізації тестів.