1. Overview

Quarkus, the Supersonic Subatomic Java, promises to deliver small artifacts, extremely fast boot time, and lower time-to-first-request. We can understand it as a framework that integrates Java standard technologies (Jakarta EE, MicroProfile, and others) and enables building a standalone application that can be deployed in any container runtime, easily fulfilling the requirements of cloud-native applications.

In this article, we’ll learn how to implement integration tests with Citrus, a framework written by Christoph Deppisch – Principal Software Engineer at Red Hat.

2. The Purpose of Citrus

The applications we develop typically don’t run isolated but communicate with other systems, such as databases, messaging systems, or online services. When testing our application, we could do this in an isolated manner by mocking the corresponding objects. But we also might want to test the communication of our application with external systems. That’s where Citrus comes into play.

Let’s take a closer look at the most common interaction scenarios.

2.1. HTTP

Our web application may have an HTTP-based API (e.g., a REST API). Citrus can act as an HTTP client that calls our application’s HTTP API and verifies the response (like REST-assured does). Our application might also be a consumer of another application’s HTTP API. Citrus could run an embedded HTTP server and act as a mock in this case:

quarkus citrus 01 http

2.2. Kafka

In this case, our application is a Kafka consumer. Citrus can act as a Kafka producer to send a record to a topic so that our application gets triggered by consuming the record. Our application also might be a Kafka producer.

Citrus can act as a consumer to verify the messages that our application sent to the topic during the test. Additionally, Citrus provides an embedded Kafka server to be independent of any external server during the test:

quarkus citrus 02 kafka

2.3. Relational Databases

Our application uses a relational database. Citrus can act as a JDBC client that verifies that the database has the expected state. Furthermore, Citrus provides a JDBC driver and an embedded database mock that can be instrumented to return test-case-specific results and verify the executed database queries:

quarkus citrus 03 jdbc

2.4. Further Support

Citrus supports further external systems, such as REST, SOAP, JMS, Websocket, Mail, FTP, and Apache Camel endpoints. We can find a full listing in the documentation.

3. Citrus Tests With Quarkus

Quarkus has extensive support for writing integration tests, including mocking, test profiles, and testing native executables. Citrus provides the QuarkusTest runtime, a Quarkus Test Resource to extend Quarkus-based tests by including Citrus capabilities.

Let’s have a sample where we use the most common technologies – a REST service provider, that stores data in a relational database and sends a message to Kafka when a new item is created. For Citrus, it doesn’t matter how we implement this in detail. Our application is a black box, and only the external systems and the communication channels are crucial:

quarkus citrus 10 appsample

3.1. Maven Dependencies

To use Citrus in our Quarkus-based project, we can use the citrus-bom:

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

We can optionally add further modules, depending on the technologies used:

<dependency>
    <groupId>org.citrusframework</groupId>
    <artifactId>citrus-openapi</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.citrusframework</groupId>
    <artifactId>citrus-http</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.citrusframework</groupId>
    <artifactId>citrus-validation-json</artifactId>
</dependency>
<dependency>
    <groupId>org.citrusframework</groupId>
    <artifactId>citrus-validation-hamcrest</artifactId>
    <version>${citrus.version}</version>
</dependency>
<dependency>
    <groupId>org.citrusframework</groupId>
    <artifactId>citrus-sql</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.citrusframework</groupId>
    <artifactId>citrus-kafka</artifactId>
    <scope>test</scope>
</dependency>

3.2. Application Configuration

There isn’t any global Quarkus configuration that needs to be done for Citrus. There’s just a warning in the logs about split packages, that we can avoid by adding this line to the application.properties file:

%test.quarkus.arc.ignored-split-packages=org.citrusframework.*

3.3. Test Setup for the Boundary

A typical test with Citrus would have the elements:

  • a @CitrusSupport annotation that adds the Quarkus Test Resource to extend the Quarkus-based test processing
  • a @CitrusConfiguration annotation that includes one or multiple configuration classes for Citrus, that are used for the global configuration of communication endpoints and dependency injection into the test classes
  • fields to get endpoints and other Citrus-provided objects injected

So, if we want to test the boundary, we would need an HTTP client to send a request to our application and verify the response. First, we need to create the Citrus configuration class:

public class BoundaryCitrusConfig {

    public static final String API_CLIENT = "apiClient";

    @BindToRegistry(name = API_CLIENT)
    public HttpClient apiClient() {
        return http()
          .client()
          .requestUrl("http://localhost:8081")
          .build();
    }

}

