Test-time Bean Injection
The juneau-bct module provides a small JUnit 5 extension for swapping production beans with test doubles (mocks/fakes) inside a Juneau BeanStore for the duration of a JUnit 5 test — without modifying production code and without proxying. It's the Juneau-flavored answer to Spring's @TestConfiguration / @MockBean.
The mechanism rests on three pieces:
- The
juneau-bctmodule — containing the JUnit 5 extension and a@TestBeanannotation. - A small overlay-builder,
TestBeanStore, that wraps the existingBasicBeanStoreoverriding-parent slot. - A consistent
overridingBeanStore(BeanStore)setter convention (theBeanStoreOverridable<B>marker interface) on the four user-facing builders that Juneau apps construct:MockRestClient.Builder,Microservice.Builder,SerializerSet.Builder,ParserSet.Builder, andEncoderSet.Builder.
Two wiring patterns are supported: Mode INJECT (construction-time wiring with overrides — the default) and Mode OVERLAY (push/pop overlays on a live SUT). Mode INJECT covers the vast majority of REST and serializer tests; Mode OVERLAY is the opt-in path for tests that hold a long-running Microservice and want to apply per-test overlays without rebuilding it.
Mode INJECT — construction-time wiring with overrides
This is the default and what every @TestBean-based test ships with out of the box. The system under test (SUT) is constructed after the overlay is built; the overlay is threaded into the builder's overridingBeanStore(...) setter, which installs it in the overridingParent slot of the SUT's bean store at construction time.
Because the overlay sits at tier 1 of the bean-store resolution chain (above local @Bean factories and framework-default memoizers), Mode INJECT is universal: every bean type is eligible for replacement, including ones the framework would otherwise pin into a per-op memoizer at boot.
Worked example — MockRestClient + @TestBean
@ExtendWith(JuneauBeanStoreExtension.class)
class MyResourceTest {
@TestBean
ExternalApi mockApi = () -> "test-double";
@Test
void who_returnsTheTestDouble(TestBeanStore store) throws Exception {
try (var client = MockRestClient.builder(MyResource.class)
.overridingBeanStore(store)
.build()) {
try (var resp = client.get("/who").run()) {
assertEquals(200, resp.getStatusCode());
assertTrue(resp.getBodyAsString().contains("test-double"));
}
}
}
// The resource under test — declares a production @Bean ExternalApi
// factory; the @TestBean above shadows it for this test.
@Rest(path = "/api")
public static class MyResource {
@Bean public ExternalApi externalApi() { return () -> "production"; }
@RestGet("/who") public String who(ExternalApi api) { return api.describe(); }
}
public interface ExternalApi {
String describe();
}
}
Three things to notice:
- The extension never reflects on the test class to find
MyResource/MockRestClientreferences. The wiring is explicit — one line:.overridingBeanStore(store). - The
TestBeanStore storeparameter is resolved by the extension'sParameterResolver. The same store can be retrieved viaJuneauBeanStoreExtension.getStore()from@BeforeAll/@BeforeEachbodies if the extension is registered via@RegisterExtensioninstead of@ExtendWith. - The resource's own
@Bean ExternalApi externalApi()factory is not removed — it's just shadowed by the overlay's higher-priority entry. Tests that don't install an overlay continue to see the production factory.
Per-test vs per-class scope
The default scope is Scope.METHOD: each @Test method gets a fresh overlay built from the instance-field / instance-method @TestBean declarations. For overrides that should be shared across all tests in a class, use Scope.CLASS on a static field or static method:
@ExtendWith(JuneauBeanStoreExtension.class)
class MySharedOverlayTest {
@TestBean(scope = Scope.CLASS)
static MyExpensiveService sharedSvc = new MyExpensiveService();
@TestBean
PerTestService perTestSvc = new PerTestService(); // rebuilt every method
@Test void a01_test(TestBeanStore store) { /* sees both overlays */ }
@Test void a02_test(TestBeanStore store) { /* still sees sharedSvc; perTestSvc is fresh */ }
}
Method-scope overrides are chained on top of class-scope ones, so a method-scope override of (Type, name) shadows a class-scope override of the same pair, while the class-scope's other entries continue to be visible.
A non-static field annotated with @TestBean(scope = Scope.CLASS) is rejected at beforeAll time with a clear error — instance state isn't available before any test method runs.
Named-bean qualifier
@TestBean(name = "...") distinguishes two overrides of the same type. This matches the framework's existing @Bean(name = "...") parameter resolution:
@TestBean(name = "primary")
MyService primary = new InMemoryMyService("p");
@TestBean(name = "secondary")
MyService secondary = new InMemoryMyService("s");
@Test void each_resolves_to_its_qualifier(TestBeanStore store) {
assertSame(primary, store.getBean(MyService.class, "primary").orElseThrow());
assertSame(secondary, store.getBean(MyService.class, "secondary").orElseThrow());
}
A resource consuming named beans via @Bean(name = "primary") constructor parameters sees the overlay-supplied values once the SUT is built with .overridingBeanStore(store).
Explicit-type override
The annotation's type() member lets a test declare a factory whose return type is a supertype (or even Object) of the intended registration type:
@TestBean(type = Greeter.class)
HelloGreeter typedGreeter = new HelloGreeter();
@Test void d01_typeOverride(TestBeanStore store) {
assertSame(typedGreeter, store.getBean(Greeter.class).orElseThrow());
assertTrue(store.getBean(HelloGreeter.class).isEmpty()); // registered under Greeter only
}
Beyond REST — Microservice, serializers, parsers, encoders
The same overridingBeanStore(BeanStore) setter is available on every Juneau builder that exposes a bean store via the BeanStoreOverridable<B> interface:
| Builder | Setter |
|---|---|
MockRestClient.Builder | .overridingBeanStore(BeanStore) |
Microservice.Builder | .overridingBeanStore(BeanStore) |
SerializerSet.Builder | .overridingBeanStore(BeanStore) |
ParserSet.Builder | .overridingBeanStore(BeanStore) |
EncoderSet.Builder | .overridingBeanStore(BeanStore) |
The wiring is identical across all of them — build a TestBeanStore, declare @TestBean overrides, hand the store to the builder. The extension's getStore() accessor returns the right store for the current scope (method-scope if a @Test method is executing, otherwise class-scope).
@TestBean
MyDependency dep = mock(MyDependency.class);
@Test void serializer_sees_overlay(TestBeanStore store) {
var ser = SerializerSet.create()
.overridingBeanStore(store)
.add(JsonSerializer.class)
.build();
// JsonSerializer construction now resolves MyDependency from the overlay.
}
Mode OVERLAY — push/pop on a live SUT
Mode OVERLAY is the opt-in pattern for tests that hold a long-lived SUT (typically a Microservice started once in @BeforeAll) and want to apply per-test overlays without rebuilding it.
The contract:
- The SUT already exists. Its bean store was constructed normally (no Mode INJECT pre-installation required).
- The extension is
attach(...)-ed to the SUT's bean store at setup time. - For each test scope, the extension calls
WritableBeanStore.pushOverlay(BeanStore)to layer the@TestBean-built overlay onto the live store, thenpopOverlay(Snapshot)to remove it afterwards. - Subsequent tests see the original beans again — no leakage.
Mode OVERLAY requires @TestBean(mode = Mode.OVERLAY) on every annotation participating in the push/pop scope. Mixing Mode.INJECT and Mode.OVERLAY annotations in the same scope is rejected with a clear IllegalStateException.
Framework primitive — push/pop on WritableBeanStore
The Mode OVERLAY mechanism rests on three additions to the bean-store interface:
public interface WritableBeanStore extends BeanStore, AutoCloseable {
// ... existing API ...
/**
* Pushes a new overlay onto this store's overlay stack. Returns a Snapshot
* that must be passed to popOverlay() to remove it. Pop order must be LIFO.
*/
Snapshot pushOverlay(BeanStore overlay);
/**
* Removes the overlay identified by the supplied Snapshot. Throws
* IllegalStateException if the Snapshot is not the current top of the stack
* (LIFO violation), if the stack is empty, or if the Snapshot was produced
* by a different store.
*/
void popOverlay(Snapshot snapshot);
}
Snapshot is an opaque value type. It identifies one pushed frame and carries the store identity that produced it; tests cannot construct one externally. The implementation in BasicBeanStore uses a StackOverlay composed into the existing overridingParent slot, so pushed frames win over the store's local entries and the regular parent chain (matching Mode INJECT's precedence semantics).
Out-of-order pops, pop-when-empty, foreign-snapshot pops, and null pushes all throw IllegalStateException / NullPointerException with descriptive messages — these are programming errors, surfaced loudly rather than silently degraded.
Worked example — Microservice + Mode OVERLAY
@ExtendWith(JuneauBeanStoreExtension.class)
class MyMicroserviceTest {
static Microservice microservice;
@RegisterExtension
final JuneauBeanStoreExtension ext = new JuneauBeanStoreExtension();
@BeforeAll
static void bootMicroservice() throws Exception {
microservice = Microservice.create()
.build()
.start();
}
@AfterAll
static void stopMicroservice() throws Exception {
microservice.stop();
}
@BeforeEach
void attachExtension() {
// Point the extension at the long-lived microservice's bean store.
// Per-test overlays will be push'd / pop'd against it automatically.
ext.attach(microservice.getBeanStore());
}
@TestBean(mode = Mode.OVERLAY)
MyExternalApi mockApi = mock(MyExternalApi.class);
@Test
void each_test_sees_only_its_own_overlay() {
assertSame(mockApi, microservice.getBeanStore().getBean(MyExternalApi.class).orElseThrow());
// After this test, the overlay is popped — the next test starts fresh.
}
}
The attach(...) call hands the extension a WritableBeanStore reference. The extension then drives the push/pop lifecycle automatically:
@BeforeAll→ push the class-scope overlay (if any@TestBean(scope = CLASS, mode = Mode.OVERLAY)declarations are present).@BeforeEach→ push the method-scope overlay.@AfterEach→ pop the method-scope overlay.@AfterAll→ pop the class-scope overlay anddetach().
The push/pop sequence is LIFO and balanced — JUnit's callback lifecycle already guarantees the nesting; the extension never observes a partial state.
Mode-OVERLAY safety inventory
Mode OVERLAY's overlay-push wins over local bean-store entries and the regular parent chain, just like Mode INJECT's overridingParent slot. But it does not un-pin beans the framework has already memoized at boot — RestContext per-op memoizers and Microservice constructor-final fields are immune.
Concretely:
- Mode-OVERLAY-safe (per-call resolution): beans resolved per-request via
getBean(...)from application code,MicroserviceListenerinstances broadcast onstart()/stop(), named beans looked up by qualifier. - Mode-INJECT-only (boot-time pinned):
RestDebugFormatter,SerializerSet,ParserSet,EncoderSet,MarshallingContext,SwaggerProvider,OpenApiProvider, and the otherRestContextframework defaults the moment they've been read through a per-op memoizer. Swap these via Mode INJECT (fresh SUT with.overridingBeanStore(...)).
If you push a Mode OVERLAY overlay for a Mode-INJECT-only type, the push succeeds but the override is silently ineffective for the framework's already-pinned reference. The override remains visible to user code that calls getBean(...) directly. This is a known limitation; the recommended mitigation is to surface a loud test failure when the swap doesn't take effect.
REST and Mode OVERLAY
MockRestClient caches RestContext per resource class for performance — by design, calling MockRestClient.builder(MyResource.class).build() twice returns two clients that share the same underlying RestContext. Wiring Mode OVERLAY push/pop into that shared cache cleanly is a larger lift than the v1 scope allows; instead, the recommendation is:
- Use Mode INJECT for REST tests. Build a fresh
MockRestClientper test with.overridingBeanStore(store). The cache cost is amortized for simple resources and is the safer default. - Use Mode OVERLAY for
Microservicetests. Booting a microservice is genuinely expensive; the push/pop pattern pays for itself when ten tests share one running microservice.
This split is the v1 ship; revisit Mode OVERLAY for MockRestClient if a concrete use case demands it.
See also
- REST Server — Mixins and Multi-Mount Paths — the
@Rest(mixins=...)composition primitive that also benefits from@TestBeanoverlays. - REST Server — RFC 7807 / 9457 Problem Details
- REST Server — Conditional-GET / ETag Helpers
- REST Server — Rate-Limiting and Request-Id Propagation
- Apache Juneau Javadoc: