How to test code with CompletableFuture
Testing asynchronous code differs fundamentally from testing synchronous code because test frameworks (JUnit, TestNG) execute on the main test runner thread. If the test thread...
🟢 Junior Level
Testing asynchronous code differs fundamentally from testing synchronous code because test frameworks (JUnit, TestNG) execute on the main test runner thread. If the test thread terminates before background CompletableFuture computations complete, the test exits prematurely with a false positive, leaving assertions unexecuted.
Basic Synchronization: Awaiting the Result
In unit tests, use .join() (which avoids boilerplate checked exceptions) or bounded .get(timeout, unit) to hold the test runner thread until completion:
@Test
void shouldCalculateDiscountSuccessfully() {
CompletableFuture<Double> future = pricingService.calculateDiscountAsync(orderId);
// Block the test runner thread until the result is available:
Double discount = future.join();
// Verify assertions:
assertEquals(0.15, discount, 0.001);
}
Mocking Dependencies with completedFuture
When the unit under test calls external asynchronous dependencies (clients, repositories), stub them using Mockito with CompletableFuture.completedFuture():
@Test
void shouldLoadUserProfile() {
User mockUser = new User("Alice", "alice@example.com");
// Mock the asynchronous repository call:
when(userRepository.findByIdAsync(1L))
.thenReturn(CompletableFuture.completedFuture(mockUser));
UserProfile profile = userFacade.getProfileAsync(1L).join();
assertNotNull(profile);
assertEquals("Alice", profile.getName());
}
🟡 Middle Level
1. Testing Exceptional Flows and CompletionException
Because .join() encapsulates failures inside an unchecked CompletionException, assertions must inspect the underlying cause:
@Test
void shouldHandleErrorWhenUserNotFound() {
// Stub an immediate failure:
when(userRepository.findByIdAsync(99L))
.thenReturn(CompletableFuture.failedFuture(new UserNotFoundException("User 99 not found")));
// Assert using AssertJ:
assertThatThrownBy(() -> userFacade.getProfileAsync(99L).join())
.isInstanceOf(CompletionException.class)
.hasCauseInstanceOf(UserNotFoundException.class)
.hasMessageContaining("User 99 not found");
}
2. Eliminating Flaky Tests: Direct Executor Injection (Runnable::run)
The cleanest pattern for testing asynchronous pipelines is constructor dependency injection of the Executor, supplying a direct synchronous executor in unit tests:
@Test
void testAsyncPipelineSynchronously() {
// Inject a same-thread direct executor in tests:
Executor directExecutor = Runnable::run; // Executes the task directly on the current test thread!
OrderService service = new OrderService(orderRepo, directExecutor);
CompletableFuture<Receipt> future = service.processOrderAsync(order);
// The future is already completed because directExecutor ran synchronously:
assertTrue(future.isDone());
assertEquals("PAID", future.join().getStatus());
}
Advantage: The test runs 100% deterministically in microseconds without thread scheduling jitter, eliminating flaky test failures in CI/CD environments.
🔴 Senior Level
Testing Eventual Consistency and Side Effects with Awaitility
When an asynchronous method returns CompletableFuture<Void> or triggers background side effects in distributed architectures, blocking via Thread.sleep() is an anti-pattern. Instead, leverage Awaitility:
@Test
void shouldEventuallyUpdateOrderStatus() {
orderService.submitOrderAsync(new Order(42L));
// Poll the target state with bounded polling:
await()
.atMost(5, TimeUnit.SECONDS)
.pollInterval(100, TimeUnit.MILLISECONDS)
.untilAsserted(() -> {
OrderStatus status = database.findStatus(42L);
assertThat(status).isEqualTo(OrderStatus.COMPLETED);
});
}
Testing Timeout Scenarios (orTimeout)
To verify that an operation properly aborts when an external gateway hangs:
@Test
void shouldTimeoutWhenGatewayHangs() {
// An uncompleted promise that simulates a hanging upstream dependency:
CompletableFuture<Response> hangingFuture = new CompletableFuture<>();
when(externalGateway.callAsync()).thenReturn(hangingFuture);
// Invoke client with a short 100ms deadline:
CompletableFuture<Response> result = clientService.executeWithTimeout(Duration.ofMillis(100));
assertThatThrownBy(result::join)
.isInstanceOf(CompletionException.class)
.hasCauseInstanceOf(TimeoutException.class);
}
4 Tricky Questions
1. Why is passing Runnable::run (Direct Executor) considered a best practice in unit tests?
Answer:
Passing Runnable::run causes methods like supplyAsync(supplier, exec) and thenApplyAsync(fn, exec) to execute synchronously on the test runner thread, bypassing operating system thread context switches.
This provides three major benefits:
- Total Determinism: Eliminates race conditions, timing dependencies, and flaky CI/CD test failures.
- Speed: Executes in microseconds without thread scheduling latency.
- Context Preservation: Retains
ThreadLocalstorage, security contexts, Mockito verifications, and transactional boundaries on the same test thread.
2. If the asynchronous method throws MyCustomException, why will assertThrows(MyCustomException.class, () -> service.call().join()) fail?
Answer:
Because .join() catches checked and runtime exceptions thrown inside asynchronous pipeline stages and wraps them in a java.util.concurrent.CompletionException.
MyCustomException is stored as the nested cause. The test must either assert CompletionException and inspect e.getCause() instanceof MyCustomException, or use AssertJ’s fluent assertion: assertThatThrownBy(...).hasCauseInstanceOf(MyCustomException.class).
3. Why is using Thread.sleep(1000) to synchronize asynchronous tests strongly prohibited in code reviews?
Answer:
- Flaky CI/CD Builds: On overloaded continuous integration workers with limited CPU shares, 1 second may not be enough, causing spurious, intermittent test failures.
- Build Time Inflation: In a test suite with 500 tests, sleeping 1 second each adds over 8 minutes of idle waiting to every build, even if the actual operations complete in 5 milliseconds. Always prefer explicit synchronization via
.join(), bounded.get(timeout), orAwaitility.await().
4. How do you test error recovery branches (whenComplete or exceptionally) without introducing real delays?
Answer:
Use the factory method CompletableFuture.failedFuture(new MyException()) (introduced in Java 9) or an uncompleted future completed immediately via completeExceptionally(). This supplies a pre-failed future synchronously without spinning worker threads, allowing instant validation of fallback and compensation logic.
🎯 Interview Cheat Sheet
Summary in 60 Seconds
- In unit tests, always synchronize asynchronous futures using
.join()or bounded.get(timeout, unit)to keep the test runner thread alive until assertions execute. - Always enforce timeouts (via
@Timeoutor.get(timeout)) so hung futures do not stall CI pipelines. - Mock asynchronous dependencies using
CompletableFuture.completedFuture(payload)(success) orCompletableFuture.failedFuture(ex)(failure). - Best practice for unit tests: inject
Executorand supplyRunnable::runto execute asynchronous pipelines synchronously on the test thread. - For testing background side effects, use
Awaitility.await(), neverThread.sleep().
Common Interview Traps
- Forgetting to call
.join(), causing tests to pass while assertions never actually execute. - Expecting business exceptions directly from
.join()instead of unwrappingCompletionException. - Using arbitrary
Thread.sleep()durations instead of reactive or polling synchronization.