Integration testing Java message queues with embedded brokers

Integration testing for Java message queues should prove that a producer can place a correctly shaped message on a broker and that the intended consumer receives it. An embedded ActiveMQ or Artemis instance exercises real JMS routing, serialization, acknowledgement and, where relevant, redelivery behaviour. It catches failures that a mocked MessageTemplate cannot: incompatible property names, destination mistakes, transaction scope errors and client-library incompatibilities.

Embedded brokers are especially useful for fast backend test suites in Australian Java shops, particularly the finance, ecommerce and logistics teams concentrated around Sydney and Melbourne. They keep infrastructure on the developer machine or CI worker while still giving each test a genuine broker implementation. This article focuses on writing integration tests for Java message queues with embedded ActiveMQ or Artemis, including isolation, round trips, failure paths and asynchronous waits.

Choosing the right integration boundary

An embedded broker sits between a unit test and a full deployment test. The application still creates a real producer and consumer, and the broker performs the work, but persistence, clustering, TLS and network partitions remain outside the test boundary. That boundary is valuable when the goal is to verify the application-to-broker contract quickly. Use Testcontainers or a managed environment when those operational characteristics are the behaviour under examination.

Choose the embedded implementation that matches the application rather than the one that seems newer. ActiveMQ Classic fits many services built around the javax.jms namespace, while modern Artemis releases commonly use jakarta.jms. The body of a JMS test is often similar, but package names, client factories, dependency versions and administration APIs differ. Keep the broker and client versions aligned with the service and avoid testing a broker combination that production never runs.

Before writing the fixture, identify the queue or topic, expected delivery guarantee, acknowledgement mode, retry policy and message schema. Those details determine whether a passing receive test is meaningful. A test that sends one string and reads it back may still miss a selector mismatch or a payload field required by the consumer.

Building an isolated embedded broker

Each test class should own a clean broker lifecycle. Disable persistent storage and JMX for an in-memory fixture, bind only to loopback, and use an ephemeral connector so parallel builds do not fight over port 61616. Start the broker before creating clients and stop it in a finally block or JUnit teardown. Give every test a unique destination so a timed-out message cannot contaminate the next case.

BrokerService broker = new BrokerService();
broker.setPersistent(false);
broker.setUseJmx(false);

Connector connector = broker.addConnector("tcp://127.0.0.1:0");
broker.start();

String brokerUrl = connector.getConnectUri();
ActiveMQConnectionFactory factory =
    new ActiveMQConnectionFactory(brokerUrl);

The connector exposes the actual loopback port after startup, avoiding both fixed-port clashes and a race between reserving a port and opening it. Keep the BrokerService reference so teardown can call stop() and wait. If JUnit 5 manages the fixture, create it in a static @BeforeAll method and close it in @AfterAll; otherwise use try-with-resources around the surrounding test suite.

Artemis follows the same lifecycle with an EmbeddedActiveMQ server and a ConfigurationImpl. Disable persistence and security for local tests, direct journal and binding data to target directories when Artemis still requires them, then obtain a ServerLocator from the running server rather than launching a second broker. With Jakarta builds, use the Artemis Jakarta client and server artifacts together so the namespace and protocol versions stay compatible.

Verifying a complete message round trip

The core test should create the consumer before publishing, send a realistic payload, receive with a bounded timeout and assert the complete contract. Creating the consumer first prevents a fast producer from racing a subscription. A receive timeout turns a missing delivery into a clear failure instead of hanging the build.

Connection connection = factory.createConnection();
connection.start();

Session session = connection.createSession(
    false, Session.AUTO_ACKNOWLEDGE);
Queue queue = session.createQueue("orders." + UUID.randomUUID());

MessageConsumer consumer = session.createConsumer(queue);
MessageProducer producer = session.createProducer(queue);
producer.setStringProperty("orderId", "AU-1042");
producer.send(session.createTextMessage("accepted"));

