QA How-To
Parallelize Test Data Setup with CompletableFuture
Learn java completablefuture test data setup with runnable Java 21 and JUnit 5 code, safe fan-out, failure handling, timeouts, cleanup, and verification.
22 min read | 2,645 words
TL;DR
For java completablefuture test data setup, start independent API creation calls with supplyAsync on a bounded custom executor, combine them with allOf, and map the completed futures into a typed fixture. Add unique data, timeouts, root-cause reporting, and guaranteed cleanup before using the pattern in CI.
Key Takeaways
- Model each independent setup operation as a CompletableFuture with a typed result.
- Use an explicitly owned executor instead of silently competing for the common pool.
- Combine independent futures with allOf, then join them only after the barrier completes.
- Give every setup request a unique correlation key so parallel tasks cannot overwrite one another.
- Apply request and batch timeouts, preserve root causes, and fail setup before test assertions run.
- Track every created resource and clean it in reverse dependency order even after partial failure.
Java completablefuture test data setup lets you create independent users, products, accounts, or other fixtures at the same time instead of waiting for each API call sequentially. The safe pattern is to use typed futures, an explicitly owned executor, a fan-in barrier, bounded execution, and cleanup that survives partial failure.
This tutorial builds that pattern from an empty Maven project through a runnable JUnit 5 integration test. Read the Java concurrency for test automation complete guide for the larger decision framework around executors, futures, synchronization, and test isolation.
You will call a deterministic local HTTP fixture, not a public service. That makes every command repeatable and lets you observe parallel setup without creating unauthorized traffic.
TL;DR
Start every independent creation future before you wait for any result. Run the tasks on a bounded, owned executor, combine them with allOf, apply request and workflow deadlines, and register each successful resource immediately so finally can clean partial setup.
What You Will Build
You will build a small test harness that:
- Starts a local API that creates and deletes test resources.
- Creates three independent prerequisites concurrently and combines them into one typed
CheckoutFixture. - Uses a fixed executor to control client-side work.
- Applies per-request and whole-setup timeouts while preserving the original failure.
- Deletes successfully created resources after the test, including partial setup.
The final test proves both correctness and overlap. It checks the returned fixture, verifies that setup was actually concurrent, and confirms that cleanup leaves no resources behind.
Prerequisites
Install JDK 21 or newer and Maven 3.9 or newer. This tutorial uses stable Java APIs and does not require preview flags. Check both runtimes:
java -version
mvn -version
Maven must report that it runs on Java 21 or later. You also need a terminal and an editor. No external database, Docker container, or third-party API is required.
Use the versions in the example for a reproducible project: JUnit Jupiter 5.12.2 and Maven Surefire 3.5.3. If your organization maintains a newer compatible dependency catalog, follow that catalog.
Step 1: Create the Java 21 Test Project
Create this directory structure:
completablefuture-test-data/
├── pom.xml
└── src/test/java/example/
├── LocalTestDataApi.java
└── ParallelTestDataSetupTest.java
Add pom.xml:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>example</groupId>
<artifactId>completablefuture-test-data</artifactId>
<version>1.0-SNAPSHOT</version>
<properties>
<maven.compiler.release>21</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<junit.version>5.12.2</junit.version>
</properties>
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.5.3</version>
<configuration>
<useModulePath>false</useModulePath>
</configuration>
</plugin>
</plugins>
</build>
</project>
The compiler setting gives every machine the same Java language baseline. JUnit Jupiter supplies the test API and engine, while Surefire discovers and executes the test class.
Verify the step: run mvn test. Expect BUILD SUCCESS with zero tests. If Maven reports invalid target release: 21, fix the JDK used by Maven before continuing.
Step 2: Build a Deterministic Test Data API
Add src/test/java/example/LocalTestDataApi.java:
package example;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;
final class LocalTestDataApi implements AutoCloseable {
private final HttpServer server;
private final Map<Integer, String> resources = new ConcurrentHashMap<>();
private final AtomicInteger ids = new AtomicInteger();
private final AtomicInteger active = new AtomicInteger();
private final AtomicInteger peakActive = new AtomicInteger();
LocalTestDataApi() throws IOException {
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/resources", this::handle);
server.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
}
void start() { server.start(); }
String baseUrl() { return "http://127.0.0.1:" + server.getAddress().getPort(); }
int resourceCount() { return resources.size(); }
int peakActive() { return peakActive.get(); }
private void handle(HttpExchange exchange) throws IOException {
int now = active.incrementAndGet();
peakActive.accumulateAndGet(now, Math::max);
try {
Thread.sleep(120);
String method = exchange.getRequestMethod();
String path = exchange.getRequestURI().getPath();
if ("POST".equals(method) && "/resources".equals(path)) {
String name = new String(exchange.getRequestBody().readAllBytes(),
StandardCharsets.UTF_8);
int id = ids.incrementAndGet();
resources.put(id, name);
send(exchange, 201, id + "|" + name);
return;
}
if ("DELETE".equals(method) && path.matches("/resources/\\d+")) {
int id = Integer.parseInt(path.substring(path.lastIndexOf('/') + 1));
resources.remove(id);
send(exchange, 204, "");
return;
}
send(exchange, 404, "not found");
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
send(exchange, 503, "interrupted");
} finally {
active.decrementAndGet();
}
}
private static void send(HttpExchange exchange, int status, String body)
throws IOException {
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(status, status == 204 ? -1 : bytes.length);
if (status != 204) {
try (var output = exchange.getResponseBody()) { output.write(bytes); }
} else {
exchange.close();
}
}
@Override
public void close() { server.stop(0); }
}
The API stores resources in a ConcurrentHashMap because requests overlap. Its 120 millisecond pause represents a blocking database or service operation. The peakActive counter gives the test deterministic evidence that more than one request ran at once.
A production test should use an approved nonproduction endpoint. Never add artificial delay to a real service, and never infer production capacity from this fixture.
Verify the step: run mvn test. The fixture should compile and the build should succeed. Zero tests is still expected.
Step 3: Implement Java CompletableFuture Test Data Setup
Add src/test/java/example/ParallelTestDataSetupTest.java:
package example;
import org.junit.jupiter.api.Test;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executors;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class ParallelTestDataSetupTest {
record Resource(int id, String name) {}
record CheckoutFixture(Resource user, Resource product, Resource address) {}
private final HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(2))
.build();
@Test
void createsIndependentPrerequisitesInParallel() throws Exception {
try (var api = new LocalTestDataApi();
var executor = Executors.newFixedThreadPool(3)) {
api.start();
String runId = Long.toUnsignedString(System.nanoTime());
CompletableFuture<Resource> user = CompletableFuture.supplyAsync(
() -> create(api.baseUrl(), "user-" + runId), executor);
CompletableFuture<Resource> product = CompletableFuture.supplyAsync(
() -> create(api.baseUrl(), "product-" + runId), executor);
CompletableFuture<Resource> address = CompletableFuture.supplyAsync(
() -> create(api.baseUrl(), "address-" + runId), executor);
CompletableFuture<CheckoutFixture> fixtureFuture =
CompletableFuture.allOf(user, product, address)
.thenApply(ignored -> new CheckoutFixture(
user.join(), product.join(), address.join()));
CheckoutFixture fixture = fixtureFuture.join();
assertEquals(3, api.resourceCount());
assertTrue(fixture.user().name().startsWith("user-"));
assertTrue(fixture.product().name().startsWith("product-"));
assertTrue(fixture.address().name().startsWith("address-"));
assertTrue(api.peakActive() >= 2, "setup requests did not overlap");
}
}
private Resource create(String baseUrl, String name) {
try {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "/resources"))
.timeout(Duration.ofSeconds(2))
.POST(HttpRequest.BodyPublishers.ofString(name))
.build();
HttpResponse<String> response = client.send(
request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 201) {
throw new IllegalStateException(
"create failed for " + name + " with status "
+ response.statusCode());
}
String[] parts = response.body().split("\\|", 2);
return new Resource(Integer.parseInt(parts[0]), parts[1]);
} catch (Exception exception) {
throw new IllegalStateException("could not create " + name, exception);
}
}
}
Call supplyAsync before joining anything. Starting a future and immediately calling join() would serialize setup. allOf creates a barrier that completes after all three futures finish, and thenApply maps their typed values into one fixture.
allOf returns CompletableFuture<Void>, so it does not collect values for you. Joining each component inside thenApply is safe because the barrier has already completed. The explicit fixed pool prevents setup from silently competing with unrelated work in ForkJoinPool.commonPool().
Verify the step: run mvn test. Expect one passing test, three resources, and a peak active count of at least two.
Step 4: Add Whole-Setup Timeouts and Clear Failures
A request timeout protects one HTTP call. Add a whole-workflow deadline so setup cannot consume the entire test budget. Replace the final fixture assignment with:
CheckoutFixture fixture = fixtureFuture
.orTimeout(3, java.util.concurrent.TimeUnit.SECONDS)
.handle((value, error) -> {
if (error != null) {
Throwable cause = unwrap(error);
throw new IllegalStateException(
"parallel test data setup failed: " + cause.getMessage(), cause);
}
return value;
})
.join();
Add this helper to the class:
private static Throwable unwrap(Throwable error) {
Throwable current = error;
while ((current instanceof java.util.concurrent.CompletionException
|| current instanceof java.util.concurrent.ExecutionException)
&& current.getCause() != null) {
current = current.getCause();
}
return current;
}
orTimeout completes the stage exceptionally if the deadline expires. It does not guarantee that underlying I/O is canceled, so keep the request timeout too. The handle stage converts completion wrappers into a message that identifies setup as the failed phase while retaining the original cause and stack trace.
Avoid exceptionally(error -> null). A null fixture moves the failure into the test body and usually produces a misleading NullPointerException. Setup either returns a valid fixture or fails clearly.
Verify the step: run mvn test and expect success. Temporarily change the workflow timeout to 10 milliseconds. Expect a failure mentioning parallel test data setup and TimeoutException. Restore three seconds.
Step 5: Track Partial Success and Always Clean Up
If the product call fails after the user was created, cleanup still needs the user ID. Add a thread-safe registry near the client field:
private final java.util.Queue<Resource> created =
new java.util.concurrent.ConcurrentLinkedQueue<>();
Change the successful return in create so every resource is registered immediately:
Resource resource = new Resource(Integer.parseInt(parts[0]), parts[1]);
created.add(resource);
return resource;
Wrap setup and assertions in try, then clean every registered resource in finally before the executor and API close:
try {
CheckoutFixture fixture = fixtureFuture
.orTimeout(3, java.util.concurrent.TimeUnit.SECONDS)
.join();
assertEquals(3, api.resourceCount());
assertTrue(fixture.user().name().startsWith("user-"));
assertTrue(api.peakActive() >= 2);
} finally {
java.util.List<Resource> snapshot = new java.util.ArrayList<>(created);
java.util.Collections.reverse(snapshot);
for (Resource resource : snapshot) {
delete(api.baseUrl(), resource);
}
created.clear();
}
assertEquals(0, api.resourceCount());
Add the deletion helper:
private void delete(String baseUrl, Resource resource) {
try {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "/resources/" + resource.id()))
.timeout(Duration.ofSeconds(2))
.DELETE()
.build();
HttpResponse<Void> response = client.send(
request, HttpResponse.BodyHandlers.discarding());
if (response.statusCode() != 204) {
throw new IllegalStateException("cleanup failed for " + resource);
}
} catch (Exception exception) {
throw new IllegalStateException("could not clean " + resource, exception);
}
}
A concurrent queue accepts completions in any order. For genuinely dependent data, record dependency metadata and delete children before parents. The reverse snapshot is sufficient for this independent fixture, but completion order is not a substitute for a dependency graph.
In a shared environment, make deletion idempotent when the API supports it. Collect cleanup errors instead of stopping after the first one, then attach them as suppressed exceptions to the primary failure. Never hide a test failure merely because cleanup also failed.
Verify the step: run mvn test. Expect the assertions to pass and the final resource count to equal zero. Force one assertion to fail and confirm cleanup still executes before restoring it.
Step 6: Separate Independent and Dependent Setup
Not every prerequisite can start together. Suppose an order requires the user and product IDs. Start those two independently, then compose the dependent call with thenCombine:
CompletableFuture<Resource> user = CompletableFuture.supplyAsync(
() -> create(api.baseUrl(), "user-" + runId), executor);
CompletableFuture<Resource> product = CompletableFuture.supplyAsync(
() -> create(api.baseUrl(), "product-" + runId), executor);
CompletableFuture<Resource> order = user.thenCombineAsync(
product,
(createdUser, createdProduct) -> create(
api.baseUrl(),
"order-" + createdUser.id() + "-" + createdProduct.id()),
executor);
CompletableFuture<CheckoutFixture> fixtureFuture = order.thenApply(
createdOrder -> new CheckoutFixture(
user.join(), product.join(), createdOrder));
The graph now expresses the real dependency. User and product overlap. Order begins only when both exist. Do not use sleeps to guess when prerequisites are ready, and do not place dependent creation in a separate future that races its parents.
Use thenCompose when one completed value starts a method that already returns a CompletableFuture. Use thenCombine when two results are needed together. Use allOf for a variable-size collection or a barrier across several independent stages.
| Operation | Use it for | Result shape | Common mistake |
|---|---|---|---|
thenApply |
Synchronous value mapping | One transformed future | Returning a future and creating nesting |
thenCompose |
Starting a dependent async stage | One flattened future | Using it when no future is returned |
thenCombine |
Joining two typed results | One combined future | Starting dependent work too early |
allOf |
Waiting for many futures | CompletableFuture<Void> |
Assuming it collects typed values |
Verify the step: replace the address stage with the order stage and run mvn test. Expect three resources total. Inspect the order name and confirm it contains the IDs returned for both parents.
Step 7: Bound and Configure Parallel Test Data Creation
A fixed pool bounds client tasks, but size it deliberately. Make the worker count configurable and reject unsafe values:
int setupWorkers = Integer.getInteger("test.setup.workers", 3);
if (setupWorkers < 1 || setupWorkers > 8) {
throw new IllegalArgumentException(
"test.setup.workers must be between 1 and 8");
}
try (var executor = Executors.newFixedThreadPool(setupWorkers)) {
// Build and join the setup graph here.
}
Run with a CI-controlled value:
mvn test -Dtest.setup.workers=2
The maximum of eight is an example policy for the local fixture. In a real suite, derive the limit from the test environment, connection pool, rate limits, database capacity, and service-owner agreement. Executor size controls tasks executing on this client, but it does not coordinate other test processes. Cross-process limits require environment-level coordination.
For many blocking operations on Java 21, virtual threads can simplify thread management. The virtual threads parallel API test tutorial shows that model. Even with cheap virtual threads, protect a finite downstream system using the Java Semaphore parallel load control pattern.
Verify the step: run with worker counts one, two, and three. Every run must produce correct data. Peak concurrency should be one with one worker and at least two with two or three workers. Run with zero and confirm validation fails immediately rather than hanging.
Step 8: Make the Pattern Safe in a Real Suite
Move the setup graph behind a fixture factory that returns CompletableFuture<CheckoutFixture>. Keep the JUnit method responsible for the deadline, assertions, and cleanup boundary. This separation makes the graph reusable without hiding failure policy inside a global test hook.
Generate a unique run ID for every test invocation. Prefer a UUID or a CI run ID plus test ID when resources are visible across machines. Never share a static mutable fixture between parallel tests. If credentials require refresh, use a documented thread-safe provider instead of mutating a shared token field.
Keep setup operations idempotent where the service contract permits it. A retry can duplicate users or orders if the first response was lost after the server committed the write. Use an idempotency key, query by a unique correlation key before retrying, and retry only transient, approved failures with a strict attempt limit.
Log resource type, correlation key, duration, final status, and exception class. Do not log passwords, tokens, personal data, or full confidential bodies. Use a JUnit 5 parallel test execution guide to configure test-level concurrency separately from setup concurrency. Apply the same isolation rules from the API test automation best practices guide when the fixture factory calls shared services. When correctness changes with worker count, follow the Java test automation race condition debugging workflow.
Verify the step: execute the test repeatedly and with JUnit parallel execution if your suite enables it. Each invocation must use distinct names, return its own IDs, and delete only its own resources. Search the logs for secrets before enabling diagnostic output in CI.
Java CompletableFuture Test Data Setup: When It Fits
Use this approach when setup calls are independent or form a clear dependency graph and each spends meaningful time waiting on I/O. Keep sequential setup when operations are fast, strictly ordered, or easier to diagnose than any saved time warrants.
| Approach | Best fit | Strength | Main risk |
|---|---|---|---|
| Sequential calls | Small ordered fixtures | Simplest debugging | Unnecessary cumulative wait |
CompletableFuture graph |
Mixed independent and dependent async work | Explicit composition and fan-in | Wrapped exceptions and complex chains |
Fixed executor with Future |
Independent task batch | Direct task control | Manual result composition |
| Virtual thread per task | Many blocking independent calls | Readable blocking code | External load still needs bounds |
Do not optimize setup from intuition alone. Measure the setup phase in a controlled environment, then decide whether the added concurrency improves the suite enough to justify its lifecycle and failure-handling code. Functional correctness and clean isolation remain the acceptance criteria.
Troubleshooting
The setup still runs sequentially -> Start all independent futures before calling join, get, or a dependent stage. Check that the executor has more than one worker and that the server itself permits concurrent requests.
A failure appears only as CompletionException -> Walk through completion and execution wrappers until you reach the root cause. Preserve that cause when throwing the setup-level exception.
The test hangs after a request fails -> Put a timeout on every request and on the whole graph. Close the owned executor, preserve interruption, and capture a thread dump if the process remains alive.
Resources remain after failed setup -> Register each resource immediately after creation, not only after allOf succeeds. Run cleanup from finally and make it handle partial collections.
The API returns 429 or 503 in CI -> Lower the executor size or add an explicit semaphore based on the service policy. Remember that several CI workers can multiply total concurrency.
Parallel runs create duplicate or mixed data -> Add a unique correlation key per test, remove static mutable builders, and delete resources by returned ID. Investigate any shared reporter, authentication cache, or random-data generator for thread safety.
Common Mistakes
- Calling
join()immediately after eachsupplyAsync, which turns parallel setup back into sequential setup. - Using the common pool without understanding what other framework or application work shares it.
- Mutating an
ArrayListfrom completion stages instead of using a thread-safe registry. - Treating
allOfas a typed result collector. - Swallowing exceptions with a fallback fixture that lets the test run on invalid data.
- Adding retries to non-idempotent creation calls without correlation keys.
- Canceling the barrier and assuming every underlying HTTP request stopped.
- Cleaning only after a fully successful fan-in, which leaks partial setup.
- Making worker count unlimited because the client has available threads.
- Comparing one wall-clock run and presenting it as a reliable benchmark.
Keep the graph small and name stages after business resources. If composition becomes difficult to explain, extract methods or use straightforward blocking code with a suitable executor. Readability is part of reliability in test infrastructure.
Interview Questions and Answers
Q: Why use CompletableFuture for test data setup?
It represents independent and dependent asynchronous setup operations as a completion graph. Independent API calls can overlap, dependent calls can be composed from their prerequisites, and one final stage can produce a typed fixture. The suite still needs bounds, deadlines, failure propagation, and cleanup.
Q: What is the difference between allOf and thenCombine?
allOf creates a Void barrier over several futures and is useful for fan-in. thenCombine combines the typed results of two stages with a function. For many typed results, wait on allOf and map the already completed component futures afterward.
Q: Why use a custom executor instead of the common pool?
An owned executor makes worker capacity and lifecycle explicit. It prevents blocking test setup from unexpectedly competing with unrelated common-pool work. The test must close the executor after all work and cleanup finish.
Q: How should exceptions be handled?
Fail setup as one clearly named phase, unwrap completion wrappers for diagnostics, and keep the root cause attached. Do not replace failures with null or an empty fixture. Cleanup should run independently and cleanup failures should not erase the primary cause.
Q: Does canceling a CompletableFuture stop its HTTP call?
Not reliably. Cancellation changes the future state, but the underlying operation may continue unless its implementation supports and observes cancellation. Use transport timeouts, bound resource use, and design cleanup for late completion.
Q: How do you clean data after partial setup?
Register each resource as soon as creation succeeds in a thread-safe collection. In finally, delete all registered resources in dependency-safe order. Make cleanup idempotent where possible and report every cleanup failure.
Q: When would you prefer virtual threads?
Use virtual threads when setup is mainly blocking I/O and direct sequential code inside each task is easier to maintain. Use CompletableFuture when explicit stage composition and fan-in are central. Neither choice removes the need to cap load on external systems.
Where To Go Next
Start by extracting the completed graph into a fixture factory and applying it to one slow integration test. Measure setup separately, keep the sequential implementation available for diagnosis, and document the environment-approved worker limit.
Return to the complete Java concurrency test automation guide for the full tool selection map. Continue with parallel API tests using Java virtual threads, Semaphore-based control for parallel test load, and debugging race conditions in Java test automation.
Conclusion
Java completablefuture test data setup works best when you express real dependencies instead of simply moving every call to a background thread. Start independent stages together, compose dependent stages, fan them into a typed fixture, and enforce one setup deadline.
Make the production-ready version bounded and observable. Give every resource a unique identity, retain root causes, register partial success, and clean it in finally. Once those safeguards pass at several worker counts, use the pattern for the slow, I/O-heavy setup paths where concurrency delivers a measurable benefit.
Interview Questions and Answers
How would you design parallel test data setup with CompletableFuture?
I would identify independent resources, create typed futures for them on a bounded owned executor, and express dependencies with thenCombine or thenCompose. I would fan in to one typed fixture, apply request and workflow deadlines, and fail with the root cause. Every successful creation would be registered immediately for cleanup in finally.
Why is joining each future immediately a problem?
Joining the first future blocks the test thread before later tasks are even submitted, so nominally asynchronous calls run sequentially. I start every independent future first, build the fan-in stage, and join only at the fixture boundary.
What does CompletableFuture.allOf do with exceptions?
The combined stage completes exceptionally if a component future fails. The visible error is commonly wrapped in CompletionException when joined. I unwrap it for a useful setup message while retaining the original cause and stack trace.
How do you choose an executor for asynchronous test setup?
For a small number of blocking calls, I use an explicitly sized fixed executor based on approved downstream capacity. For many blocking tasks on Java 21, virtual threads may improve clarity, but I still bound external concurrency. I avoid silently using the common pool for integration I/O.
How do you prevent data collisions in parallel setup?
I generate a unique correlation key for every test invocation and include it in created data or idempotency keys. Mutable builders remain task-local, shared helpers must be thread-safe, and cleanup uses returned resource IDs rather than names or creation order.
How do you handle cleanup when setup partially fails?
Each successful creation is added immediately to a concurrent registry. A finally block snapshots that registry and deletes resources in dependency-safe order. Cleanup errors are collected and reported without replacing the original setup or assertion failure.
When would you not use CompletableFuture for test setup?
I would keep setup sequential when steps are strictly dependent, already fast, difficult to clean independently, or constrained to one operation by the environment. I also avoid a complex future graph when straightforward blocking code is easier to diagnose and offers no measured disadvantage.
Frequently Asked Questions
How do I parallelize test data setup with CompletableFuture?
Start each independent creation call with CompletableFuture.supplyAsync on an explicitly owned executor. Combine the stages with allOf or thenCombine, then join only after the fan-in stage completes and map the results into a typed fixture.
Should JUnit tests use ForkJoinPool.commonPool for setup?
Usually prefer an explicitly sized and owned executor for blocking integration setup. It gives the suite a visible concurrency limit and avoids competing unpredictably with other common-pool work in the test process.
Does CompletableFuture.allOf return the completed values?
No. allOf returns CompletableFuture<Void> and acts as a completion barrier. After it completes, read the typed component futures or map them into a fixture in a following stage.
How do I set a timeout on parallel Java test setup?
Set a timeout on every network request and use orTimeout on the whole completion graph. A graph timeout does not guarantee that underlying I/O stops, so both layers are necessary.
How do I clean up if only some parallel setup calls succeed?
Register each successfully created resource immediately in a thread-safe collection. Execute cleanup from finally using the returned IDs and a dependency-safe deletion order, even when the fan-in future fails.
When should test setup use thenCompose instead of thenCombine?
Use thenCompose when one completed value starts a method that already returns a CompletableFuture. Use thenCombine when a new operation needs the completed values of two independent stages.
Is CompletableFuture faster than sequential test setup?
It can reduce elapsed setup time when independent operations wait on I/O and the environment supports safe concurrency. It may add overhead or instability for fast, ordered, CPU-bound, or capacity-limited operations, so measure the real setup phase.