Your integration tests keep failing for non-obvious reasons. Or even worse, the test suite reports success, but some of those green tests might be lying to you. The tests pass because the validations are too week, because timing coincidences mask real problems, or because poor testability of routes makes an assertion trivially true.

Integration tests are especially prone to false confidence or flakiness because there are more moving parts — Kafka topics, multiple routes, multiple consumers, shared infrastructure, asynchronous processing.

This post covers several testing pitfalls collected from real-world Apache Camel integration projects, each with a concrete fix. For Kafka-specific pitfalls — offset races, cross-test interference, and header serialization — see the companion post Kafka Testing Pitfalls in Camel Integration Tests.

Citrus AI generated with Google Gemini

Pitfall: Poor testability of routes

An otherwise() branch that just calls .log(...) is invisible to Camel’s management API. It is extremely hard to verify that the otherwise branch has been executed in a test because inline log statements do not have their own route MBean.

The bad pattern

from("kafka:eip.consumer.dispatch?brokers=")
    .routeId("message-dispatcher")
    .unmarshal().json()
    .choice()
        .when(this::isknownEventType)
            .toD("direct:handle-${body[event_type]}")
        .otherwise()
            .log("Unknown event_type '${body[event_type]}' — skipping")
    .end();

The route evaluates the event type of incoming messages to be known to the system. Unknown event types are handled by the otherwise() branch which logs and drops the message. An integration test that sends an unknown event type has a hard time to verify that the branch was taken. There is no output message to receive, and no named route whose exchange count can be checked.

The fix

from("kafka:eip.consumer.dispatch?brokers=")
    .routeId("message-dispatcher")
    .unmarshal().json()
    .choice()
        .when(this::isknownEventType)
            .toD("direct:handle-${body[event_type]}")
        .otherwise()
            .to("direct:handle-order_unknown")
    .end();

from("direct:handle-order_unknown")
    .routeId("handle-order-unknown")
    .log("Unknown event_type '${body[event_type]}' — skipping");

The otherwise() branch now routes to a named direct:handle-order_unknown endpoint, backed by a separate route with its own routeId. A Citrus test can now verify the otherwise branch by accessing the route stats with an expected number of completed exchanges:

t.then(
    camel().route()
        .verifyRouteStats("handle-order-unknown")
        .completed(1)
        .failed(0)
);

This pattern applies to any inline processing that should be testable. The 14-consumer-patterns example shows the Message Dispatcher route with named handlers for every event type — including the unknown handler.

Pitfall: Too weak message validations gives false confidence

When receiving messages with Citrus the test should verify that the incoming message matches a given set of assertions. An empty receive().message() call with no expected body or header validation proves only that some message arrived at the test endpoint. It could even be a leftover from a previous test run, a message from a demo data generator, or output from an unrelated route that happens to write to the same topic.

The bad pattern

t.then(
    receive()
        .endpoint("kafka:eip.orders.processed?consumerGroup=citrus-group")
        .message()
);

This test will pass as long as any message exists on the topic. It says nothing about whether your route processed the specific order input your test has sent.

The same issue applies to message validation that are too weak or trivially true. For instance when all field values in a Json structure are ignored by @ignore@ expression.

t.then(
    receive()
        .endpoint("kafka:eip.orders.processed?consumerGroup=citrus-group")
        .message()
        .body()
        .data("""
        {
          "order_id": "@ignore@",
          "customer_id": "@ignore@",
          "item_sku": "@ignore@",
          "quantity": 1,
          "amount": "@ignore@"
        }
        """)
);

The fix

Always validate at least one field that ties the received message to your test input — a message key, an order ID, or the full template body. It helps when the test creates unique identifiers and test data at the very beginning of the test in the form of test variables.

t.given(
    createVariables()
        .variable("id", "citrus:randomNumber(4)")
        .variable("amount", 90.00)
        .variable("country", "US")
        .variable("hazmat", true)
);
t.then(
    receive()
        .endpoint("kafka:eip.orders.processed?consumerGroup=citrus-group")
        .message()
        .body(Resources.create("templates/order.json"))
        .header(KafkaMessageHeaders.MESSAGE_KEY, "org.citrusframework:citrus-website:pom:1.1.0")
);

