Testing Vert.x Async Applications with VertxExtension
Testing asynchronous code is notoriously tricky. When your application is built on Vert.x — a reactive toolkit where everything happens on an event loop — standard synchronous JUnit assertions don't work. You need to tell JUnit to wait for asynchronous operations to complete before asserting results. The Vert.x JUnit 5 extension solves this elegantly.
Why Vert.x Testing Is Different
Vert.x is event-driven. When you call server.listen(8080), control returns immediately — the server binds asynchronously. A test that asserts the server started immediately after calling listen() will always pass (or always fail) regardless of whether the server actually bound.
The VertxExtension from vertx-junit5 bridges this gap by providing:
- Injected
VertxandVertxTestContextparameters - Automatic test failure on timeout if async checkpoints aren't reached
- Checkpoint-based completion signaling
Setting Up Dependencies
<dependency>
<groupId>io.vertx</groupId>
<artifactId>vertx-junit5</artifactId>
<version>4.5.9</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.vertx</groupId>
<artifactId>vertx-web-client</artifactId>
<version>4.5.9</version>
<scope>test</scope>
</dependency>The VertxTestContext Pattern
VertxTestContext is the key to async test control:
@ExtendWith(VertxExtension.class)
class EventBusTest {
@Test
void testMessageDelivery(Vertx vertx, VertxTestContext testContext) {
// Create a checkpoint — test fails if this isn't called before timeout
Checkpoint messageReceived = testContext.checkpoint();
vertx.eventBus().consumer("test.address", message -> {
testContext.verify(() -> {
assertThat(message.body()).isEqualTo("hello");
messageReceived.flag(); // Signal checkpoint reached
});
});
vertx.eventBus().send("test.address", "hello");
}
}The testContext.verify() block catches assertion failures and routes them to JUnit. Without it, assertion exceptions thrown in async callbacks would silently swallow, causing the test to timeout rather than fail with a meaningful message.
Testing HTTP Servers
A typical Vert.x HTTP server test deploys a Verticle and uses the WebClient to make requests:
@ExtendWith(VertxExtension.class)
class ApiVerticleTest {
@BeforeEach
void setUp(Vertx vertx, VertxTestContext testContext) {
vertx.deployVerticle(new ApiVerticle(), testContext.succeeding(deployId -> {
testContext.completeNow(); // Signal setup done
}));
}
@Test
void testGetItems(Vertx vertx, VertxTestContext testContext) {
WebClient client = WebClient.create(vertx);
client.get(8080, "localhost", "/items")
.send()
.onSuccess(response -> {
testContext.verify(() -> {
assertThat(response.statusCode()).isEqualTo(200);
assertThat(response.bodyAsJsonArray()).isNotEmpty();
testContext.completeNow();
});
})
.onFailure(testContext::failNow); // Route failure to JUnit
}
@Test
void testCreateItem(Vertx vertx, VertxTestContext testContext) {
WebClient client = WebClient.create(vertx);
JsonObject body = new JsonObject()
.put("name", "Widget")
.put("price", 9.99);
client.post(8080, "localhost", "/items")
.sendJsonObject(body)
.onSuccess(response -> {
testContext.verify(() -> {
assertThat(response.statusCode()).isEqualTo(201);
JsonObject created = response.bodyAsJsonObject();
assertThat(created.getString("id")).isNotNull();
assertThat(created.getString("name")).isEqualTo("Widget");
testContext.completeNow();
});
})
.onFailure(testContext::failNow);
}
@AfterEach
void tearDown(Vertx vertx, VertxTestContext testContext) {
vertx.close(testContext.succeedingThenComplete());
}
}Using Futures (Vert.x 4+)
Vert.x 4 introduced Future<T> as the primary async abstraction, replacing the older callback style. Tests become significantly cleaner:
@ExtendWith(VertxExtension.class)
class UserServiceTest {
private UserService userService;
@BeforeEach
void setUp(Vertx vertx, VertxTestContext testContext) {
this.userService = new UserService(vertx);
testContext.completeNow();
}
@Test
void testCreateUser(Vertx vertx, VertxTestContext testContext) {
userService.create("alice@example.com", "Alice")
.onComplete(testContext.succeeding(user -> {
assertThat(user.getId()).isNotNull();
assertThat(user.getEmail()).isEqualTo("alice@example.com");
testContext.completeNow();
}));
}
@Test
void testFindUser(Vertx vertx, VertxTestContext testContext) {
userService.create("bob@example.com", "Bob")
.compose(created -> userService.findById(created.getId()))
.onComplete(testContext.succeeding(found -> {
assertThat(found.getEmail()).isEqualTo("bob@example.com");
testContext.completeNow();
}));
}
@Test
void testUserNotFound(Vertx vertx, VertxTestContext testContext) {
userService.findById("nonexistent-id")
.onComplete(testContext.failing(error -> {
assertThat(error).isInstanceOf(UserNotFoundException.class);
testContext.completeNow();
}));
}
}testContext.succeeding() creates a handler that fails the test if the Future fails, and runs your assertions if it succeeds. testContext.failing() is the inverse — tests that an error occurred.
Multiple Checkpoints for Complex Flows
When a test involves multiple async events, use multiple checkpoints:
@ExtendWith(VertxExtension.class)
class OrderProcessingTest {
@Test
void testOrderCreatesAuditLog(Vertx vertx, VertxTestContext testContext) {
Checkpoint orderCreated = testContext.checkpoint();
Checkpoint auditLogged = testContext.checkpoint();
OrderService orderService = new OrderService(vertx);
AuditService auditService = new AuditService(vertx);
// Subscribe to audit events first
vertx.eventBus().consumer("audit.order", msg -> {
testContext.verify(() -> {
assertThat(msg.body().toString()).contains("ORDER_CREATED");
auditLogged.flag();
});
});
// Create the order
orderService.create(new OrderRequest("product-1", 2))
.onComplete(testContext.succeeding(order -> {
assertThat(order.getId()).isNotNull();
orderCreated.flag();
}));
}
}The test passes only when all checkpoints are flagged before the timeout.
Setting Test Timeouts
Default timeout is 30 seconds. Override with the @Timeout annotation:
@ExtendWith(VertxExtension.class)
class SlowIntegrationTest {
@Test
@Timeout(value = 5, timeUnit = TimeUnit.SECONDS)
void testWithCustomTimeout(Vertx vertx, VertxTestContext testContext) {
// This test fails after 5 seconds if not completed
}
}For the entire test class:
@ExtendWith(VertxExtension.class)
@Timeout(value = 10, timeUnit = TimeUnit.SECONDS)
class ApiIntegrationTest {
// All tests have 10-second timeout
}Integration Testing with Testcontainers
Vert.x applications commonly use Mongo, Redis, or PostgreSQL. Testcontainers provides these in tests:
@ExtendWith(VertxExtension.class)
@Testcontainers
class MongoRepositoryTest {
@Container
static MongoDBContainer mongo = new MongoDBContainer("mongo:7");
@BeforeEach
void setUp(Vertx vertx, VertxTestContext testContext) {
JsonObject mongoConfig = new JsonObject()
.put("connection_string", mongo.getConnectionString())
.put("db_name", "testdb");
MongoClient mongoClient = MongoClient.create(vertx, mongoConfig);
this.repository = new ProductRepository(mongoClient);
testContext.completeNow();
}
@Test
void testSaveProduct(Vertx vertx, VertxTestContext testContext) {
JsonObject product = new JsonObject()
.put("name", "Gadget")
.put("price", 29.99);
repository.save(product)
.compose(id -> repository.findById(id))
.onComplete(testContext.succeeding(found -> {
assertThat(found.getString("name")).isEqualTo("Gadget");
testContext.completeNow();
}));
}
}Parameterized Async Tests
Combine @ParameterizedTest with VertxExtension:
@ExtendWith(VertxExtension.class)
class StatusCodeTest {
static Stream<Arguments> httpStatusCases() {
return Stream.of(
Arguments.of("/health", 200),
Arguments.of("/metrics", 200),
Arguments.of("/nonexistent", 404),
Arguments.of("/unauthorized", 401)
);
}
@ParameterizedTest
@MethodSource("httpStatusCases")
void testStatusCodes(String path, int expectedStatus,
Vertx vertx, VertxTestContext testContext) {
WebClient client = WebClient.create(vertx);
client.get(8080, "localhost", path)
.send()
.onComplete(testContext.succeeding(response -> {
assertThat(response.statusCode()).isEqualTo(expectedStatus);
testContext.completeNow();
}));
}
}Testing Verticle Deployment and Clustering
For tests involving multiple verticles or clustered deployments:
@ExtendWith(VertxExtension.class)
class ClusteredEventBusTest {
@Test
void testDeploymentWithConfig(Vertx vertx, VertxTestContext testContext) {
JsonObject config = new JsonObject()
.put("http.port", 0) // Random port for tests
.put("db.pool.size", 2);
DeploymentOptions options = new DeploymentOptions()
.setConfig(config)
.setInstances(2); // Deploy 2 instances
vertx.deployVerticle(ApiVerticle.class, options)
.onComplete(testContext.succeeding(id -> {
assertThat(id).isNotNull();
// Both instances deployed successfully
testContext.completeNow();
}));
}
}Connecting Async Tests to Production Monitoring
Vert.x's async model makes internal unit testing straightforward once you learn the VertxExtension patterns. But end-to-end behavioral validation — verifying that your reactive service actually processes user requests correctly in production — needs a different approach.
HelpMeTest lets you monitor your Vert.x services continuously with health checks and plain-English behavioral tests. Set up helpmetest health vertx-api 30s for uptime monitoring, and write test scenarios that verify user-facing behavior without fighting async callback chains.
Summary
VertxExtensionis the JUnit 5 extension — always annotate your test class with@ExtendWith(VertxExtension.class)VertxTestContext.verify()routes assertion failures from async callbacks to JUnittestContext.succeeding()andtestContext.failing()are the primary Future handlers in tests- Multiple checkpoints handle tests where several async events must all complete
testContext.completeNow()andtestContext.failNow()are the async equivalents of test pass/fail- Always call
testContext.completeNow()— a test that never calls it will hang until timeout