Testing Asynchronous Java Code With CompletableFuture and Awaitility
Asynchronous Java code can finish after the test method has already moved on. A service may return a CompletableFuture, publish an event, update a cache, or trigger a background task while the test is still checking the old state. This timing gap creates failures that are difficult to reproduce and easy to misdiagnose.
CompletableFuture gives production code a clear way to compose asynchronous operations, while Awaitility provides a readable mechanism for waiting until an expected condition becomes true. Used together, they support tests that verify behaviour without relying on arbitrary sleeps or assumptions about thread scheduling.
Why Asynchronous Tests Need A Different Approach
A synchronous test usually follows a predictable sequence: call a method, receive a result, and make assertions. With asynchronous code, the call may return immediately, while the useful work continues on a ForkJoinPool, an executor, or a messaging consumer thread. An assertion made too early can fail even when the application is correct.
The opposite problem is also common. A test may use Thread.sleep(2000) to give the background work time to finish. This makes the suite slower and still unreliable because two seconds may be excessive on a developer laptop but insufficient on a busy CI agent. A Melbourne build runner, for example, can behave differently from a local machine when several pipelines compete for CPU resources.
A good asynchronous test waits for a meaningful state transition rather than for a fixed duration. It should also distinguish between a condition that has not happened yet and a condition that can never happen because the operation failed.
Understanding CompletableFuture Behaviour
CompletableFuture represents a value that may become available later. Methods such as thenApply, thenCompose, thenAccept, and exceptionally allow developers to build a pipeline of dependent actions. Tests need to know which stage they are observing, because checking the original future may say little about a later database write or event publication.
For example, a service might return a future for an accepted order:
CompletableFuture<Order> future = orderService.submit(order);
Order result = future.join();
assertThat(result.status()).isEqualTo(Status.ACCEPTED);
Calling join() is appropriate when the future itself is the contract under test. It blocks until completion and wraps failures in CompletionException. get() has similar behaviour but forces checked exception handling. Neither method proves that a separate asynchronous side effect, such as sending an event to a queue, has completed.
A useful pattern is to keep the returned future for direct assertions while using Awaitility for eventual effects:
CompletableFuture<Order> result = orderService.submit(order);
assertThat(result.join().status()).isEqualTo(Status.ACCEPTED);
await()
.atMost(Duration.ofSeconds(5))
.untilAsserted(() ->
assertThat(eventStore.findByOrderId(order.id())).isPresent()
);
This separates two claims: the operation produced the expected result, and the event became observable within an acceptable period.
Using Awaitility For Eventual Conditions
Awaitility repeatedly evaluates a condition until it passes, times out, or encounters an unrecoverable failure. Its fluent syntax makes the timing policy visible in the test:
await()
.atMost(Duration.ofSeconds(10))
.pollInterval(Duration.ofMillis(100))
.until(() -> cache.containsKey(orderId));
For assertions involving AssertJ or Hamcrest, untilAsserted is often clearer:
await()
.atMost(Duration.ofSeconds(10))
.untilAsserted(() -> {
var record = repository.findById(orderId);
assertThat(record).isPresent();
assertThat(record.get().state()).isEqualTo("PROCESSED");
});
The timeout should reflect the application’s expected behaviour, not the test author’s hope. A local in-memory operation may deserve a short limit, while an integration test involving Kafka, a remote HTTP service, or a database transaction may need longer. The timeout should still be bounded tightly enough to expose regressions instead of hiding them.
Awaitility can also wait for a future directly:
await()
.atMost(Duration.ofSeconds(3))
.until(future::isDone);
assertThat(future).isCompletedWithValue("ready");
In many cases, waiting for the observable outcome is better than waiting for isDone(). A completed future does not necessarily mean that every downstream action has finished.
Making Timing Failures Useful
A timeout should produce diagnostic information. If an asynchronous test fails with only “condition was not fulfilled”, developers may spend time investigating the wrong component. Include identifiers, status values, and relevant state in assertions or failure messages where possible.
await()
.atMost(Duration.ofSeconds(5))
.untilAsserted(() -> {
var job = jobRepository.find(jobId)
.orElseThrow(() -> new AssertionError("Job has not been created"));
assertThat(job.status())
.as("status for job %s", jobId)
.isEqualTo(JobStatus.COMPLETE);
});
Avoid catching every exception inside the polling condition. A temporary “not found” result may be expected, but an authentication failure, invalid payload, or database schema error should usually fail immediately. Broad exception handling can convert a genuine defect into a slow timeout.
The test should also clean up executors, queues, temporary records, and subscriptions. Reusing a shared executor across tests can create interference, particularly when a test leaves unfinished futures behind. This matters in continuous delivery pipelines where tests may run in parallel on Linux agents in Sydney, Perth, or outside Australia with different scheduling characteristics.
Practical Checks For Reliable Test Design
Asynchronous tests become easier to maintain when the production code exposes clear boundaries. Prefer returning a future for work that has a meaningful result, inject executors rather than creating them inside methods, and provide test-friendly interfaces for external systems. These choices make it possible to control execution in unit tests and observe real completion in integration tests.
A unit test can use a direct executor or a deterministic test executor when the goal is to verify transformation logic. An integration test should use the real concurrency model when the goal is to verify thread interaction, transaction boundaries, or message delivery. Mixing those purposes in one test often produces either excessive mocking or unnecessarily slow execution.
Useful habits include:
- Give each asynchronous test a bounded timeout.
- Wait for business state, not an arbitrary sleep.
- Use unique IDs and isolated test data.
- Capture failures from futures explicitly.
When diagnosing a flaky build, check the following:
- Whether the future is actually awaited.
- Whether the assertion observes the final side effect.
- Whether polling reads stale or cached data.
- Whether background exceptions are being discarded.
A test that passes only after increasing a timeout is usually signalling an observability or synchronisation problem. Increasing the limit can be reasonable for a remote dependency, but it should come with an explanation and evidence from production timing.
Testing REST, Messaging, And Australian Workflows
A common backend scenario is an HTTP endpoint that accepts a request and starts asynchronous processing. The REST test should first verify the immediate response, such as 202 Accepted and a job identifier. A second phase can poll a status endpoint or inspect a controlled test repository until the job reaches its expected state.
For an Australian payments or logistics system, the workflow might include a business-day rule, a postcode-based delivery calculation, or an event emitted after a bank transfer is accepted. The test data should make these rules explicit rather than depending on the current date in AEST or AEDT. A Sydney order and a Perth order can cross time-zone boundaries, so timestamps should be stored and asserted with an explicit offset or in UTC.
Messaging tests require the same distinction between producer completion and consumer processing. A producer future may complete once a message is handed to the broker, while the consumer has not yet updated the read model. Awaitility should poll the read model, database, or test event listener that represents the business outcome. When using a local Kafka or RabbitMQ instance, ensure that messages from previous tests cannot satisfy the current assertion.
The local market can also affect integration timing. A service calling an Australian address-validation provider, a bank sandbox, or an NBN-connected staging environment may have variable response times. Keep such tests separate from fast unit tests, use controlled doubles where possible, and reserve external-provider checks for a small, clearly identified suite.
The following approaches suit different levels of asynchronous verification:
| Approach | Best Use | Main Strength | Main Risk |
|---|---|---|---|
future.join() or get() |
Verifying the future’s direct result | Simple and explicit | Does not prove downstream side effects |
Awaitility with untilAsserted |
Checking eventual database, cache, or message state | Reads like business behaviour | Can hide poor diagnostics if assertions are weak |
Thread.sleep() |
Rarely justified timing experiments | Very simple to write | Slow and inherently flaky |
| Deterministic executor | Unit-testing task composition | Predictable and fast | May miss real thread and scheduling defects |
| Real executor with Awaitility | Integration and concurrency testing | Exercises production-like behaviour | Requires isolation and careful cleanup |
A practical test often combines these techniques: use join() for the returned CompletableFuture, Awaitility for an eventual side effect, and a deterministic executor for smaller unit tests. Keep the timeout visible, make failures descriptive, and verify the state that users or downstream services actually depend on.