Testing Java message-driven beans in an embedded EJB container
Message-driven beans turn asynchronous JMS events into application work, but the risky behaviour sits between several components: a broker accepts a message, the EJB runtime invokes the listener, container-managed resources are injected, and a transaction commits or rolls back. Calling onMessage directly may prove that a method works while leaving deployment, acknowledgement, redelivery and transaction behaviour untested.
Writing tests for Java message-driven beans with embedded EJB container support gives the listener real lifecycle callbacks, JNDI resources, security context and transaction boundaries without provisioning a separate server. This middle ground is valuable to Australian delivery teams, where groups in Sydney, Melbourne, Brisbane and Perth often support Java platforms for banks, insurers, telecoms and logistics operators. A repeatable local test harness also reduces hand-off friction in long-lived enterprise support arrangements.
Build an isolated embedded environment
Choose a maintained Jakarta EE implementation that supports both EJB and Jakarta Messaging in one embedded runtime. TomEE is a practical option for many Maven projects, while another supported container may suit an existing platform standard. Align the Jakarta Messaging, EJB and CDI versions; mixing a javax.* test harness with a Jakarta deployment produces misleading linkage errors. Keep production dependency versions in a separate Maven profile so a test does not silently upgrade runtime behaviour.
The listener can stay small while the container owns the difficult wiring:
@MessageDriven(
name = "InventoryUpdateHandler",
activationConfig = {
@ActivationConfigProperty(
propertyName = "destinationType",
propertyValue = "jakarta.jms.Queue"),
@ActivationConfigProperty(
propertyName = "destinationName",
propertyValue = "inventory.events"),
@ActivationConfigProperty(
propertyName = "acknowledgeMode",
propertyValue = "Auto")
})
public class InventoryUpdateHandler
implements MessageListener<InventoryEvent> {
@EJB
private StockService stockService;
@Override
public void onMessage(Message<InventoryEvent> message) {
try {
stockService.apply(message.getBody());
} catch (JMSException ex) {
throw new EJBException(ex);
}
}
}
The annotation binds the class to a queue, while the injected EJB performs the stock change. Configure the destination, selectors, acknowledgement mode, durable subscription and dead-letter behaviour in the embedded runtime. Some containers deploy an exploded bean archive only after explicit registration, so verify that the listener is discoverable instead of assuming classpath scanning found it. The JMS provider must also be real: a mocked ConnectionFactory cannot reveal broker conversion errors, duplicate delivery or transaction enlistment.
Give the suite deterministic resources. Use a unique destination per test class, fixed broker storage settings and a controlled clock, then expose the connection factory, destination and UserTransaction through JNDI. Container APIs differ, but the lifecycle should follow the same sequence: boot, deploy, look up resources, execute, close consumers, roll back open work and shut down. Starting one container for a suite is usually faster than restarting it per method, while isolated destinations prevent state from leaking between tests.
Verify delivery, transactions and failure paths
A useful test publishes a real message, waits for the MDB to consume it, then asserts the business outcome rather than the listener method’s return value. Capture the relevant state in a test database, an in-memory repository or a test double behind the EJB boundary. Use Awaitility or the container’s delivery hook to avoid arbitrary sleeps, and set a short, explicit timeout so a broken subscription fails quickly.
try (JMSContext context = connectionFactory.createContext()) {
context.createProducer().send(destination, event);
}
await().atMost(Duration.ofSeconds(2)).untilAsserted(() ->
assertThat(stockRepository.quantityFor("SKU-42")).isEqualTo(7));
Transaction tests should answer specific questions. Does the handler commit when the database update succeeds? Does a business exception trigger rollback, redelivery and eventual dead-letter routing? A local JMS transaction cannot provide one atomic outcome across the broker and database unless the resources support JTA and, where required, XA. Test the transaction manager actually present in the container; do not infer atomicity from a @Transactional annotation on a helper. For at-least-once delivery, make the business operation idempotent and verify that a repeated event does not duplicate stock or payment records.
Exercise selectors and timing as separate cases, because a handler can work with every message yet receive none when the broker applies a different selector. Send competing events to prove ordering assumptions, and force a transient downstream failure to observe retry limits. Timestamps deserve special care for Australian systems: daylight-saving changes in Sydney and Melbourne can expose code that stores local time instead of an instant or zone. Assert in UTC where that is the contract, while using Australia/Sydney or Australia/Perth explicitly when business rules depend on local civil time.
Compare test boundaries for faster feedback
Not every check needs the embedded container. Separate fast business-rule tests from component tests that exercise EJB injection, JMS delivery and transaction handling. The right boundary depends on the confidence required at that stage of delivery, not on a desire to make every test look like an integration test. A small embedded suite can expose wiring errors early, while a release pipeline can add a production-like server and broker check.
The main boundaries compare as follows:
| Boundary | What executes | Feedback | Main limitation |
|---|---|---|---|
| Plain unit test with mocks | Handler logic and selected collaborators | Very fast | Container resources and broker behaviour stay synthetic |
| Embedded EJB container | MDB lifecycle, EJB injection, JNDI, JMS and transactions | Fast enough for routine builds | Startup, setup and cleanup add complexity |
| Production-like server test | Deployed application, broker and database | Highest environmental confidence | Slower and harder to reproduce |
Checks for each commit
- Test event mapping and business rules with plain JUnit.
- Verify handler delegation through a narrow business interface.
- Keep deployment-specific code behind a small adapter.
- Reuse deterministic sample events and builders.
Checks for a release build
- Boot the embedded container with the real message destination.
- Publish and consume a genuine JMS message.
- Force a rollback and verify redelivery or dead-letter handling.
- Confirm audit records and downstream state after acknowledgement.
A practical split keeps most feedback within seconds while reserving container-backed coverage for behaviour that mocks cannot prove. Put the embedded execution in a dedicated Maven phase or CI job so unit-test queues are not blocked by container startup. Keep each test focused on one observable outcome, but retain one end-to-end case that crosses producer, broker, listener and persistence. That combination catches both local logic errors and the wiring gaps that appear only after deployment.
Automate the lifecycle in builds and debugging
Wire embedded tests into Maven with Surefire for fast tests and Failsafe, or an equivalent integration-test phase, for container-backed specifications. Pin the JDK, container and broker versions used by the suite, disable sleeps and automatic port reuse, and archive container logs with the test report. If the embedded runtime creates files, direct them into the build directory and clean them between executions. Parallel execution is safe only when each test has a unique destination, database schema and transaction names.
When a message is not consumed, inspect the broker and runtime logs before changing the handler. Check the deployment log for a missing bean, the destination name and selector for a mismatch, and the consumer count for an inactive subscription. For host-level checks of ports, processes and permissions, the Linux command reference provides useful command patterns without forcing the test suite to depend on a particular workstation. Container logs should remain the primary source because they show the application server’s view of the failure.
CI can run the same suite on Linux, Windows or macOS, but one canonical worker image prevents environmental drift. Australian teams may hand work between Sydney and Perth or an offshore delivery partner, so publish the container version, database seed, environment variables and expected log locations with every run. A container image is often a better boundary than a developer workstation, especially for organisations maintaining Java EE estates under local vendor-support agreements. Save the broker state and failed-event payload as build artefacts so a failure can be reproduced without first recreating the message.
Finish with durable assertions and one concrete next step
Durable assertions describe behaviour that a future refactor should preserve: an event creates one order record, an invalid payload reaches the dead-letter destination, or a rolled-back transaction leaves no stock adjustment. Avoid asserting private-field values, container-generated identifiers or exact log text. Give each event a correlation identifier and assert relationships through that identifier. When the handler calls a framework that relies on reflection, keep framework-specific adaptation in a separate class; a focused approach to testing reflection frameworks can verify that boundary without starting the whole EJB runtime for every rule.
Treat payloads as contracts, and redact secrets before they reach a test broker or stored build artefact. Australian organisations handling personal information should also check payloads against the Australian Privacy Act 1988, the Australian Privacy Principles and any applicable Notifiable Data Breaches obligations. These checks do not belong inside every message assertion, but a separate contract test can verify that prohibited fields are absent. A stable event schema then gives teams in Melbourne and offshore developers the same expected input without exposing production customer data.
The next concrete step is to add one failing-to-passing example to the repository: boot the chosen embedded container, publish a single inventory event, await the stock update, assert the audit record, and verify that the destination is drained after acknowledgement.