This project uses Quarkus to implement a sample event-driven application and shows how to verify the event processing with an automated integration test written in Citrus. The Quarkus support in Citrus is described in more detail in reference guide

Objectives

The project uses the Quarkus test framework to set up a dev services environment with JUnit Jupiter where the application is running on the local machine. The Quarkus dev services capabilities will automatically start Testcontainers during the test in order to simulate the surrounding infrastructure (e.g. PostgreSQL database and the Kafka message broker).

If you want to learn more about Quarkus, please visit its website: https://quarkus.io/.

Quarkus sample application

The Quarkus sample demo application is a food market event-driven application that listens for incoming events of type booking and supply.

Food Market App

Users are able to add booking events. Each of them references a product and gives an amount as well as an accepted price in a simple Json object structure.

{ "client": "citrus-test", "product": "Pineapple", "amount":  100, "price":  0.99 }

At the same time suppliers may add their individual supply events that again reference a product with an amount and a selling price.

The Quarkus application consumes both event types and as soon as bookings and supplies do match in all criteria the food market application will produce booking-completed and shipping events as a result.

All events are produced and consumed with Kafka event streams. The domain model objects with their individual status are stored in a PostgreSQL database.

Adding Citrus to the project

Looking at the Maven pom.xml you will see that Citrus is added as a test scoped dependency. The most convenient way to add Citrus to your project is to import the citrus-bom.

<dependencyManagement>
    <dependencies>
      <dependency>
        <groupId>org.citrusframework</groupId>
        <artifactId>citrus-bom</artifactId>
        <version>4.5.0</version>
        <type>pom</type>
        <scope>import</scope>
      </dependency>
    </dependencies>
  </dependencyManagement>

Citrus is very modular. This means you can choose from a wide range of modules that add specific testing capabilities to the project (e.g. citrus-kafka, citrus-http, citrus-mail, …). In this sample project we include the following modules as test scoped dependencies:

  • citrus-quarkus
  • citrus-kafka
  • citrus-http
  • citrus-sql
  • citrus-selenium
  • citrus-validation-json
  • citrus-validation-text

The citrus-quarkus module provides the QuarkusTest resource implementation that enables Citrus on a Quarkus test.

<dependency>
  <groupId>org.citrusframework</groupId>
  <artifactId>citrus-quarkus</artifactId>
</dependency>

The other modules add very specific Citrus capabilities such as validation of a Json message payload.

This completes the dependency setup. Now we can move on to writing an automated integration test that verifies the Quarkus application.

Enable Citrus with @QuarkusTest

The test uses an arbitrary @QuarkusTest annotation with JUnit Jupiter. This means that Quarkus takes care of starting the application under test. It also starts some Testcontainers for the PostgreSQL database and the Kafka message broker.

You can enable the Citrus capabilities on the test by adding the @CitrusSupport annotation to the test class.

@QuarkusTest
@CitrusSupport
class FoodMarketApplicationTest {

    @CitrusResource
    TestCaseRunner t;

    @Inject
    ObjectMapper mapper;

    @Test
    void shouldProcessEvents() {
        createBooking();
        
        createSupply();
        
        verifyBookingCompletedEvent();
        
        verifyShippingEvent();
    }
}

The Citrus enabled test is able to inject additional resources such as the TestCaseRunner. This runner is the entrance to all Citrus related test actions like send/receive messages or querying and verifying entities in the database.

The test will perform four main actions:

  • Create a booking event
  • Create a matching supply event
  • Verify the booking completed event
  • Verify the shipping event

In first version of the test all events will be sent/received via the Kafka message broker.

Stage #1: Prototyping the test

Citrus is able to send and receive messages via Kafka quite easily. You can use a dynamic endpoint URL (e.g. kafka:my-topic-name) to exchange data. The message content (header and body) is given with simple inline Json Strings in this first prototype.

@QuarkusTest
@CitrusSupport
public class FoodMarketDemoTest {

