Testing service with Vertx web-client

Vertx people suggest web-client and junit5 extension to test web-services.

To try this I added dependencies:

./build.gradletestImplementation 'io.vertx:vertx-junit5:3.8.3'
// testImplementation('org.junit.jupiter:junit-jupiter:5.5.2')
testImplementation 'io.vertx:vertx-web-client:3.8.3'

junit-jupiter is dependency of the vertx-junit5.

Lets test one method that creates account.

@Test
public void createAccountTest() throws InterruptedException {
    Vertx vertx = Vertx.vertx();
    WebClient webClient = WebClient.create(vertx);

    JsonObject account = new JsonObject();
    account.put("name", "accountLOSS1");
    account.put("type", "LOSS");
    account.put("summ", 1000.00);
    webClient.post(8080, "localhost", "/api/v1/accounts")
    .sendJsonObject(account, ar -> {
        if (ar.failed()) {
            fail("Create account request failed", ar.cause());
        }
        HttpResponse<Buffer> response = ar.result();
        if (response.statusCode() != 200) {
            String errors = response.bodyAsString();
            fail(errors);
        }
        assertEquals(response.getHeader("content-type"), "application/json");
        long accountId = Long.parseLong(response.bodyAsString());
        assertTrue(accountId > 0, "No account Id");
    });
}

This test succeeded. But should not. Without authorization service responses with code 401.

I wanted to watch errors and added them to console:

if (ar.failed()) {
    ar.cause().printStackTrace();
    fail("Create account request failed", ar.cause());
}
HttpResponse<Buffer> response = ar.result();
if (response.statusCode() != 200) {
    String errors = response.bodyAsString();
    System.err.println(response.statusCode());
    System.err.println(errors);
    fail(errors);
}

There is nothing on console. Web-client makes request asynchronously. Test ends before response is ready. With debug I can see that there are another thread, and 401 status code, and errors.

How fix test? I need to wait for response threat.

import java.util.concurrent.atomic.AtomicReference;
import org.infinispan.util.concurrent.ReclosableLatch;

@Test
public void createAccountTest() throws InterruptedException {
    Vertx vertx = Vertx.vertx();
    WebClient webClient = WebClient.create(vertx);

    JsonObject account = new JsonObject();
    account.put("name", "accountLOSS1");
    account.put("type", "LOSS");
    account.put("summ", 1000.00);
    
    ReclosableLatch waitForResponseLatch = new ReclosableLatch();
    final AtomicReference<Throwable> testErrorRef = new AtomicReference<Throwable>();
    webClient.post(8080, "localhost", "/api/v1/accounts")
    .sendJsonObject(account, ar -> {
        if (ar.failed()) {
            ar.cause().printStackTrace();
            testErrorRef.set(ar.cause());
            waitForResponseLatch.open();
            return;
        }
        HttpResponse<Buffer> response = ar.result();
        if (response.statusCode() != 200) {
            String errors = response.bodyAsString();
            testErrorRef.set(new Exception("Wrong http status code: " + response.statusCode() + " " + errors));
            waitForResponseLatch.open();
            return;
        }
        try {
            assertEquals(response.getHeader("content-type"), "application/json");
            long accountId = Long.parseLong(response.bodyAsString());
            assertTrue(accountId > 0, "No account Id");
            waitForResponseLatch.open();
        } catch (Exception e) {
            testErrorRef.set(e);
            waitForResponseLatch.open();
        }
    });
    boolean opened = waitForResponseLatch.await(10, TimeUnit.SECONDS);
    if (!opened) {
        fail("timeout");
    }
    Throwable testError = testErrorRef.get();
    if (testError != null) {
        fail(testError);
    }
}

I used ReclosableLatch to wait. Then I should get exceptions from the other thread. I wrapped assertions with try-catch and set exception to AtomicReference.

I should be authorized to make test success. It will be another request with waiting.

