CDI Unit Testing Without an Application Server: WeldInitiator in Practice

Smart Summary

CDI unit testing has a reputation for being impossible without an application server. It isn’t: WeldInitiator boots a real Weld SE container inside your test JVM in milliseconds, so a CDI managed bean can be resolved, injected, and exercised for real. This post shows the pattern, the tests it genuinely verifies, and the two container services it does not provide — the part most write-ups get wrong.

In this post you will learn:

  • How @EnableWeld and @WeldSetup boot a scoped Weld SE container per test method
  • Why @Transactional and @PersistenceContext are not honoured in a plain Weld SE test, and what that means for what you can assert
  • How to combine WeldInitiator with Mockito to test business logic without a database
  • Why registering a mock bean with .addBeans() is a stronger pattern than reflecting into a private field
  • Where the boundary sits between CDI unit tests and deployed integration tests on Azul Payara Micro

Note: This post is part one in a three-part series. Part two will cover the Testcontainers layer in detail, and part three will address all three tools: WeldInitiator, Arquillian, and Testcontainers.

How many of your Jakarta EE service classes have no unit tests at all? For a lot of teams the honest answer is “the interesting ones” — the CDI managed beans that were never designed to be instantiated with new. They lean on the container for dependency injection, scoping, interceptors, and proxying. Strip the container away and you are choosing between two bad options: rewrite the class to be container-agnostic just so it is testable, or skip unit tests entirely and push everything down into slower, full-stack integration tests.

There is a third option, and by the end of this post you will know how to use it, what it actually proves, and — just as important — the two things it does not do that a lot of write-ups claim it does.

What WeldInitiator actually does

WeldInitiator is a JUnit 5 extension shipped in the weld-junit5 module of the Weld project, the reference implementation of Contexts and Dependency Injection (CDI). It boots a real Weld SE container inside your test JVM: no application server, no Docker, no deployment descriptor, no classpath-wide bean discovery.

Annotate the test class with @EnableWeld and the extension takes over the container lifecycle. Declare a field annotated with @WeldSetup and initialised via WeldInitiator.from(…), and you tell Weld exactly which classes to bring into the container for this test, rather than scanning the whole classpath the way a deployed application would. Weld starts before each test method, resolves and injects the bean’s CDI dependencies, and shuts down afterwards. The whole cycle runs in milliseconds because it never leaves the JVM.

That catches a class of defect plain JUnit and a hand-rolled new MyService() cannot see: CDI wiring problems. If a bean’s scope is wrong, if an @Inject point cannot be satisfied, if an interceptor binding is enabled but misconfigured, the test fails for the same reason production would fail — because it is going through the same dependency injection machinery.

What it does not do

This is the part worth being precise about, because it changes what your assertions are worth.

A Weld SE container is a CDI container, not a Jakarta EE container. It provides bean discovery, injection, scopes, events, and interceptors. It does not provide the platform services an application server layers on top of CDI. Two of those matter constantly in service-layer code:

  • @Transactional is not applied. The transaction interceptors behind jakarta.transaction.Transactional are supplied by the runtime’s JTA integration — in Weld’s case through the TransactionServices SPI — not by Weld itself. In a plain WeldInitiator test there is no transaction manager and no transactional interceptor, so @Transactional(REQUIRED) is inert. Your test still passes; it simply is not testing a transaction boundary. Asserting rollback behaviour needs either a registered interceptor stub or a real deployed runtime.
  • @PersistenceContext is not injected. Injecting an EntityManager is a Jakarta Persistence service performed by the container, not a CDI @Inject point. Weld ignores the annotation entirely — which is also why it does not fail deployment: there is no unsatisfied dependency, just a field that stays null.

There is a related limit on the “it catches wiring regressions” claim. It does — but only for @Inject points, and only for beans inside the set you handed to WeldInitiator.from(…). Scoping the container to a single class keeps the test fast and the failure surface narrow; it also means the container is not checking the rest of your graph.

And the usual boundary still applies: anything needing a real persistence provider, a real HTTP layer, or a real browser belongs to integration testing. Keeping that line clean is exactly what keeps the unit layer fast enough to run on every save.

