You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@camel.apache.org by cd...@apache.org on 2026/10/16 12:33:18 UTC
(camel-website) branch main updated: Blog: Camel Integration Testing Pitfalls
This is an automated email from the ASF dual-hosted git repository.
christophd pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel-website.git
The following commit(s) were added to refs/heads/main by this push:
new 24b63ac9 Blog: Camel Integration Testing Pitfalls
24b63ac9 is described below
commit 24b63ac924ffa49b6354c16fb426106d33327839
Author: Christoph Deppisch <ch...@web.de>
AuthorDate: Thu Oct 15 22:00:08 2026 +0200
Blog: Camel Integration Testing Pitfalls
Co-Authored-By: Claude Opus 4.6 <no...@anthropic.com>
---
.../2026/10/camel-testing-pitfalls/featured.jpg | Bin 0 -> 505565 bytes
.../blog/2026/10/camel-testing-pitfalls/index.md | 616 +++++++++++++++++++++
2 files changed, 616 insertions(+)
diff --git a/content/blog/2026/10/camel-testing-pitfalls/featured.jpg b/content/blog/2026/10/camel-testing-pitfalls/featured.jpg
new file mode 100644
index 00000000..7f684fa4
Binary files /dev/null and b/content/blog/2026/10/camel-testing-pitfalls/featured.jpg differ
diff --git a/content/blog/2026/10/camel-testing-pitfalls/index.md b/content/blog/2026/10/camel-testing-pitfalls/index.md
new file mode 100644
index 00000000..a3dd8451
--- /dev/null
+++ b/content/blog/2026/10/camel-testing-pitfalls/index.md
@@ -0,0 +1,616 @@
+---
+title: "Camel Integration Testing Pitfalls"
+date: 2026-10-16
+draft: false
+authors: [christophd]
+categories: ["Howtos", "Tooling"]
+keywords: ["apache camel", "testing", "integration testing", "citrus", "pitfalls", "kafka"]
+preview: "Your integration tests keep failing for non-obvious reasons — or worse, they pass when they shouldn't. The root cause may not be the test itself but Camel routes that got trapped in a common integration testing pitfall. This post covers real-world Camel testing pitfalls and how to fix them."
+---
+
+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](https://citrusframework.org), 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
+
+```java
+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
+
+```java
+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:
+
+```java
+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](https://github.com/citrusframework/citrus-camel-eip-examples/tree/main/examples/14-consumer-patterns/quarkus) 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
+
+```java
+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:
+
+```java
+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.
+
+```java
+t.given(
+ createVariables()
+ .variable("id", "citrus:randomNumber(4)")
+ .variable("amount", 90.00)
+ .variable("country", "US")
+ .variable("hazmat", true)
+);
+```
+
+```java
+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:
+
+```json
+{
+ "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
+
+```java
+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
+
+```java
+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.
+
+```java
+@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.
+
+```java
+@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](https://github.com/citrusframework/citrus-camel-eip-examples/tree/main/examples/09-routing-fundamentals/quarkus) 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
+
+```java
+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
+
+```java
+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:_
+
+```java
+@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:_
+
+```java
+@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`:
+
+```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
+
+```java
+@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:
+
+```java
+@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:
+
+```properties
+redis.catalog.seed=false
+```
+
+The test class then calls `seedCatalog()` explicitly after the infrastructure is up:
+
+```java
+@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
+
+```java
+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:
+
+```java
+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.
+
+```java
+@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.
+
+```java
+@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](https://github.com/citrusframework/citrus-camel-eip-examples/tree/main).
+
+[Citrus](https://citrusframework.org) 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!