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 <[email protected]>
AuthorDate: Thu Oct 15 22:00:08 2026 +0200
Blog: Camel Integration Testing Pitfalls
Co-Authored-By: Claude Opus 4.6 <[email protected]>
---
.../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!