TextMessage received = (TextMessage) consumer.receive(5_000);
assertNotNull(received);
assertEquals("accepted", received.getText());
assertEquals("AU-1042", received.getStringProperty("orderId"));

Close the connection after the assertions so the broker can release resources and the test suite remains repeatable. Use try-with-resources where the client API supports it. Five seconds is a ceiling, not a required delay: a healthy broker normally returns immediately, while a timeout exposes routing, selector or listener-registration problems.

Assert the fields the consumer actually depends on, including correlation identifiers, typed properties, headers, body format and destination. Avoid checking generated identifiers or timestamps for exact equality. For JSON payloads, deserialize into the production DTO and compare meaningful values, while retaining a small raw-message assertion if character encoding or broker metadata matters.

Exercising failure and delivery semantics

Delivery guarantees deserve separate tests from the happy path. Configure the broker to move poison messages to a dead-letter destination, consume without acknowledgement and force a redelivery. Assert the delivery count and the final dead-letter contents rather than sleeping for an arbitrary number of seconds. ActiveMQ Classic commonly expresses this through a queue dead-letter policy; Artemis uses dead-letter addresses and maximum delivery attempts on its configuration.

Transactions need explicit checks too. In a transacted producer session, rolling back should leave no consumable message. In a transacted consumer session, rollback should return the message for redelivery, and commit should remove it. These cases catch an easy mistake in which code uses AUTO_ACKNOWLEDGE settings on a session created with Session.SESSION_TRANSACTED, or acknowledges before validation has succeeded.

Make negative tests deterministic by controlling the consumer rather than simulating a network failure with timing. A test-only listener can throw once, record the attempt and then acknowledge the second delivery. That reproduces the retry decision without destabilising unrelated tests. A bounded polling utility such as Awaitility is suitable for observing broker state, provided its timeout is short and the failure message identifies the missing condition.

Testing asynchronous business flows

Asynchronous tests need a completion signal. A JMS listener may run on a broker delivery thread, so the test thread should wait on a CountDownLatch with a timeout and capture any exception in an AtomicReference. Sleeping for a fixed interval is slower and hides intermittent failures. After the latch opens, assert the captured error is null before checking the received payload.

Also test broker routing rules directly: competing consumers should divide messages from one queue, selectors should deliver only matching properties, and a topic subscription should receive a copy rather than compete for it. These are separate from business processing tests. Keeping them focused makes a failure reveal whether the problem is broker behaviour, client configuration or application logic.

Time-based scenarios need one canonical representation. Teams in Sydney and Melbourne encounter daylight-saving changes between AEST and AEDT, while Brisbane remains on AEST throughout the year. Store scheduled delivery instants in UTC, assert the broker receives the intended epoch, and separately test the Australian local conversion. For an Australian retailer or logistics service, use synthetic order, payment and shipping events rather than copying customer records into the fixture.

Running broker tests in continuous delivery

Continuous delivery changes the emphasis from whether the broker can run to whether it can run repeatedly. Keep embedded broker tests in the integration-test phase, use unique destinations, fail fast on port binding and expose the broker version in test diagnostics. A dedicated Maven Failsafe execution for classes ending in IT is one practical split from fast unit tests. Container-based brokers are a better fit when the test must cover TLS, persistence or a multi-node topology.

Data residency makes local execution attractive in Australia. Many organisations restrict test workloads or sensitive payloads to approved Australian regions, including AWS Sydney in ap-southeast-2 and Azure Australia East. An embedded broker sends no traffic outside the worker, provided the test uses synthetic data and local storage. A cloud-hosted broker remains appropriate for deployment smoke tests, but it adds latency, credentials and possible regional failover behaviour to every run.

Keep the fixture small enough to diagnose. A test report should show the destination, expected outcome and timeout, while the build log records the ActiveMQ or Artemis version, client version and relevant queue settings. The next concrete step is to add one isolated broker round-trip test to an existing Java service, run it on the CI worker, and record the broker and client versions with the build result.