Two failure modes show up in almost every Camel test suite we review. The first suite needs a live broker, a mounted file share, and someone's VPN to run, so it runs nightly at best and nobody trusts the red. The second mocks every endpoint until the test asserts that Camel can call a stub, which it can. Neither tells you whether the route works.
The way out is layering: cheap tests that exercise route logic in memory, a small number of expensive tests that exercise real protocols, and honesty about the things tests cannot cover at all.
Layer 1: route logic, in memory
Start from camel-test-junit5 (CamelTestSupport, or the Spring Boot variant @CamelSpringBootTest with @MockEndpoints if the route is wired by the framework). The goal at this layer is to run the real route definition — the same splitter, choice, transformation, and error handler that ships — while replacing everything that touches the outside world.
The tool for the replacement is AdviceWith. It rewrites a route at test time instead of asking you to parameterize every endpoint URI for testability:
AdviceWith.adviceWith(context, "order-intake", r -> {
r.replaceFromWith("direct:start");
r.interceptSendToEndpoint("jms:queue:downstream")
.skipSendToOriginalEndpoint()
.to("mock:downstream");
});
Two habits make this layer pay off:
- Name every route.
from("jms:queue:orders").routeId("order-intake")costs nothing and is the handle AdviceWith, the JMX beans, and your log correlation all need. Unnamed routes get generated ids that change when you reorder the file, which breaks tests for no reason. - Prefer
interceptSendToEndpointover rewriting the route body. Intercepting leaves the route's structure intact; rewriting turns the test into a test of the rewrite.
Then assert on mock endpoints, and assert on something specific:
MockEndpoint downstream = getMockEndpoint("mock:downstream");
downstream.expectedMessageCount(3);
downstream.message(0).body().isEqualTo("...");
downstream.expectedHeaderReceived("orderId", "88213");
template.sendBody("direct:start", sampleBatch());
assertMockEndpointsSatisfied();
expectedMessageCount(1) alone is weak — it passes when the transformation is wrong. Assert the body and the headers that downstream systems actually read. And remember assertMockEndpointsSatisfied() waits; for a route with an async hop, a bare assertion without the wait is a flaky test in waiting.
Layer 2: the error paths, which is where the bugs are
Happy-path route tests are the easy half and the less useful half. Production incidents come from redelivery, poison messages, and partial failures — so test those deliberately.
AdviceWith makes it straightforward to force failure at a chosen point:
r.weaveById("call-pricing")
.replace()
.throwException(new java.net.SocketTimeoutException("boom"));
With that in place you can assert the behaviors you actually designed:
- The exchange lands on the dead letter channel after the configured number of redeliveries, not before and not forever.
- The DLQ message carries enough context to diagnose it: original body, route id, exception summary.
onExceptionfor a business exception (validation failure) does not retry, while a transient IO exception does. Getting these two backwards is one of the most common findings in our architecture reviews.- An idempotent consumer releases its key when the exchange fails, so a legitimate retry is not swallowed as a duplicate.
Set redelivery delays to zero in tests (errorHandler overridden via AdviceWith, or a test profile property). A suite that genuinely sleeps through a 30-second backoff will be deleted by the first engineer in a hurry.
Layer 3: real protocols, with Testcontainers
In-memory tests cannot tell you whether your JMS acknowledgement mode is right, whether your Kafka consumer commits where you think, or whether your SQL component's parameter binding matches the driver. Those are protocol behaviors, and they need the real thing.
Testcontainers is the pragmatic answer: start an actual Artemis, Kafka, or Postgres container for the test class, point the route's endpoint at the container's mapped port, and run the unmodified route. Keep this layer small and deliberate:
- One or two tests per component, not per route. You are testing that your configuration of the broker integration is correct, not re-testing Camel.
- Cover the semantics you depend on: does a thrown exception actually cause redelivery from the broker? Does a rolled-back transaction leave the message on the queue?
- Accept the cost. These tests take seconds, not milliseconds. They belong in CI on every merge; they do not belong in the loop a developer runs every save.
For HTTP dependencies, a stub server (WireMock, or Camel's own test infra) is usually a better fit than a container — it makes it easy to script timeouts and 500s, which is the interesting half of HTTP behavior.
What not to test
- Don't test Camel. If your test asserts that a splitter splits, you are testing the framework's regression suite, not your code.
- Don't assert on exact log lines. They change, and the assertion tells you nothing about behavior.
- Don't chase 100% route coverage. A route that is
from(...).to(...)with no logic gets its value from the Layer 3 component test, not from a mock-endpoint test that proves wiring the compiler already proved.
Why this matters beyond the test suite
The practical payoff shows up during upgrades. A Camel 3-to-4 jump changes package names, component defaults, and error-handling details; the difference between a two-week upgrade and a two-month one is almost entirely whether the routes have tests that run in minutes and fail loudly. The same is true of an ESB migration: reconciliation proves the new route matches the old one on real traffic, but a layered test suite is what lets you keep changing the route afterwards without re-running the whole reconciliation exercise.
If your estate has routes with no tests — most estates have some — do not start with a coverage mandate. Start with the three routes that page someone at night, name them, advise them, and write the error-path tests first.