@Test
public void createAccountTest() throws InterruptedException {
    Vertx vertx = Vertx.vertx();
    WebClient webClient = WebClient.create(vertx);

    AtomicReference<String> token = new AtomicReference<>();
    
    ReclosableLatch waitForLoginLatch = new ReclosableLatch();
    final AtomicReference<Throwable> loginErrorRef = new AtomicReference<Throwable>();
    
    JsonObject user = new JsonObject();
    user.put("login", "testLogin");
    user.put("password", "testPassword");
    webClient.post(8080, "localhost", "/api/v1/login")
    .sendJsonObject(user, ar -> {
        if (ar.failed()) {
            loginErrorRef.set(ar.cause());
            waitForLoginLatch.open();
            return;
        }
        HttpResponse<Buffer> response = ar.result();
        if (response.statusCode() != 200) {
            String errors = response.bodyAsString();
            loginErrorRef.set(new Exception("Wrong http status code: " + response.statusCode() + " " + errors));
            waitForLoginLatch.open();
            return;
        }
        token.set(response.getHeader("X-Auth-Token"));
        waitForLoginLatch.open();
    });
    boolean loginLatchOpened = waitForLoginLatch.await(10, TimeUnit.SECONDS);
    if (!loginLatchOpened) {
        fail("timeout login");
    }
    Throwable loginError = loginErrorRef.get();
    if (loginError != null) {
        fail(loginError);
    }
    

    ReclosableLatch waitForResponseLatch = new ReclosableLatch();
    final AtomicReference<Throwable> testErrorRef = new AtomicReference<Throwable>();
    
    JsonObject account = new JsonObject();
    account.put("name", "accountLOSS1");
    account.put("type", "LOSS");
    account.put("summ", 1000.00);
    webClient.post(8080, "localhost", "/api/v1/accounts")
    .putHeader("Authorization", "Bearer " + token.get())
    .sendJsonObject(account, ar -> {
        if (ar.failed()) {
            ar.cause().printStackTrace();
            testErrorRef.set(ar.cause());
            waitForResponseLatch.open();
            return;
        }
        HttpResponse<Buffer> response = ar.result();
        if (response.statusCode() != 200) {
            String errors = response.bodyAsString();
            testErrorRef.set(new Exception("Wrong http status code: " + response.statusCode() + " " + errors));
            waitForReregistrationsponseLatch.open();
            return;
        }
        try {
            assertEquals(response.getHeader("content-type"), "application/json");
            long accountId = Long.parseLong(response.bodyAsString());
            assertTrue(accountId > 0, "No account Id");
            waitForResponseLatch.open();
        } catch (Exception e) {
            testErrorRef.set(e);
            waitForResponseLatch.open();
        }
    });
    boolean opened = waitForResponseLatch.await(10, TimeUnit.SECONDS);
    if (!opened) {
        fail("timeout");
    }
    Throwable testError = testErrorRef.get();
    if (testError != null) {
        fail(testError);
    }
}

Now test finishes with success.

This code is difficult to read. The Vertx suggests own class VertTestContext. This class helps with exceptions and to wait for a callback thread. Let's try.

