Every Apache Camel project needs tests, but which kind? A unit test with AdviceWith and MockEndpoint runs in milliseconds and tells you whether the filter predicate is correct. But it never touches a Kafka broker, never serializes a message to JSON bytes, never deals with consumer group offsets. An integration test with Citrus starts real infrastructure, sends through real transports, and catches the bugs that in-memory mocking cannot see — but it takes seconds, not milliseconds, and requires Docker.

The answer is not one or the other. A well-tested Camel project uses both. Unit tests provide fast feedback on routing logic during development. Integration tests provide confidence that the full pipeline works end-to-end before deployment. They test different things, and the bugs they catch barely overlap.

This post walks through both approaches side by side, using the same routes as examples. We start with Camel’s built-in unit testing tools, then show how Citrus integration tests cover the gaps that have been missed. Along the way, we build a clear guidance for deciding which tool to use when.

Unit testing with AdviceWith and MockEndpoint

Camel’s built-in test support lets you replace a route’s real endpoints with in-memory stubs. The key tools are AdviceWith — which modifies a route’s definition before it starts — and MockEndpoint — which captures messages for assertion.

A route designed for both testing levels

Here is an OrderFilterRoute that filters high-value orders. Notice the design: the Kafka consumer delegates to a direct:filter-order endpoint, and the filter’s output goes to a direct:high-value-orders endpoint:

@ApplicationScoped
public class OrderFilterRoute extends RouteBuilder {

    @Override
    public void configure() {
        from("direct:filter-order")
            .routeId("order-filter")
            .unmarshal().json(java.util.Map.class)
            .filter().simple("${body[amount]} >= 100")
                .log("High-value order ${body[order_id]}: $${body[amount]}")
                .to("direct:high-value-orders")
            .end();

        from("direct:high-value-orders")
            .routeId("high-value-handler")
            .log("High-value order received for priority processing");

        from("kafka:eip.orders.placed?brokers=&groupId=filter-service")
            .routeId("kafka-order-filter")
            .to("direct:filter-order");
    }
}

This layered design is intentional. The core logic lives in direct:filter-order, which can be tested without Kafka. The Kafka consumer is a thin adapter that feeds into the same direct: endpoint. Unit tests target the logic layer; integration tests target the full Kafka path.

The unit test

@QuarkusTest
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class OrderFilterRouteTest {

    @Inject
    CamelContext camelContext;

    @Inject
    ProducerTemplate producer;

    @BeforeAll
    void adviceRoutes() throws Exception {
        AdviceWith.adviceWith(camelContext, "kafka-order-filter", route -> {
            route.replaceFromWith("direct:test-kafka-input");
        });
        AdviceWith.adviceWith(camelContext, "high-value-handler", route -> {
            route.weaveAddLast().to("mock:high-value");
        });
    }

    @BeforeEach
    void resetMocks() {
        MockEndpoint.resetMocks(camelContext);
    }

    @Test
    void highValueOrderPassesFilter() throws Exception {
        MockEndpoint mock = camelContext.getEndpoint("mock:high-value", MockEndpoint.class);
        mock.expectedMessageCount(1);

        String order = """
            {"order_id": 2001, "amount": 250.00, "customer_id": "C-100"}
            """;
        producer.sendBody("direct:filter-order", order);

        mock.assertIsSatisfied();
    }

    @Test
    void lowValueOrderIsFiltered() throws Exception {
        MockEndpoint mock = camelContext.getEndpoint("mock:high-value", MockEndpoint.class);
        mock.expectedMessageCount(0);

        String order = """
            {"order_id": 2002, "amount": 49.99, "customer_id": "C-101"}
            """;
        producer.sendBody("direct:filter-order", order);

        mock.assertIsSatisfied();
    }

    @Test
    void borderlineOrderPassesFilter() throws Exception {
        MockEndpoint mock = camelContext.getEndpoint("mock:high-value", MockEndpoint.class);
        mock.expectedMessageCount(1);

        String order = """
            {"order_id": 2003, "amount": 100.00, "customer_id": "C-102"}
            """;
        producer.sendBody("direct:filter-order", order);

        mock.assertIsSatisfied();
    }
}