    @CitrusResource
    TestCaseRunner t;

    @Test
    void shouldMatchBookingAndSupply() {
        createBooking();

        createSupply();

        verifyBookingCompletedEvent();

        verifyShippingEvent();
    }

    private void createBooking() {
        t.when(send()
                .endpoint("kafka:bookings")
                .message()
                .body("""
                    {
                        "client": "citrus",
                        "product": {
                            "name": "Kiwi"
                        },
                        "amount": 10,
                        "price": 0.99,
                        "shippingAddress": "001, Foo Blvd."
                    }
                """)
        );
    }

    //...
}

The injected Citrus TestCaseRunner t is able to use Gherkin Given-When-Then syntax and references the KafkaEndpoint kafka:bookings in the send operation. The message body is a simple Json String that represents the booking.

The rest of the story is quite easy. In the same way we can also send a supply event and then receive completed and shipping events in the test.

When receiving the completed and shipping events the test is able to use the Citrus Json validation power coming with the citrus-validation-json module. Citrus will compare the received Json object with an expected template and make sure that all fields and properties do match as expected.

class FoodMarketApplicationTest {

    // ...

    private void verifyBookingCompletedEvent() {
        t.then(receive()
                .endpoint("kafka:completed?timeout=10000&consumerGroup=citrus-booking")
                .message()
                .body("""
                    {
                        "client": "citrus",
                        "product": "Kiwi",
                        "amount": 10,
                        "status": "COMPLETED"
                    }
                """)
        );
    }
}

The Citrus Json validation will now compare the received event with the expected Json object and fail the test when there is a mismatch.

Citrus Json validation

{ "client":  "citrus", "product": "Kiwi", "amount": 10, "status": "COMPLETED" }

// compared to

{ "client":  "citrus", "product": "Kiwi", "amount": 10, "status": "COMPLETED" }

The Json validation is very powerful. You can ignore properties (expected value set to @ignore@), use validation matchers, functions and test variables. A mismatch in the order of elements or some difference in the formatting of the Json document is not failing the test.

In case there is a mismatch you will be provided with an error and the test fails accordingly.

Running the Citrus tests

The Quarkus test framework uses JUnit Jupiter as a test driver. This means you can run the tests just like any other JUnit test (e.g. from your Java IDE, with Maven).

./mvnw test

The Citrus test capabilities are added on top of @QuarkusTest with the @CitrusSupport annotation. So you will not need any other configuration to empower the tests with Citrus.

Stage #2: Use endpoint builders and domain model objects

Using the dynamic endpoint kafka:my-topic-name may be a good and easy start for prototyping. When it comes to writing more tests in your project you may want to leverage a central Kafka endpoint configuration and reuse it in multiple tests.

You can add a @CitrusConfiguration annotation that loads endpoints from one to many configuration classes.

@QuarkusTest
@CitrusSupport
@CitrusConfiguration(classes = { CitrusEndpointConfig.class })
public class FoodMarketDemoTest {

    @CitrusResource
    TestCaseRunner t;

    @CitrusEndpoint
    KafkaEndpoint supplies;

    @CitrusEndpoint
    KafkaEndpoint bookings;

    @CitrusEndpoint
    KafkaEndpoint completed;

    @CitrusEndpoint
    KafkaEndpoint shipping;

    // code the tests
}

In the loaded CitrusEndpointConfig class the Kafka endpoint instances get configured for all tests that load the configuration.

public class CitrusEndpointConfig {

    @BindToRegistry
    public KafkaEndpoint bookings() {
        return kafka()
                .asynchronous()
                .topic("bookings")
                .build();
    }

    @BindToRegistry
    public KafkaEndpoint supplies() {
        return kafka()
                .asynchronous()
                .topic("supplies")
                .build();
    }

    @BindToRegistry
    public KafkaEndpoint shipping() {
        return kafka()
                .asynchronous()
                .topic("shipping")
                .consumerGroup("citrus-shipping")
                .timeout(10000L)
                .build();
    }