The tests it is genuinely good for

  • Fast, isolated unit tests for CDI managed service classes — let Weld do the real injection of the bean under test, and replace expensive collaborators (a remote client, a messaging connection, an EntityManager) with Mockito mocks, so you can assert on business logic with no database and no network.
  • Regression tests for the injection graph, within the scope you declare: WeldInitiator.from(SomeBean.class).build() fails fast if the bean can no longer be resolved or its @Inject points break.
  • Tests of interceptor-driven behaviour that you enable yourself — a custom interceptor binding activated via @Priority or a test beans.xml. Weld applies those for real. Container-supplied interceptors such as @Transactional are the exception noted above.
  • Alternative-bean and mock-bean scenarios, using .addBeans(…) to register CDI alternatives or mock beans directly in the container. This is the cleaner tool wherever reflection-based field swapping would otherwise be needed.

Demonstration: the service tests in testcontainers-example

The testcontainers-example project is a small Jakarta EE library management system — books, patrons, librarians, loans — whose unit test layer (BookServiceTest, PatronServiceTest, LibrarianServiceTest, LoanServiceTest) is built entirely on weld-junit5. The dependency in pom.xml:

Two numbers in that snippet are easy to misread. The 5 in weld-junit5 is the JUnit version, not the Weld version, and the artifact’s own 5.0.x line supports Weld 6.0, which implements CDI 4.1 for Jakarta EE 11 — the platform this application targets, which is why every annotation below is in the jakarta.* namespace. So 5.0.3.Final is the version that matches an EE 11 project, not an older one.

If you are starting fresh, also know that weld-testing 6.x renamed this artifact to weld-junit-jupiter and moved the package to org.jboss.weld.junit.jupiter, because the “5” referred to the wrong thing: the extension is tied to the Jupiter engine rather than to a JUnit version, and the renamed artifact works with both JUnit 5 and JUnit 6. That 6.x line supports Weld 7 and CDI 5.0, which is ahead of Jakarta EE 11 — so an EE 11 project stays on weld-junit5 5.0.3.Final, as this one does. An OpenRewrite recipe handles the rename when you eventually move.

Every service under test extends a shared generic base, AbstractService<E, P>, which is where the container-facing machinery lives:

BookService itself is a @Dependent-scoped subclass:

And the test:

Four things are worth highlighting.

  • WeldInitiator.from(BookService.class).build() scopes the container to exactly one bean instead of discovering the whole application. That is what keeps the test fast and its failure surface narrow: if this test breaks, the problem is in BookService or its direct CDI dependencies.
  • weld.select(BookService.class).get() retrieves a container-managed instance. BookService is @Dependent, so every call to get() produces a new contextual instance — resolve it once in @BeforeEach, and if you care about clean-up, destroy it in @AfterEach.
  • setUp() must declare throws Exception. getDeclaredField and Field.set throw checked exceptions; without it the test class does not compile.
  • The reflection is there because EntityManager arrives through field injection on a container service (@PersistenceContext), not through a constructor or an @Inject point — so there is no seam in the public API to hand Mockito a mock through. It is a pragmatic compromise: it keeps BookService free of test-only constructors while letting the test substitute the one collaborator it does not want to exercise for real.

From there the test body is ordinary Mockito: stub the EntityManager calls you need (find, merge, or a full CriteriaBuilder / CriteriaQuery / TypedQuery chain for findAll), invoke the service method, and assert on the result or verify the interaction. Note that persist returns void, and a Mockito mock does nothing by default — so doNothing().when(entityManager).persist(…) is redundant and can go. PatronServiceTest, LibrarianServiceTest, and LoanServiceTest repeat the identical shape, which is itself a useful signal: once the pattern exists against AbstractService, adding CDI unit test coverage for the next entity’s service is close to mechanical.

A stronger pattern, where you can change how the collaborator arrives

It is worth being honest about what the version above buys you. Because the EntityManager is swapped in by reflection after Weld has built the bean, that particular test would behave the same with new BookService() and the same reflection. What the container adds is the proof that BookService is resolvable and constructible as a CDI bean — real, but modest.