The @BeforeAll method does two things. First, it replaces the Kafka consumer route’s from: with a direct: endpoint, so no Kafka broker is needed. Second, it appends a mock:high-value endpoint to the high-value-handler route, capturing any message that passes the filter.

Each test sends a JSON string to direct:filter-order via ProducerTemplate and checks whether mock:high-value received the expected number of messages. The boundary test with amount: 100.00 catches off-by-one errors in the >= 100 predicate.

What unit tests catch

This approach is excellent for testing routing logic in isolation:

  • Filter predicates — does the simple("${body[amount]} >= 100") expression evaluate correctly?
  • Content-Based Router conditions — does each when() branch fire for the right input?
  • Bean method results — does the enrichment service return the expected data?
  • Boundary conditions — what happens at exactly 100.00?

The tests run fast because they operate entirely in memory. No Docker, no network calls, no serialization overhead.

What unit tests miss

The test sends a JSON string and receives a Java object. It never tests whether the route correctly marshals the output back to JSON before sending to Kafka. A route that does unmarshal().json() → filter → to("kafka:...") without a marshal().json() call would produce {order_id=2001, amount=250.0, customer_id=C-100} on the Kafka topic — Java Map.toString() output, not valid JSON. The unit test would never notice because MockEndpoint captures the in-memory Java object, not the serialized bytes.

Unit tests also cannot verify:

  • Kafka consumer group behavior — offset management, rebalancing, partition assignment.
  • Header propagation — whether Kafka message keys and custom headers survive the transport.
  • Multi-hop async flows — where a message passes through multiple Kafka topics and routes.
  • Infrastructure configuration — broker connection strings, topic auto-creation, timeouts.

Testing routes with multiple branches

The same AdviceWith pattern scales to Content-Based Routers. Here is a route that classifies orders by destination:

@ApplicationScoped
public class OrderValidationRoute extends RouteBuilder {

    @Override
    public void configure() {
        from("direct:validate-order")
            .routeId("order-validation")
            .unmarshal().json(java.util.Map.class)
            .choice()
                .when().simple("${body[shipping_type]} == 'HAZMAT'")
                    .to("direct:hazmat")
                .when().simple("${body[country]} != 'US'")
                    .to("direct:international")
                .otherwise()
                    .to("direct:domestic")
            .end();
    }
}

The unit test adds mock endpoints to all three branches and verifies each one independently:

@QuarkusTest
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class OrderValidationRouteTest {

    @Inject
    CamelContext camelContext;

    @Inject
    ProducerTemplate producer;

    @BeforeAll
    void adviceRoutes() throws Exception {
        AdviceWith.adviceWith(camelContext, "domestic-handler", route -> {
            route.weaveAddLast().to("mock:domestic");
        });
        AdviceWith.adviceWith(camelContext, "international-handler", route -> {
            route.weaveAddLast().to("mock:international");
        });
        AdviceWith.adviceWith(camelContext, "hazmat-handler", route -> {
            route.weaveAddLast().to("mock:hazmat");
        });
    }

    @BeforeEach
    void resetMocks() {
        MockEndpoint.resetMocks(camelContext);
    }

    @Test
    void domesticOrderRoutesToDomestic() throws Exception {
        MockEndpoint domestic = camelContext.getEndpoint("mock:domestic", MockEndpoint.class);
        MockEndpoint international = camelContext.getEndpoint("mock:international", MockEndpoint.class);
        MockEndpoint hazmat = camelContext.getEndpoint("mock:hazmat", MockEndpoint.class);

        domestic.expectedMessageCount(1);
        international.expectedMessageCount(0);
        hazmat.expectedMessageCount(0);

        String order = """
            {"order_id": 1001, "country": "US", "shipping_type": "STANDARD", "amount": 59.99}
            """;
        producer.sendBody("direct:validate-order", order);

        domestic.assertIsSatisfied();
        international.assertIsSatisfied();
        hazmat.assertIsSatisfied();
    }
}