@Test
public void createAccountTest() throws InterruptedException {
    Vertx vertx = Vertx.vertx();
    WebClient webClient = WebClient.create(vertx);

    AtomicReference<String> token = new AtomicReference<>();
    
    VertxTestContext loginTctx = new VertxTestContext();
    
    JsonObject user = new JsonObject();
    user.put("login", "testLogin");
    user.put("password", "testPassword");
    webClient.post(8080, "localhost", "/api/v1/login")
    .sendJsonObject(user, ar -> {
        if (ar.failed()) {
            loginTctx.failNow(ar.cause());
            return;
        }
        HttpResponse<Buffer> response = ar.result();
        if (response.statusCode() != 200) {
            String errors = response.bodyAsString();
            loginTctx.failNow(new Exception("Wrong http status code: " + response.statusCode() + " " + errors));
            return;
        }
        token.set(response.getHeader("X-Auth-Token"));
        loginTctx.completeNow();
    });
    boolean loginLatchOpened = loginTctx.awaitCompletion(10, TimeUnit.SECONDS);
    if (!loginLatchOpened) {
        fail("timeout login");
    }
    if (loginTctx.failed()) {
        Throwable loginError = loginTctx.causeOfFailure();
        fail(loginError);
    }
    

    VertxTestContext tctx = new VertxTestContext();
    
    JsonObject account = new JsonObject();
    account.put("name", "accountLOSS1");
    account.put("type", "LOSS");
    account.put("summ", 1000.00);
    webClient.post(8080, "localhost", "/api/v1/accounts")
    .putHeader("Authorization", "Bearer " + token.get())
    .sendJsonObject(account, ar -> {
        if (ar.failed()) {
            tctx.failNow(ar.cause());
            return;
        }
        HttpResponse<Buffer> response = ar.result();
        if (response.statusCode() != 200) {
            String errors = response.bodyAsString();
            tctx.failNow(new Exception("Wrong http status code: " + response.statusCode() + " " + errors));
            return;
        }
        try {
            assertEquals(response.getHeader("content-type"), "application/json");
            long accountId = Long.parseLong(response.bodyAsString());
            assertTrue(accountId > 0, "No account Id");
            tctx.completeNow();
        } catch (Exception e) {
            tctx.failNow(e);
        }
    });
    boolean opened = tctx.awaitCompletion(10, TimeUnit.SECONDS);
    if (!opened) {
        fail("timeout");
    }
    if (tctx.failed()) {
        Throwable testError = tctx.causeOfFailure();
        fail(testError);
    }
}

Latch and exception are hidden in VertxTestContext now. To open latch I should call eather completeNow() or failNow() methods. awaitCompletion() waits on latch. After latch context will be failed or completed.

There are more useful methods in context. With succeeding() I can remove

if (ar.failed()) {
    tctx.failNow(ar.cause());
    return;
}

And I can replace try-catch with verify()

@Test
public void createAccountTest() throws InterruptedException {
    Vertx vertx = Vertx.vertx();
    WebClient webClient = WebClient.create(vertx);

    AtomicReference<String> token = new AtomicReference<>();
    
    VertxTestContext loginTctx = new VertxTestContext();
    
    JsonObject user = new JsonObject();
    user.put("login", "testLogin");
    user.put("password", "testPassword");
    webClient.post(8080, "localhost", "/api/v1/login")
    .sendJsonObject(user, loginTctx.succeeding(response -> {
        if (response.statusCode() != 200) {
            String errors = response.bodyAsString();
            loginTctx.failNow(new Exception("Wrong http status code: " + response.statusCode() + " " + errors));
            return;
        }
        token.set(response.getHeader("X-Auth-Token"));
        loginTctx.completeNow();
    }));
    boolean loginLatchOpened = loginTctx.awaitCompletion(10, TimeUnit.SECONDS);
    if (!loginLatchOpened) {
        fail("timeout login");
    }
    if (loginTctx.failed()) {
        Throwable loginError = loginTctx.causeOfFailure();
        fail(loginError);
    }
    
    VertxTestContext tctx = new VertxTestContext();
    
    JsonObject account = new JsonObject();
    account.put("name", "accountLOSS1");
    account.put("type", "LOSS");
    account.put("summ", 1000.00);
    webClient.post(8080, "localhost", "/api/v1/accounts")
    .putHeader("Authorization", "Bearer " + token.get())
    .sendJsonObject(account, tctx.succeeding(response -> {
        if (response.statusCode() != 200) {
            String errors = response.bodyAsString();
            tctx.failNow(new Exception("Wrong http status code: " + response.statusCode() + " " + errors));
            return;
        }
        tctx.verify(() -> {
            assertEquals(response.getHeader("content-type"), "application/json");
            long accountId = Long.parseLong(response.bodyAsString());
            assertTrue(accountId > 0, "No account Id");
            tctx.completeNow();
        });
    }));
    boolean opened = tctx.awaitCompletion(10, TimeUnit.SECONDS);
    if (!opened) {
        fail("timeout");
    }
    if (tctx.failed()) {
        Throwable testError = tctx.causeOfFailure();
        fail(testError);
    }
}