    @BindToRegistry
    public KafkaEndpoint completed() {
        return kafka()
                .asynchronous()
                .topic("completed")
                .consumerGroup("citrus-completed")
                .timeout(10000L)
                .build();
    }

    // more endpoints
}

The configuration class uses the KafkaEndpoint builder and binds the components to the Citrus registry. With that configuration you can inject the endpoint instances in your test with the @CitrusEndpoint annotation.

Now you can reference the endpoint in Citrus send/receive test actions.

Product product = new Product("Kiwi");
Booking booking = new Booking("citrus", product, 10, 0.99D, TestHelper.createShippingAddress().getFullAddress());

private void createBooking(Booking booking) {
    t.when(send()
        .endpoint(bookings)
        .message()
        .body(marshal(booking))
    );
}

Another improvement for the test is to use the domain model object Booking as a message payload instead of using the inline Json String.

Stage #3: Add mail verification

So far the test has been using Kafka endpoints exclusively. Citrus provides a huge set of components to connect to different messaging transports and technologies.

As a next step the test verifies a mail message that is sent by the Quarkus application when a booking has been completed.

First of all the configuration adds a Citrus mail server.

@BindToRegistry
public MailServer mailServer() {
    return mail().server()
            .port(2222)
            .knownUsers(Collections.singletonList("foodmarket@quarkus.io:foodmarket:secr3t"))
            .autoAccept(true)
            .autoStart(true)
            .build();
}

The mail server accepts incoming mail requests on port 2222 and adds some known users. The Quarkus application then uses the connection credentials in the application.properties.

quarkus.mailer.mock=false
quarkus.mailer.own-host-name=localhost
quarkus.mailer.from=foodmarket@quarkus.io
quarkus.mailer.host=localhost
quarkus.mailer.port=2222

quarkus.mailer.username=foodmarket
quarkus.mailer.password=secr3t
quarkus.mailer.start-tls=OPTIONAL

The test is able to reference this mail server to verify the mail sent by Quarkus.

private void verifyBookingCompletedMail(Booking booking) {
    t.then(receive()
            .endpoint(mailServer)
            .message(MailMessage.request()
                    .from("foodmarket@quarkus.io")
                    .to("%s@quarkus.io".formatted(booking.getClient()))
                    .subject("Booking completed!")
                    .body("Hey %s, your booking %s has been completed."
                            .formatted(booking.getClient(), booking.getProduct().getName()), "text/plain")));

    t.then(send()
            .endpoint(mailServer)
            .message(MailMessage.response(250)));
}

The mail verification includes the validation of all mail related properties (e.g. from, to, subject, body) and the simulation of the mail server response (250 OK). This is a good point to simulate a mail server error in order to verify the resilience and the error handling on the Quarkus application.

Stage #4: Use TestBehaviors

Citrus has the concept of TestBehaviors to reuse a set of test actions in multiple tests. The mail verification steps may be added into a behavior so many tests can make use of it.

public class VerifyBookingCompletedMail implements TestBehavior {

    private final Booking booking;
    private final MailServer mailServer;

    public VerifyBookingCompletedMail(Booking booking, MailServer mailServer) {
        this.booking = booking;
        this.mailServer = mailServer;
    }

    @Override
    public void apply(TestActionRunner t) {
        t.run(receive()
            .endpoint(mailServer)
            .message(MailMessage.request()
                    .from("foodmarket@quarkus.io")
                    .to("%s@quarkus.io".formatted(booking.getClient()))
                    .subject("Booking completed!")
                    .body("Hey %s, your booking %s has been completed."
                            .formatted(booking.getClient(), booking.getProduct().getName()), "text/plain"))
        );

        t.run(send()
            .endpoint(mailServer)
            .message(MailMessage.response())
        );
    }
}

The test is able to apply the behavior quite easily.