Notice how the test asserts that the other mocks received zero messages. This is the unit-test equivalent of negative testing — proving that the domestic order did not go to the international or hazmat handlers. Each branch gets its own test method with appropriate assertions on all three mocks.

Mocking external services

When a route calls an external service, @InjectMock (from Quarkus) or @MockBean (from Spring Boot) lets you stub it:

@QuarkusTest
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class OrderEnrichmentRouteTest {

    @Inject
    CamelContext camelContext;

    @Inject
    ProducerTemplate producer;

    @InjectMock
    InventoryService inventoryService;

    @BeforeEach
    void setup() {
        MockEndpoint.resetMocks(camelContext);

        when(inventoryService.checkStock(any())).thenReturn(Map.of(
            "order_id", 3001,
            "item_sku", "ELEC-TV-55",
            "warehouse", "WAREHOUSE-MOCK",
            "stock_available", 99,
            "weight_kg", 15.0
        ));
    }

    @Test
    void orderIsEnrichedWithInventoryData() throws Exception {
        MockEndpoint mock = camelContext.getEndpoint("mock:enriched", MockEndpoint.class);
        mock.expectedMessageCount(1);

        String order = """
            {"order_id": 3001, "item_sku": "ELEC-TV-55", "amount": 599.99}
            """;
        producer.sendBody("direct:enrich-order", order);

        mock.assertIsSatisfied();

        String body = mock.getReceivedExchanges().get(0).getIn().getBody(String.class);
        assertTrue(body.contains("WAREHOUSE-MOCK"));
    }
}

The Mockito stub replaces the real InventoryService with a predictable response. The test verifies that the route correctly calls the service and includes the enrichment data in the output. This is useful for services that are expensive, slow, or unavailable in the test environment.

Integration testing with Citrus

Citrus integration tests operate at a different level. They start real infrastructure, send messages through real Kafka brokers, and validate the output on real topics. Here is what the filter route test looks like with Citrus (from the Routing Fundamentals example):

@QuarkusTest
@CitrusSupport
class EipTests implements EipTestSupport {

    @CitrusResource
    TestCaseRunner t;

    @Inject
    @BindToRegistry
    CamelContext camelContext;

    @Nested
    class MessageFilterTest {

        @Test
        public void shouldPassHighValueOrder() {
            t.given(
                createVariables()
                    .variable("id", "citrus:randomNumber(4)")
                    .variable("amount", 250.00)
                    .variable("country", "US")
                    .variable("hazmat", false)
            );

            t.given(waitForCamelRouteStarted("message-filter", camelContext));

            t.when(
                send()
                    .endpoint("kafka:eip.orders.placed")
                    .message()
                    .body(Resources.create("templates/order.json"))
                    .header("kafka.KEY", "org.citrusframework:citrus-website:pom:1.1.0")
            );

            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() {
            t.given(
                createVariables()
                    .variable("id", "citrus:randomNumber(4)")
                    .variable("amount", 50.00)
                    .variable("country", "US")
                    .variable("hazmat", false)
            );

            t.given(waitForCamelRouteStarted("message-filter", camelContext));

            t.when(
                send()
                    .endpoint("kafka:eip.orders.placed")
                    .message()
                    .body(Resources.create("templates/order.json"))
                    .header("kafka.KEY", "org.citrusframework:citrus-website:pom:1.1.0")
            );

            t.then(
                expectTimeout()
                    .endpoint("kafka:eip.orders.high-value?consumerGroup=citrus-filter-reject-group")
                    .timeout(5000)
            );
        }
    }
}

The Citrus test sends a JSON message to the real eip.orders.placed Kafka topic. The Camel route — running inside the Quarkus application — consumes from that topic, applies the filter, and publishes to eip.orders.high-value. The test then reads from eip.orders.high-value and validates the entire message body against the template.

The negative test uses expectTimeout() — it sends a low-value order and verifies that nothing arrives on the output topic within 5 seconds. This is a stronger guarantee than mock.expectedMessageCount(0) because it tests the full pipeline, including Kafka serialization and consumer group behavior.

What Citrus catches that unit tests miss

The Citrus test exercises the entire transport layer. Consider these failure modes:

