Question 24 · Section 19

How to test code with CompletableFuture

Testing CompletableFutures comes down to waiting for completion and checking the result:

Language versions: English Russian Ukrainian

🟢 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

  1. 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]]