I have two VertxTestContext. I want to try and left just one context. But I can't. The second request depends on the first. I must wait callback from the login request. After the first request the latch will be opened. And awaitCompletion will not work. Because there is no method to close the latch again. The latch is created in VertxTestContext constructor. I took a look at Checkpoint but checkpoints are usefull on independend requests.

Now I want to move login to BeforeAll. It will be convenient to add more tests.

class AccountTest {

    private static String token;
    private static WebClient webClient;

    @BeforeAll
    static void beforeAll() throws InterruptedException {
        Vertx vertx = Vertx.vertx();
        webClient = WebClient.create(vertx);
        
        VertxTestContext tctx = new VertxTestContext();
        
        JsonObject user = new JsonObject();
        user.put("login", "testLogin");
        user.put("password", "testPassword");
        webClient.post(8080, "localhost", "/api/v1/login")
        .sendJsonObject(user,ar -> {
            try {
                HttpResponse<Buffer> response = succeeding(ar);
                token = response.getHeader("X-Auth-Token");
                tctx.completeNow();
            } catch (Throwable e) {
                tctx.failNow(e);
            }
        });

        checkTestContext(tctx, "Login");
    }

    @Test
    public void createAccountTest() throws InterruptedException {
        VertxTestContext tctx = new VertxTestContext();
        
        JsonObject account = new JsonObject();
        account.put("name", "accountLOSS1");
        account.put("type", "LOSS");
        account.put("summ", 1000.00);
        webClient.post(8080, "localhost", "/api/v1/accounts")
        .putHeader("Authorization", "Bearer " + token)
        .sendJsonObject(account, ar -> {
            try {
                HttpResponse<Buffer> response = succeeding(ar);
                assertEquals(response.getHeader("content-type"), "application/json");
                long accountId = Long.parseLong(response.bodyAsString());
                assertTrue(accountId > 0, "No account Id");
                tctx.completeNow();
            } catch (Throwable e) {
                tctx.failNow(e);
            }
        });

        checkTestContext(tctx, "Create account");
    }

    private static void checkTestContext(VertxTestContext tctx, String message)
            throws InterruptedException {
        assertTrue(tctx.awaitCompletion(10, TimeUnit.SECONDS), message);
        if (tctx.failed()) {
            Throwable causeOfFailure = tctx.causeOfFailure();
            causeOfFailure.printStackTrace();
            fail(message + " " + causeOfFailure.getMessage());
        }
    }
    
    private static HttpResponse<Buffer> succeeding(AsyncResult<HttpResponse<Buffer>> ar)
            throws Throwable {
        if (ar.failed()) {
            Throwable exception = ar.cause();
            throw exception;
        }
        HttpResponse<Buffer> response = ar.result();
        if (response.statusCode() != 200) {
            String errors = response.bodyAsString();
            Exception exception = new Exception("Wrong http status code: "
                    + response.statusCode() + " " + errors);
            throw exception;
        }
        return response;
    }
}

I ended with two own methods checkTestContext() and succeeding. I think that

testContext.succeeding(response -> testContext.verify(() -> {

makes code unreadable.

Conclusion

On my opinion asinchronious web-client is not handy for test. Almost every response should be checked right after request. I can create a batch of accounts asynchronously. But speed doesn't metter in tests. If I want to do something simultaneously I can use threads. It will be grate if async code was optional in the Vertx web-client.