  • Missing marshal().json() — the route unmarshals JSON into a Java Map for filtering but forgets to marshal it back to JSON before sending to Kafka. The Kafka topic receives {order_id=2001, amount=250.0} instead of valid JSON. The unit test with MockEndpoint passes because it captures the Java object. The Citrus test fails because it reads the actual bytes off the topic and validates them as JSON.

  • Kafka header loss — the route strips or overwrites Kafka headers during processing. The unit test never sees headers because ProducerTemplate doesn’t simulate Kafka header behavior. The Citrus test validates headers explicitly with .header("kafka.KEY", "org.citrusframework:citrus-website:pom:1.1.0").

  • Consumer group misconfiguration — the route uses a consumer group that conflicts with another route, causing messages to be split between consumers. This is invisible in unit tests because there is no real consumer group. Citrus tests with unique consumer groups detect this immediately.

When to use which

Here is a practical decision framework:

Concern Unit Test (AdviceWith) Citrus Integration Test
Routing logic (choice, filter) Yes Yes
Transformation correctness Yes Yes
Bean/service method behavior Yes (with mocks) Yes (with real services)
Serialization format on wire No Yes
Kafka header propagation No Yes
Consumer group behavior No Yes
Multi-hop async flows No Yes
REST endpoint HTTP behavior Partial (via RestAssured) Yes (via HTTP actions)
Execution speed Fast (milliseconds) Slower (seconds, Docker startup)
Infrastructure needed None Docker, Testcontainers
Failure isolation Precise (single route) Broader (full pipeline)

Start with unit tests for every route’s core logic. They run fast, give precise failure messages, and catch most predicate and transformation bugs during development.

Add Citrus integration tests for end-to-end flows that cross transport boundaries. If a message goes from Kafka to your route to another Kafka topic — or from a REST endpoint through internal processing to a database — that flow needs an integration test. The transport layer is where the most painful production bugs hide.

Combining both in a single project

The Testing Strategies example demonstrates this layering of unit and integration tests in one Maven project.

The separation of test categories is not just organizational. Maven’s surefire plugin runs the unit tests (*Test.java) during the test phase, while the failsafe plugin runs the integration tests (*IT.java) during the integration-test phase. This means mvn test gives fast feedback during development, and mvn verify runs the full suite including integration tests.

The IntegrationTestProfile is critical for the Quarkus integration tests. It disables Kafka Dev Services (since the integration tests manage their own infrastructure or use AdviceWith to bypass Kafka) and sets the payment gateway to mock mode:

public class IntegrationTestProfile implements QuarkusTestProfile {

    @Override
    public Map<String, String> getConfigOverrides() {
        return Map.of(
            "payment.gateway.mode", "mock",
            "quarkus.kafka.devservices.enabled", "false",
            "quarkus.http.test-port", "0"
        );
    }
}

Designing routes for testability at both levels

The OrderFilterRoute shown earlier illustrates an important design principle: separate the transport layer from the business logic. The core filter logic lives in direct:filter-order — a direct: endpoint that can be called from a unit test without any transport infrastructure. The Kafka consumer is a thin adapter that delegates to the same direct: endpoint.

This pattern makes the route testable at both levels:

  • Unit test: call direct:filter-order with ProducerTemplate, assert with MockEndpoint — no Kafka needed.
  • Integration test: send to the real Kafka topic, the Kafka consumer route calls direct:filter-order, and the output appears on the output topic.

Both tests exercise the same filter predicate, but they do so through different entry points. The unit test is fast and precise. The integration test is slower but covers the full transport chain.

Where to find the examples

The complete source code for all tests shown in this post is available in the EIP with Camel repository:

Use unit tests for fast iteration on routing logic. Add Citrus integration tests for end-to-end confidence across transport boundaries. Together they cover the full spectrum — from predicate correctness to wire-format fidelity.

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