The message template order.json should use the test variables to verify the field values for the current test data.

{
  "order_id": org.citrusframework:citrus-website:pom:1.1.0,
  "customer_id": "CUST-org.citrusframework:citrus-website:pom:1.1.0",
  "item_sku": "SKU-org.citrusframework:citrus-website:pom:1.1.0",
  "quantity": 1,
  "amount": ${amount}
}

Now the test verifies that the message body matches the expected template with the same variable values used in the send step, and that the Kafka message key matches the order ID. If the message came from somewhere else, the assertion fails.

When the full body content is unreliable verify message headers instead. Or better yet, identify reliable parts of the message body and perform message expression validations for that part for instance via JsonPath or XPath expressions that evaluate a very specific element in a Json or XML message body.

Pitfall: Missing JSON marshalling step

This is the most common route-side bug that breaks Citrus tests. A route does unmarshal().json() to parse JSON into a Java Map object, processes the map, then sends it to let’s say a Kafka topic. On the output topic, the message body is {order_id=1234, amount=99.95} — Java’s Map.toString() format, not valid JSON. Citrus’s JSON validator fails with Failed to parse JSON text.

The problem

from("kafka:eip.orders.placed?brokers=&groupId=filter-demo")
    .routeId("message-filter")
    .unmarshal().json()
    .filter(simple("${body[amount]} >= 100"))
        .log("High-value order ${body[order_id]}: $${body[amount]}")
        .to("kafka:eip.orders.high-value?brokers=")
    .end();

After unmarshal().json(), the body is a Map<String, Object>. The to("kafka:...") serializes it using Map.toString(), which produces {order_id=1234, ...} with equals signs instead of colons and no quotes around keys. This is not JSON.

IMPORTANT: The missing marshal step problem is nasty because unit tests that mock the Kafka output topic may pass without noticing the issue. Only integration tests that perform the Kafka value serialization may reveal the issue when Citrus tries to parse the serialized Json body.

The fix

from("kafka:eip.orders.placed?brokers=&groupId=filter-demo")
    .routeId("message-filter")
    .unmarshal().json()
    .filter(simple("${body[amount]} >= 100"))
        .log("High-value order ${body[order_id]}: $${body[amount]}")
        .marshal().json()
        .to("kafka:eip.orders.high-value?brokers=")
    .end();

The added marshal().json() converts the Map back to valid JSON before sending to Kafka. Two important details:

The log() must come before marshal(). Expressions like ${body[order_id]} work on a Map — they access map entries by key. After marshal().json(), the body is a JSON string, and ${body[order_id]} no longer resolves.

Pitfall: Testing only the happy path

A choice() route with three branches, tested with one test method, gives 33% branch coverage. The other two branches might be completely broken — wrong topic names, missing marshal calls, incorrect conditions — and your test suite will not catch it.

The bad pattern

A message filter route passes messages that match a condition and silently drops the rest. Here is a route that filters for high-value orders. Orders with amount >= 100 pass through to eip.orders.high-value. Orders below that threshold are simply dropped.

@ApplicationScoped
public class MessageFilterRoute extends RouteBuilder {

    @Override
    public void configure() {
        from("kafka:eip.orders.placed?brokers=&groupId=filter-demo")
            .routeId("message-filter")
            .unmarshal().json()
            .filter(simple("${body[amount]} >= 100"))
                .log("High-value order ${body[order_id]}: $${body[amount]}")
                .marshal().json()
                .to("kafka:eip.orders.high-value?brokers=")
            .end();
    }
}

Testing only the happy path where high-value orders are sent to the Kafka topic is not enough!

The fix

One test per distinct routing outcome. For the Message Filter specifically, you need both a pass test and a reject test. Even if it is significantly harder to verify that something has not happened such as low-value orders being dropped.