Then, we create the test class:

As a convention, we could skip the name attributes of the annotations if the declaring method and the field in the test class have the same name. This might be shorter, but prone to errors due to missing compiler checks.

3.4. Testing the Boundary

For writing the test, we need to know that Citrus has a declarative concept, defining the components:

quarkus citrus 11 concept

  • Test Context is an object that provides test variables and functions, that, among others, replace dynamic content in message payloads and headers.
  • A Test Action is an abstraction for each step in the test. This could be one interaction, like sending a request or receiving a response, including validations and verifications. It could also be just a simple output or a timer. Citrus provides a Java DSL, and XML as an alternative to define test definitions with Test Actions. We can find a list of pre-defined Test Actions in the documentation.
  • Test Action Builder is used to define and build the Test Action. Citrus uses the Builder Pattern here.
  • Test Action Runner uses the Test Action Builder to build the Test Action. Then, it executes the Test Action, providing the Test Context. For BBD style, we can use a GherkinTestActionRunner.

We can get the Test Action Runner injected too. The code shows a test that sends an HTTP POST request to http://localhost:8081/api/v1/todos with a JSON body and expects to receive a response with a 201 status code:

@CitrusResource
GherkinTestActionRunner t;

@Test
void shouldReturn201OnCreateItem() {
    t.when(
            http()
              .client(apiClient)
              .send()
              .post("/api/v1/todos")
              .message()
              .contentType(MediaType.APPLICATION_JSON)
              .body("{\"title\": \"test\"}")
    );
    t.then(
            http()
              .client(apiClient)
              .receive()
              .response(HttpStatus.CREATED)
    );
}

The body is written directly as a JSON string. Alternatively, we could use a Data Dictionary as shown in this sample.

For message validation, we have multiple possibilities. For example, using JSON-Path in combination with Hamcrest, we can extend the then block:

t.then(
        http()
          .client(apiClient)
          .receive()
          .response(HttpStatus.CREATED)
          .message()
          .type(MessageType.JSON)
          .validate(
                    jsonPath()
                      .expression("$.title", "test")
                      .expression("$.id", is(notNullValue()))
          )
);

Unfortunately, only Hamcrest is supported. For AssertJ, there’s been a GitHub issue opened in 2016.

3.5. Testing the Boundary Based on OpenAPI

We can also send requests based on an OpenAPI definition. This automatically validates the response concerning property and header constraints declared in the OpenAPI schema.

First, we need to load the OpenAPI schema. For example, if we have a YML file in our project, we can do this by defining an OpenApiSpecification field:

final OpenApiSpecification apiSpecification = OpenApiSpecification.from(
        Resources.create("classpath:openapi.yml")
);

We could also read the OpenApi from the running Quarkus application, if available:

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

For the test, we can refer to the operationId to send a request or to verify a response:

t.when(
        openapi()
          .specification(apiSpecification)
          .client(apiClient)
          .send("createTodo") // operationId
);
t.then(
        openapi()
          .specification(apiSpecification)
          .client(apiClient)
          .receive("createTodo", HttpStatus.CREATED)
);

This generates a request including the necessary body by creating random values. Currently, it’s impossible to use explicitly defined values for headers, parameters, or bodies (see this GitHub-Issue). Also, there is a bug when generating random date values. We could avoid this at least for optional fields by skipping random values:

@BeforeEach
void setup() {
    this.apiSpecification.setGenerateOptionalFields(false);
    this.apiSpecification.setValidateOptionalFields(false);
}

In this case, we must also disable strict validation, which will fail because the service returns optional fields. (see this GitHub-Issue) We could do this by using JUnit Pioneer. For this, we add the junit-pioneer dependency:

<dependency>
    <groupId>org.junit-pioneer</groupId>
    <artifactId>junit-pioneer</artifactId>
    <version>2.2.0</version>
    <scope>test</scope>
</dependency>

Then, we can add the @SystemProperty annotation to our test class before the @CitrusSupport annotation:

@SetSystemProperty(
    key = "citrus.json.message.validation.strict",
    value = "false"
)

3.6. Testing the Database Access

When we invoke the create operation of our REST API, it should store the new item in the database. To evaluate this, we can query the database for the newly created ID.

First, we need a data source. We can get this easily injected from Quarkus:

@Inject
DataSource dataSource;

Then, we need to extract the ID of the newly created item from the response body and store it as a Test Context variable:

