Joel Holder

Math, coding, philosophy, art, and other things that interest me

View on GitHub
13 September 2015

Testing Integration Routes as Software Design

by Joel Holder

Integration code is often described as glue, but that phrase undersells the work. A route that receives a payload, validates it, transforms it, calls another service, publishes a message, and records an outcome is application logic. It has behavior. It has failure modes. It deserves tests that are as deliberate as the tests around any domain service.

The philosophy is simple: design integration routes as explicit boundaries, then test those boundaries before the real infrastructure becomes the only way to learn whether the design works. A good integration test does not prove that the whole distributed system is healthy. It proves that this route honors its contract when collaborators behave in known ways.

This article uses Apache Camel, Spring, and JUnit examples, but the same ideas apply to message brokers, HTTP APIs, ETL jobs, serverless workflows, and event-driven services in general.

Treat integration routes as designed boundaries

An integration route is a boundary between systems. It usually answers questions like:

Those questions are testable. They also make the design sharper. If a route cannot be tested without booting a queue broker, a database, three vendor APIs, and a scheduler, the route probably has too much environmental knowledge baked into it.

Design for testability before writing the route

Engineers who already practice test-driven development know the loop: specify behavior, implement the minimum code to satisfy it, refactor while protected by tests. Integration work benefits from the same discipline, but the unit under test is often a pipeline rather than a method.

For routes, the design-for-testability version of that loop looks like this:

  1. Define the contract: input, output, headers, side effects, and error behavior.
  2. Keep transformation logic in small services or processors that can be tested without infrastructure.
  3. Inject endpoints, clients, and configuration instead of hard-coding them.
  4. Build route tests that replace real endpoints with mocks, stubs, or in-memory components.
  5. Run those tests in CI so route behavior is protected as dependencies change.

The goal is not to mock everything forever. The goal is to put fast, deterministic tests around route behavior so the slower environment tests can focus on wiring, credentials, schema drift, and deployment reality.

Separate orchestration from transformation

Routes should orchestrate. They should decide where messages come from, what processors run, where the result goes, and how errors are handled. They should not hide complex parsing, validation, enrichment, or business rules directly inside route definitions.

A small processor or service is easy to test in isolation:

XmlProcessingService xmlProcessingService =
    new XmlProcessingService();

ProcessingResult result =
    xmlProcessingService.processTransaction(sampleXml);

assertTrue(result.isAccepted());
assertEquals("SUCCESS", result.status());

That style of test gives precise feedback when the transformation is wrong. The route test can then focus on whether the processor is called at the right point and whether the resulting exchange is sent to the correct endpoint.

Use dependency injection to control lifecycles

Object lifecycle matters in integration tests because routes often hold references to processors, clients, connection factories, serializers, and retry policies. If those objects are created implicitly, the test has fewer ways to replace them.

In Spring, keeping processors and clients as managed beans gives tests an obvious replacement point:

<beans
    xmlns="http://www.springframework.org/schema/beans"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="
        http://www.springframework.org/schema/beans
        http://www.springframework.org/schema/beans/spring-beans.xsd">

    <bean
        id="xmlProcessingService"
        class="com.mycompany.integration.project.services.XmlProcessingService" />

</beans>

The test context can import the production route while replacing only the collaborators that should not be real during the test run.

Example application structure

The sample project integrates customer and order XML payloads and maps them into model classes generated from an XSD.

XSD schema used to generate model classes

The XML schema defines the data contract used throughout the integration flow.

A maintainable integration project usually separates code by responsibility:

Tests should mirror this structure. That symmetry makes ownership obvious and keeps route tests from becoming the only place where behavior is verified.

Project structure organized by domain role

Production code and test code should follow the same responsibility boundaries.

Spring context files composed as bounded contexts

Spring context files can be organized by bounded context so tests import only what they need.

Test transformations with plain unit tests

Start with the parts that do not require Camel at all. XML deserialization, JSON mapping, enrichment, validation, idempotency key generation, and status mapping should have ordinary unit tests.

package com.mycompany.integration.project.tests.utils;

import org.junit.Test;

import com.mycompany.integration.project.models.CustomersOrders;
import com.mycompany.integration.project.utils.FileUtils;
import com.mycompany.integration.project.utils.ModelBuilder;

import static org.junit.Assert.assertNotNull;

public class ModelBuilderTests {

    private final String xmlFilePath =
        "src/exemplar/CustomersOrders-v.1.0.0.xml";

    @Test
    public void deserializes_customer_order_payload() throws Exception {
        String xml = FileUtils.getFileString(xmlFilePath);

        CustomersOrders payload = CustomersOrders.class.cast(
            ModelBuilder.deserialize(xml, CustomersOrders.class));

        assertNotNull(payload);
    }
}

These tests are cheap and specific. They should fail because transformation behavior changed, not because a broker is down or a port is already in use.

Test domain services with a narrow Spring context

When a service depends on Spring-managed collaborators, load the smallest context that gives the service its real dependencies. Keep the context narrow enough that the test still explains what behavior it owns.

package com.mycompany.integration.project.tests.services;

import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;

import com.mycompany.integration.project.services.XmlProcessingService;
import com.mycompany.integration.project.utils.FileUtils;

import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(locations = {
    "classpath:META-INF/spring/domain.xml"
})
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
public class XmlProcessingServiceTests {

    private final String xmlFilePath =
        "src/exemplar/CustomersOrders-v.1.0.0.xml";

    @Autowired
    private XmlProcessingService xmlProcessingService;