@Test
public void shouldPassHighValueOrder() {
    // amount=250 → arrives on eip.orders.high-value
    t.then(
        receive()
            .endpoint("kafka:eip.orders.high-value?consumerGroup=citrus-high-value-group")
            .message()
            .body(Resources.create("templates/order.json"))
    );
}

@Test
public void shouldFilterLowValueOrder() {
    // amount=50 → nothing on eip.orders.high-value
    t.then(
        expectTimeout()
            .endpoint("kafka:eip.orders.high-value?consumerGroup=citrus-filter-reject-group")
            .timeout(5000)
    );
}

The expectTimeout() assertion proves that no message arrived — which is the correct behavior for a filtered message.

The same principle applies to Content-Based routers, format indicators (test each content type), and any route with choice() or when() logic. The 09-routing-fundamentals example demonstrates this pattern with separate tests for each branch of the Content-Based Router and the Message Filter.

Pitfall: Locale-dependent String formatting in JSON

Routes that build JSON strings via String.format("%.2f", amount) produce locale-sensitive output. On machines with a non-English locale (for example, German), the decimal separator changes from . to ,. The value 29.99 becomes 29,99, which is invalid JSON. Citrus body validation fails with Failed to parse JSON text.

The problem

String enriched = String.format(
    "{\"order_id\": %d, \"amount\": %.2f, \"status\": \"%s\"}",
    orderId, amount.doubleValue(), status);

On a German-locale machine, this produces {"order_id": 42, "amount": 29,99, "status": "placed"} — the comma in 29,99 makes it unparseable JSON.

The fix

String enriched = String.format(Locale.US,
    "{\"order_id\": %d, \"amount\": %.2f, \"status\": \"%s\"}",
    orderId, amount.doubleValue(), status);

Always pass Locale.US as the first argument to String.format wherever a floating-point format specifier (%f, %.2f) appears in a JSON-producing context.

This bug is particularly insidious because it is invisible on CI runners, which typically use en_US.UTF-8. Tests pass in CI but fail on developer machines with different locales. The symptom — Failed to parse JSON text — does not immediately suggest a locale issue, so developers often waste time looking at the wrong layer.

Pitfall: Timer-driven routes breaking tests

Timer-based routes — demo data generators, scheduled business processes, periodic polling — fire at unpredictable moments during tests. They pollute exchange counts, compete for resources like distributed locks, and produce messages that tests do not expect.

The problem

A DemoDataGenerator fires every 5 seconds and writes random orders to eip.orders.placed. During a test that sends its own order to the same topic and reads from the output, the generator’s messages appear as noise. Exchange counts for routes that process eip.orders.placed include both test messages and generator messages, making verifyRouteStats() test action unreliable.

The fix

Add a configuration property that disables auto-start.

For Quarkus:

@ApplicationScoped
public class DemoDataGenerator extends RouteBuilder {

    @ConfigProperty(name = "eip.demo.data.generator.enabled", defaultValue = "true")
    boolean enabled;

    @Override
    public void configure() {
        from("timer:demo-orders?period=5000&delay=3000")
            .routeId("demo-data-generator")
            .autoStartup(enabled)
            // ... processing logic
            .to("kafka:eip.orders.placed?brokers=");
    }
}

For Spring Boot:

@Component
public class DemoDataGenerator extends RouteBuilder {

    @Value("${eip.demo.data.generator.enabled:true}")
    boolean enabled;

    @Override
    public void configure() {
        from("timer:demo-orders?period=5000&delay=3000")
            .routeId("demo-data-generator")
            .autoStartup(enabled)
            // ... processing logic
            .to("kafka:eip.orders.placed?brokers=");
    }
}

In both cases, the default value is true so the timer-based route is enabled by default. The automated test suite will disable the timer-based generator in the test-scoped application.properties:

eip.demo.data.generator.enabled=false

Use a specific property name per route — eip.demo.data.generator.enabled, eip.distributed.lock.enabled — not a generic timers.enabled flag. Different tests may need different timer routes active.

