How to test code with CompletableFuture
Testing CompletableFutures comes down to waiting for completion and checking the result:
🟢 Junior Level
Testing CompletableFutures comes down to waiting for completion and checking the result:
@Test
void testCompletableFuture() throws Exception {
CompletableFuture<String> cf = CompletableFuture.supplyAsync(() -> "Hello");
// Wait for completion
String result = cf.get(); // or cf.join()
// Assert
assertEquals("Hello", result);
}
Use join() in tests — it doesn’t throw checked exceptions:
String result = cf.join(); // simpler than get()
🟡 Middle Level
Testing async code
1. Direct testing:
@Test
void testAsyncMethod() {
CompletableFuture<String> cf = service.getDataAsync();
// Wait with timeout
String result = cf.get(5, TimeUnit.SECONDS);
assertEquals("expected", result);
}
2. Testing with mocks:
@Test
void testWithMock() {
// Mock the async dependency
when(repository.findByIdAsync(1L))
.thenReturn(CompletableFuture.completedFuture(
new User(1L, "Test")
));
CompletableFuture<User> cf = service.getUserAsync(1L);
User user = cf.join();
assertEquals("Test", user.name());
}
3. Testing errors:
@Test
void testError() {
when(repository.findByIdAsync(1L))
.thenReturn(CompletableFuture.failedFuture(
new RuntimeException("Not found")
));
CompletableFuture<User> cf = service.getUserAsync(1L);
assertThrows(ExecutionException.class, () -> cf.get());
}
Typical mistakes
- Not waiting for completion:
```java
// ❌ Test finishes before CF executes
CompletableFuture
cf = service.getDataAsync(); // assertEquals("expected", ???);
// ✅ Wait String result = cf.join(); assertEquals(“expected”, result);
---
## 🔴 Senior Level
### Testing patterns
**1. Awaitility for async tests:**
```java
@Test
void testAsyncChain() {
CompletableFuture<String> cf = service.processAsync();
// Awaitility — a library for waiting for async operations to complete.
// Do not confuse with StepVerifier from Project Reactor (reactor-test).
await().atMost(5, SECONDS)
.untilAsserted(() -> {
assertTrue(cf.isDone());
assertEquals("expected", cf.join());
});
}
2. Custom Executor for tests:
@Test
void testWithCustomExecutor() {
// Single-threaded executor for determinism
ExecutorService executor = Executors.newSingleThreadExecutor();
CompletableFuture<String> cf = service.processAsync(executor);
assertEquals("expected", cf.join());
}
3. Testing race conditions:
@Test
void testConcurrentAccess() throws Exception {
CompletableFuture<Void>[] futures = new CompletableFuture[100];
for (int i = 0; i < 100; i++) {
futures[i] = service.incrementAsync();
}
CompletableFuture.allOf(futures).join();
assertEquals(100, service.getCount());
}
Best Practices
// ✅ join() in tests (not get)
String result = cf.join();
// ✅ Timeout
String result = cf.get(5, TimeUnit.SECONDS);
// ✅ completedFuture for mocks
when(mock.asyncMethod()).thenReturn(CompletableFuture.completedFuture(result));
// ✅ failedFuture for error testing
when(mock.asyncMethod()).thenReturn(CompletableFuture.failedFuture(error));
// ❌ Blocking without timeout
// ❌ Forgetting to check cf.isDone()
Testing with Virtual Threads (Java 21+)
With Virtual Threads, many async operations can be tested like synchronous code:
@Test
void testWithVirtualThreads() {
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
CompletableFuture<String> cf = CompletableFuture.supplyAsync(
() -> service.slowBlockingCall(), executor);
// Virtual Threads allow standard synchronous
// assert checks without waiting for CompletableFuture completion.
String result = cf.join();
assertEquals("expected", result);
}
}
🎯 Interview Cheat Sheet
Must know:
- join() in tests is simpler than get() — no try-catch for checked exceptions
- get(5, TimeUnit.SECONDS) — test with timeout, prevents hanging
- completedFuture for mocks, failedFuture for testing errors
- Awaitility for async tests with completion waiting
- Custom Executor (single-threaded) for test determinism
Common follow-up questions:
- join() or get() in tests? — join() is simpler (no try-catch), get(timeout) is safer (timeout)
- How to mock an async method? — return CompletableFuture.completedFuture(testData)
- How to test an async error? — return CompletableFuture.failedFuture(ex) + assertThrows(ExecutionException.class)
- Testing race conditions? — CompletableFuture.allOf(N futures).join() + check final state
Red flags (DO NOT say):
- “Test passes without cf.join()” — test finishes before CF executes, false positive
- “get() without timeout in tests is OK” — test hangs on error
- “Awaitility and StepVerifier are the same” — Awaitility is for general async, StepVerifier is for Reactor
Related topics:
- [[26. What does join() method do and how does it differ from get()]]
- [[3. How to create a CompletableFuture that is already completed with a result]]
- [[6. How to handle exceptions in CompletableFuture chain]]
- [[25. When is it better to use CompletableFuture vs reactive programming]]