Testing Vert.x Async Applications with VertxExtension

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 Vertx and VertxTestContext parameters
  • 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

  • VertxExtension is the JUnit 5 extension — always annotate your test class with @ExtendWith(VertxExtension.class)
  • VertxTestContext.verify() routes assertion failures from async callbacks to JUnit
  • testContext.succeeding() and testContext.failing() are the primary Future handlers in tests
  • Multiple checkpoints handle tests where several async events must all complete
  • testContext.completeNow() and testContext.failNow() are the async equivalents of test pass/fail
  • Always call testContext.completeNow() — a test that never calls it will hang until timeout

Read more