t.when(
        http()
          .client(apiClient)
          .send()
          .post("/api/v1/todos")
          .message()
          .contentType(MediaType.APPLICATION_JSON)
          .body("{\"title\": \"test\"}")
);
t.then(
        http()
          .client(apiClient)
          .receive()
          .response(HttpStatus.CREATED)
          // save new id to test context variable "todoId"
          .extract(fromBody().expression("$.id", "todoId"))
);

We can now check the database with a query that uses the variable:

t.then(
        sql()
          .dataSource(dataSource)
          .query()
          .statement("select title from todos where id=${todoId}")
          .validate("title", "test")
);

3.7. Testing the Messaging

When we invoke the create operation of our REST API, it should send the new item to a Kafka topic. To evaluate this, we can subscribe to the topic and consume the message.

For this, we need a Citrus endpoint:

public class KafkaCitrusConfig {

    public static final String TODOS_EVENTS_TOPIC = "todosEvents";

    @BindToRegistry(name = TODOS_EVENTS_TOPIC)
    public KafkaEndpoint todosEvents() {
        return kafka()
          .asynchronous()
          .topic("todo-events")
          .build();
    }

}

Then, we want Citrus to inject this endpoint into our test:

@QuarkusTest
@CitrusSupport
@CitrusConfiguration(classes = {
    BoundaryCitrusConfig.class,
    KafkaCitrusConfig.class
})
class MessagingCitrusTest {

    @CitrusEndpoint(name = KafkaCitrusConfig.TODOS_EVENTS_TOPIC)
    KafkaEndpoint todosEvents;

    // ...

}

After sending and receiving the request as we saw earlier, we can then subscribe to the topic and consume and validate the message:

t.and(
        receive()
          .endpoint(todosEvents)
          .message()
          .type(MessageType.JSON)
          .validate(
                    jsonPath()
                      .expression("$.title", "test")
                      .expression("$.id", "${todoId}")
          )
);

3.8. Mocking the Servers

Citrus can mock external systems. This can be helpful to avoid the need for these external systems for testing purposes and to directly verify the messages sent to these systems and mock the responses instead of validating the state of the system after message processing.

In the case of Kafka, the Quarkus Dev Services feature runs a Docker container with a Kafka server. We could use the Citrus mock instead. We then have to disable the Dev Services feature in the application.properties file:

%test.quarkus.kafka.devservices.enabled=false

Then, we configure the Citrus mock server:

public class EmbeddedKafkaCitrusConfig {

    private EmbeddedKafkaServer kafkaServer;

    @BindToRegistry
    public EmbeddedKafkaServer kafka() {
        if (null == kafkaServer) {
            kafkaServer = new EmbeddedKafkaServerBuilder()
              .kafkaServerPort(9092)
              .topics("todo-events")
              .build();
        }
        return kafkaServer;
    }

    // stop the server after the test
    @BindToRegistry
    public AfterSuite afterSuiteActions() {
        return afterSuite()
          .actions(context -> kafka().stop())
          .build();
    }

}

We could then activate the mock server by just referring to this configuration class as already known:

@QuarkusTest
@CitrusSupport
@CitrusConfiguration(classes = {
    BoundaryCitrusConfig.class,
    KafkaCitrusConfig.class,
    EmbeddedKafkaCitrusConfig.class
})
class MessagingCitrusTest {

    // ...

}

We can also find mock servers for external HTTP services and relational databases.

4. Challenges

Writing tests with Citrus also has challenges. The API isn’t always intuitive. Integration for AssertJ is missing. Citrus throws exceptions instead of AssertionErrors when validation fails, resulting in confusing test reports. The online documentation is extensive, but code samples contain Groovy code, and sometimes XML. There’s a repository with Java code samples in GitHub, that might help. Javadocs are incomplete.

It seems that the integration into the Spring Framework is in focus. The documentation often refers to Citrus configuration in Spring. The citrus-jdbc module depends on Spring Core and Spring JDBC, which we’ll get as unnecessary transitive dependencies for our tests unless we exclude them.

5. Conclusion

In this tutorial, we’ve learned how to implement Quarkus tests with Citrus. Citrus provides many features to test the communication of our application with external systems. This also includes mocking these systems for the test. It’s well-documented, but the included code samples match for other use cases than integration into Quarkus. Fortunately, there is a GitHub repository that contains samples with Quarkus.

As usual, all the code implementations are available over on GitHub.