Blog post featured image

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 weak, 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. The test code examples use Citrus, an open-source integration testing framework that works natively with Camel, but the pitfalls themselves apply regardless of which testing tool you use.

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={{kafka.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. 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 verifying 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={{kafka.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. Any test framework — including Camel’s built-in test support — can now verify the otherwise branch by checking that the handle-order-unknown route processed exactly one exchange.

With Citrus, that check is a single action:

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 give false confidence

When verifying output messages, a test should check that the incoming message matches specific assertions. An empty receive call with no expected body or header validation proves only that some message arrived at the test endpoint. It could 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 sent.

The same issue applies to message validations that are too weak or trivially true. For instance when all field values in a JSON structure are ignored:

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, "${id}")
);

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

{
  "order_id": ${id},
  "customer_id": "CUST-${id}",
  "item_sku": "SKU-${id}",
  "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 the message body.

Pitfall: Missing JSON marshalling step

This is the most common route-side bug that breaks integration tests. A route does unmarshal().json() to parse JSON into a Java Map object, processes the map, then sends it to 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. Any JSON-aware test validator fails with Failed to parse JSON text.

A unit test that mocks the Kafka producer endpoint will likely not catch this error. The mock receives the Map object directly in memory — it never goes through Kafka’s value serializer, which is what calls Map.toString(). Only an integration test that sends and receives actual Kafka messages over the wire will see the broken output and fail on the invalid JSON.

The problem

from("kafka:eip.orders.placed?brokers={{kafka.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={{kafka.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.

The missing marshal step is nasty because unit tests that mock the Kafka output topic may pass without noticing the issue. Only integration tests that perform the actual Kafka value serialization reveal the problem.

The fix

from("kafka:eip.orders.placed?brokers={{kafka.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={{kafka.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={{kafka.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={{kafka.brokers}}")
            .end();
    }
}

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

The fix

Write 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.

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 a defined Locale (e.g. 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 route statistics 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={{kafka.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={{kafka.brokers}}");
    }
}

In both cases, the default value is true so the timer-based route is enabled by default. The automated test suite disables 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 and @PostConstruct that connects to an external service (Redis, database, message broker) fires during Spring context creation. In tests, the test infrastructure (e.g., Docker Compose containers) has not started 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 the test infrastructure starts (e.g., Docker Compose containers via a test lifecycle hook). 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 test lifecycle hooks, so the infrastructure is already available.

Pitfall: REST routes via HTTP merge 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 in a test fails with DirectConsumerNotAvailableException.

You cannot access the direct endpoint from a test, and route statistics for that direct route are not available either. Keep this in mind when designing tests to verify that a REST operation was 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\"")
);

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 calls to an intermediate service. The test sends HTTP requests to route1 and also simulates the intermediate service. 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 integration test must do two things at once: it sends an HTTP request to the /api/orders/enrich REST endpoint and it simulates the inventory-service that the route calls during processing. Here is the problem: HTTP is synchronous. When the test sends the initial request, that call blocks until the full response comes back. But the response cannot come back until the route finishes — and the route cannot finish until the inventory-service responds. The test is the one supposed to provide that response, but it is stuck waiting on the first call. Neither side can make progress. This is a classic deadlock, and it surfaces as a message timeout error.

The fix

The fix is to fork the initial HTTP send operation in the test so the test can 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")
    );
}

With fork(true), the test sends the initial HTTP request without blocking, so it can proceed to receive the inventory-service request and provide the enriched response. The 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 or ignore all fields.
  • Write one test per distinct routing outcome — including reject paths, filter drops, and otherwise() branches. 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.

You can explore all the testing patterns and pitfall fixes in the Citrus Camel EIP examples repository.

Citrus is purpose-built for this kind of Camel integration testing — it understands Camel endpoints, routes, and management APIs natively.

Citrus provides the tools you need to tackle these pitfalls head-on — fork(true) to break synchronous deadlocks, expectTimeout() to verify that messages did not arrive, route statistics verification via verifyRouteStats(), and built-in support for Kafka message key and header validation.

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

Related posts

Oct 14, 2026
Fast and easy integration testing with the Camel CLI Test Plugin
Jul 6, 2026
How Apache Camel elevates Citrus Integration Testing to next levels