    @Test
    public void injects_service() {
        assertNotNull(xmlProcessingService);
    }

    @Test
    public void processes_an_xml_transaction() throws Exception {
        String xml = FileUtils.getFileString(xmlFilePath);

        Boolean result = xmlProcessingService.processTransaction(xml);

        assertTrue(result);
    }
}

This level verifies the Spring composition and the service behavior without making a route test responsible for every detail.

Test routes by replacing infrastructure

For a route test, the route is the system under test. The test should usually replace external infrastructure while preserving the route logic itself. In Camel, that often means importing the production route and swapping real endpoints for mock or direct endpoints.

<beans
    xmlns="http://www.springframework.org/schema/beans"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="
        http://www.springframework.org/schema/beans
        http://www.springframework.org/schema/beans/spring-beans.xsd
        http://camel.apache.org/schema/spring
        http://camel.apache.org/schema/spring/camel-spring.xsd">

    <import resource="classpath:META-INF/spring/camel-context.xml" />

    <bean
        id="mockAllEndpoints"
        class="org.apache.camel.impl.InterceptSendToMockEndpointStrategy" />

    <bean
        id="activemq"
        class="org.apache.camel.component.direct.DirectComponent" />

</beans>

That test context gives the route a real Camel runtime but avoids the cost and nondeterminism of a live broker. The result is still an integration test, but it is an integration test at the route boundary rather than at the entire environment boundary.

Example route under test

<camelContext
    id="customers_and_orders_processing"
    xmlns="http://camel.apache.org/schema/spring">

    <route id="process_messages_as_models">
        <from uri="file:src/data1" />
        <process
            ref="customersOrdersModelProcessor"
            id="process_as_model" />
        <to uri="file:target/output1" />
    </route>

    <route id="process_messages_as_xml">
        <from uri="file:src/data2" />
        <process
            ref="customersOrdersXmlDocumentProcessor"
            id="process_as_xml" />
        <to uri="file:target/output2" />
    </route>

    <route id="process_http_messages_as_xml">
        <from
            uri="jetty:http://0.0.0.0:8888/myapp/myservice/?sessionSupport=true" />
        <process
            ref="customersOrdersXmlDocumentProcessor"
            id="process_http_input_as_xml" />
        <to uri="file:target/output3" />
        <transform>
            <simple>OK</simple>
        </transform>
    </route>

</camelContext>

Route IDs should describe behavior, not implementation trivia. A route named process_http_messages_as_xml is easier to connect to a test failure than a route named route3. A useful convention is to align route IDs with test names or with the business behavior asserted by the test.

Mock endpoints, not the route

The most useful route tests keep the route real and mock the edges. In Camel, endpoint interception creates a predictable mock endpoint for each endpoint URI:

file:target/output1 becomes mock:file:target/output1

The test can then assert what the route emitted:

MockEndpoint output =
    getMockEndpoint("mock:file:target/output1");

output.expectedMessageCount(1);
output.expectedBodiesReceived(expectedPayload);
output.expectedHeaderReceived("status", "SUCCESS");

template.sendBody("direct:start", inputPayload);

assertMockEndpointsSatisfied();

Good assertions cover the contract, not incidental internals. Assert the body, headers, message count, destination, and error outcome. Avoid asserting every private implementation step unless the step is part of the contract.

Example Camel test assertion failure output

Expectation-driven failures are precise, which reduces debugging time.

Mock HTTP and vendor endpoints deliberately

Not every dependency is a Camel endpoint. Many routes call HTTP services, vendor APIs, identity providers, object stores, or internal microservices. Those dependencies need their own replacement strategy.

A local HTTP mock lets a test exercise real failure behavior without depending on the real service:

mockServer.stubFor(post(urlEqualTo("/orders"))
    .withHeader("Content-Type", containing("application/json"))
    .willReturn(aResponse()
        .withStatus(202)
        .withHeader("Content-Type", "application/json")
        .withBody("{\"status\":\"accepted\"}")));

template.sendBody("direct:submitOrder", orderJson);

mockServer.verify(postRequestedFor(urlEqualTo("/orders")));

This kind of test is especially valuable for retries, timeout handling, idempotency keys, authentication headers, and non-200 responses. Those are exactly the integration behaviors that tend to break late if they are only tested manually.

Test the error paths as first-class behavior

Happy-path route tests are necessary but insufficient. Integration code spends much of its life dealing with imperfect collaborators. Tests should cover the behavior you expect when dependencies fail.

If the route has an error handler, dead-letter channel, retry policy, or compensating action, treat that as product behavior and test it explicitly.

Keep the test pyramid honest

Integration-heavy systems still need a test pyramid. The shape is just different:

The mistake is pushing every concern into environment tests. Those tests are valuable, but they are slower, more expensive, and less precise. Route-level tests give fast feedback about behavior before deployment becomes the debugging tool.

What to avoid

Why this approach pays off

These are not Camel-specific ideas. They are software design habits applied to integration architecture: isolate boundaries, name contracts, replace nondeterministic dependencies, and verify behavior continuously.

Conclusion

Testable routing is not mainly about test syntax. It is about the shape of the system. A route with explicit contracts, injectable collaborators, narrow processors, and mockable endpoints is easier to reason about and safer to change.

The practical rule is this: keep the route real, control the boundaries, and assert the contract. When integration code is treated as designed software instead of incidental glue, tests become a tool for architecture rather than a tax paid after implementation.

The original sample project is available here:

https://github.com/jclosure/integration-project

tags: BDD - DDD - integration - java - TDD