private void verifyBookingCompletedMail(Booking booking) {
    t.then(t.applyBehavior(new VerifyBookingCompletedMail(booking, mailServer)));
}

Stage #5: Use the Http REST API

The Quarkus application under test also provides a Http REST API to manage bookings and supplies.

The Citrus test is able to call the REST API with a Http client.

@BindToRegistry
public HttpClient foodMarketApiClient() {
    return http().client()
            .requestUrl("http://localhost:8081")
            .build();
}
private void createBooking(Booking booking) {
    t.when(http()
            .client(foodMarketApiClient)
            .send()
            .post("/api/bookings")
            .message()
            .contentType(MediaType.APPLICATION_JSON_VALUE)
            .body(marshal(booking))
    );

    t.then(http()
            .client(foodMarketApiClient)
            .receive()
            .response(HttpStatus.CREATED)
            .message()
            .extract(json().expression("$.id", "bookingId"))
    );
}

The test now sends a Http POST request to create the booking. The client is able to verify the Http response 201 CREATED and also save the generated booking id for later reference in the test.

Stage #6: Verify entities in the database

The test may also verify the entities saved to the PostgreSQL database. The @QuarkusTest dev services is able to inject the dataSource that connects to the PostgreSQL database Testcontainers that is startes as part of the test.

@Inject
DataSource dataSource;

private void verifyBookingStatus(Booking.Status status) {
    t.then(sql()
        .dataSource(dataSource)
        .query()
        .statement("select status from booking where booking.id=${bookingId}")
        .validate("status", status.name())
    );
}

The SQL verification uses the extracted bookingId test variable to identify the entity in the database. The returned result set gets verified with the expected column values (e.g. status=COMPLETED)

Stage #7: Use OpenAPI specification

The Quarkus application also exposes its OpenAPI specification for the REST API. The Citrus test is able to leverage this specification when sending/receiving Http messages.

private final OpenApiSpecification foodMarketSpec =
            OpenApiSpecification.from("http://localhost:8081/q/openapi");

private void createBooking(Booking booking) {
    t.when(openapi()
        .specification(foodMarketSpec)
        .client(foodMarketApiClient)
        .send("addBooking")
        .message()
        .body(marshal(booking))
    );

    t.then(openapi()
        .specification(foodMarketSpec)
        .client(foodMarketApiClient)
        .receive("addBooking", HttpStatus.CREATED)
        .message()
        .extract(json().expression("$.id", "bookingId"))
    );
}

The test loads the OpenAPI specification from the Quarkus application with the URL http://localhost:8081/q/openapi. The specification then is used with the Http send test action.

The action references an operation with the id addBooking.

---
openapi: 3.0.3
info:
  title: citrus-demo-quarkus API
  version: 1.0.0
servers:
  - url: http://localhost:8080
    description: Auto generated value
  - url: http://0.0.0.0:8080
    description: Auto generated value
paths:
  /api/bookings:
    post:
      operationId: addBooking
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/Booking'
      responses:
        "200":
          description: OK

The Citrus test action now leverages information given in the specification such as resource path /api/bookings and the content type application/json.

Stage #8: UI testing with Selenium

Citrus also integrates with Selenium UI testing. This means that the test is able to open the browser and navigate to the Quarkus application home URL. Then the test may simulate user interactions such as clicking on links and buttons on the page.

private void approveBooking() {
    t.given(selenium()
            .browser(browser)
            .start());

    t.given(doFinally().actions(
            selenium()
                    .browser(browser)
                    .stop()));

    t.when(selenium()
            .browser(browser)
            .navigate("http://localhost:8081"));

    t.then(delay().seconds(3));

    t.then(selenium()
            .browser(browser)
            .click()
            .element("id", "${bookingId}"));
}

Summary

The sample application has shown how you can integrate Citrus within a Quarkus test. The Quarkus test is able to use the Citrus capabilities because of the Citrus Quarkus extension that is provided since Citrus 4.0.