Pitfall: Eager initialization tasks

A Spring Boot application with a @Component with @PostConstruct that connects to an external service (Redis, database, message broker) fires during Spring context creation. In tests, the Citrus BeforeSuite has not started the Docker Compose infrastructure yet. The @PostConstruct runs, the service is not available, and the test fails with a connection error before it even begins.

The problem

@Component
public class RedisProductCatalog {

    @PostConstruct
    void init() {
        seedCatalog();   // connects to Redis — fails if Redis isn't running
    }

    public void seedCatalog() {
        hash.putAll("product:SKU-ABC-42", Map.of("name", "Wireless Headphones", ...));
        // ... more products
    }
}

In production, Redis is running when the application starts, so @PostConstruct works. In tests, the Spring Boot context loads first (creating all beans and running @PostConstruct), then test infrastructure is started for instance via Citrus BeforeSuite which starts Docker Compose. The initialization order is wrong.

The fix

Make the eager initialization conditional with a toggle property:

@Component
public class RedisProductCatalog {

    @Value("${redis.catalog.seed:true}")
    boolean seedEnabled;

    @PostConstruct
    void init() {
        if (seedEnabled) seedCatalog();
    }

    public void seedCatalog() {
        hash.putAll("product:SKU-ABC-42", Map.of("name", "Wireless Headphones", ...));
        // ... more products
    }
}

The test application.properties disables automatic seeding:

redis.catalog.seed=false

The test class then calls seedCatalog() explicitly after the infrastructure is up:

@Autowired
RedisProductCatalog redisProductCatalog;

@Test
public void shouldEnrichOrderWithProductData() {
    redisProductCatalog.seedCatalog();   // call after infra is up
    t.given(waitForCamelRouteStarted("content-enricher", camelContext));
    // ... send and receive
}

This pattern applies whenever a @Component or @Service connects to infrastructure in @PostConstruct. The default property value stays true so production behavior is unchanged.

NOTE: this issue is specific to Spring Boot. Quarkus runs @PostConstruct after Citrus beforeSuite, so the infrastructure is already available.

Pitfall: REST routes via HTTP merges direct endpoints

When a REST DSL definition uses .to("direct:route-status") and a separate from("direct:route-status") exists in the same RouteBuilder, Camel merges them into a single REST route. The direct: consumer is not registered as a standalone endpoint.

The problem

rest("/control")
    .get("/status/{routeId}")
        .to("direct:route-status");

from("direct:route-status")
    .routeId("control-bus-status")
    .toD("controlbus:route?routeId=${header.routeId}&action=status");