You get considerably more out of the container by letting it own the collaborator too. Expose the EntityManager as a CDI producer in production code and register a mock for it in the test:

Now the mock would arrive through the same injection path production would use, once EntityManager is exposed via a CDI producer instead of @PersistenceContext — no reflection, no private field name hard-coded into the test, and a change to how BookService obtains its EntityManager would correctly break the test. testcontainers-example does not make that change today; AbstractService still uses @PersistenceContext, so the reflection version above is what actually runs. If refactoring AbstractService is not on the table, reflection is a reasonable holding pattern — but this producer-based approach is the shape to move toward.

Where it fits

In a layered strategy, WeldInitiator owns the bottom of the pyramid. It answers “is my CDI wiring and business logic correct?” in milliseconds, with no Docker and no deployed artifact in sight.

Everything above it needs different tools. Testing that CDI, JTA and persistence work together as deployed means running the actual built WAR on a real runtime — a Testcontainers-managed Azul Payara Micro instance, for example — and browser-level assertions need a Selenium or Playwright layer on top of that. Three layers, three questions, three very different runtimes. The mistake is asking the bottom layer to answer the top layer’s question.

Part two of this series covers that Testcontainers layer in detail, and part three puts all three tools — WeldInitiator, Arquillian, and Testcontainers — side by side.

If your Jakarta EE applications run on Azul Payara Micro or Azul Payara Server, that split is worth setting up deliberately: fast CDI unit tests on every save, deployed integration tests on the runtime you actually ship.

One line to carry into your next test-strategy conversation: a real CDI container in your test JVM costs milliseconds, and it is not the same thing as an application server.

Frequently Asked Questions

Can you unit test CDI beans without starting an application server?

Yes. WeldInitiator, part of the weld-junit5 module of Weld (the CDI reference implementation), starts a real Weld SE container inside the test JVM in milliseconds. Beans are resolved and injected for real, scoped to just the classes the test declares, with no server, no Docker, and no deployment. Deployed behaviour still needs an integration layer — typically the built WAR running on a Jakarta EE runtime such as Azul Payara Micro.

Does @Transactional work in a Weld SE unit test?

No, not on its own. The interceptors behind jakarta.transaction.Transactional come from the runtime’s JTA integration, not from the CDI container, so a plain Weld SE test has no transaction manager and the annotation is inert. Tests will pass without ever exercising a transaction boundary. Real transactional behaviour has to be verified on a deployed runtime such as Azul Payara Micro or Azul Payara Server.

What is the difference between a CDI unit test and a Jakarta EE integration test?

A CDI unit test runs a CDI container only — injection, scopes, events, interceptors you enable yourself — and answers whether a bean’s wiring and business logic are correct. A Jakarta EE integration test runs the real deployed application on a full runtime, so it also covers persistence, transactions, and HTTP. The first takes milliseconds and belongs in every build; the second takes seconds to minutes and typically runs the built WAR against a container image of the production runtime, such as Azul Payara Micro.

How do you mock an injected EntityManager in a CDI test?

The clean way is to register a mock as a CDI bean: WeldInitiator.from(BookService.class).addBeans(MockBean.of(mock(EntityManager.class), EntityManager.class)).build(). The mock then arrives through the same injection path as production code. Where the EntityManager is supplied by @PersistenceContext instead, Weld SE does not inject it at all, and tests commonly fall back to reflection to set the field after the bean is built — workable, but it hard-codes a private field name into the test.

Which Jakarta EE runtimes does this testing approach apply to?

Any of them. WeldInitiator tests only the CDI layer, so they are runtime-independent as long as the test container’s CDI version matches the platform — weld-junit5 5.0.3.Final runs Weld 6.0, which implements CDI 4.1 for Jakarta EE 11. The integration layer above them is runtime-specific: teams deploying to Azul Payara Micro or Azul Payara Server run the built WAR on that runtime in a container so the deployed behaviour is tested on the same platform that ships to production.