At runtime, the route log shows control-bus-status (rest://get:/control:/status/{routeId}) — the from("direct:route-status") logic was inlined into the REST route. Sending to direct:route-status via camel().send() or CamelEndpointBuilder in a test fails with DirectConsumerNotAvailableException.

So it is not possible to access the direct endpoint from a Citrus test at all. Also accessing the route statistics for that direct route is not possible. We need to keep this in mind when designing the tests to verify if a REST operation has been processed as expected.

The fix

Test REST-backed routes via HTTP instead of direct: endpoint injection:

t.when(
    http()
        .client("http://localhost:8081")
        .send()
        .get("/control/status/my-route")
);

t.then(
    http()
        .client("http://localhost:8081")
        .receive()
        .response(HttpStatus.OK)
        .message()
        .body("\"Started\"")
);

For Quarkus, the default test port is 8081. For Spring Boot, configure the port with webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT.

If you control the route design, the REST DSL plus direct: plus separate route pattern may be avoided entirely. Either use rest().route() inline, or accept that the direct: endpoint will not be independently addressable in tests. The REST DSL’s merging behavior is a Camel optimization, not a bug — but it does change the testing surface.

Pitfall: Synchronous messaging deadlock

Multi-hop flows are common in Camel: route1 provides a REST endpoint and processes synchronous requests from clients. Each incoming request in route1 produces events on a Kafka topic. The test sends Http requests to route1 and expects to receive events from the topic for verification. At the very end the test verifies the Http response 200 OK. But there is a timing problem.

The problem

An order enrichment route calls an intermediate service inventory-service to enrich the order with warehouse specific data such as warehouse ids and stock availability. The inventory-service gets called via Http REST endpoint and the enriched order response is then passed to further internal processing.

@ApplicationScoped
public class OrderEnrichmentRoute extends RouteBuilder {

    @Override
    public void configure() {
        rest("/api/orders")
                .post("/enrich")
                .consumes("application/json")
                .produces("application/json")
                .to("direct:order-enrichment");

        from("direct:order-enrichment")
            .routeId("order-enrichment")
            .to("https://inventory-service/checkStock")
            .marshal().json()
            .log("Enriched order: ${body}")
            .to("direct:enriched-output");

        from("direct:enriched-output")
            .routeId("enriched-output-handler")
            .log("Enriched order ready for downstream processing");
    }
}

The synchronous nature of Http causes issues here, because the test both triggers the initial Http request to the /api/orders/enrich REST endpoint and verifies the intermediate inventory-service call. Also, the test is in charge of simulating the inventory-service with a proper enriched order response.

So while the test is synchronously waiting for the enriched orders response it should handle the inventory-service calls. This is a deadlock situation that leads to message timeout errors.

The fix

The fix is to fork the initial Http send operation in the test so the test is able to continue with the next steps that handle the inventory-service calls.

@Test
public void shouldEnrichOrder() {
    t.given(
        createVariables()
            .variable("id", "citrus:randomNumber(4)")
            .variable("amount", 100)
            .variable("status", "placed")
            .variable("priority", "STANDARD")
    );

    t.given(waitForCamelRouteStarted("enriched-output-handler", camelContext));

    t.given(
        http()
            .client("http://localhost:8081")
            .send()
            .post("/api/orders/enrich")
            .fork(true)
            .message()
            .body(Resources.create("templates/order.json"))
            .contentType("application/json")
    );

    t.when(
        http()
            .server("inventoryService")
            .receive("/checkStock")
            .post()
            .message()
            .body(Resources.create("templates/order.json"))
            .contentType("application/json")
    );

    t.then(
        http()
            .server("inventoryService")
            .send()
            .response(HttpStatus.OK)
            .message()
            .body(Resources.create("templates/enriched_order.json"))
            .contentType("application/json")
    );
}

The test now both sends the initial Http request at the same time receives the inventory-service Http request and provides the enriched orders response. With the enabled fork the test sequence is not blocked by the synchronous nature of Http and a deadlock in the test actions is avoided.

A testing checklist

Here are all pitfalls as a quick-reference checklist:

  • Add marshal().json() before every to("kafka:...") that follows an unmarshal() — on every branch.
  • Extract all routing branches (including otherwise()) into named direct: routes so they show up in MBean statistics.
  • Make all timer-based routes toggleable via a configuration property with .autoStartup(enabled).
  • Defer @PostConstruct initialization that connects to external services behind a conditional property — call it explicitly in tests after infrastructure is up.
  • Always pass Locale.US to String.format when producing JSON with floating-point values.
  • Test REST DSL routes via HTTP, not via direct: endpoint injection.
  • Validate at least one field that ties the received message to your test input — never use an empty receive().message() or ignore all fields with @ignore@.
  • Write one test per distinct routing outcome — including reject paths, filter drops, and otherwise() branches. Use expectTimeout() to verify that no message arrived when that is the expected behavior.
  • Fork synchronous HTTP requests with fork(true) when the test both triggers the request and simulates an intermediate service to avoid deadlock in the test actions.

For Kafka-specific checklist items — offset races, consumer group isolation, header serialization — see the Kafka Testing Pitfalls post.

None of these pitfalls are specific to Citrus — they apply to integration testing in general. But Citrus gives you the tools and capabilities to fix every one of them.

You can explore all the testing patterns and pitfall fixes in the eip-with-camel repository.

Give it a try, and let us know what you think!