Skip to main content

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-bct module — containing the JUnit 5 extension and a @TestBean annotation.
  • A small overlay-builder, TestBeanStore, that wraps the existing BasicBeanStore overriding-parent slot.
  • A consistent overridingBeanStore(BeanStore) setter convention (the BeanStoreOverridable<B> marker interface) on the four user-facing builders that Juneau apps construct: MockRestClient.Builder, Microservice.Builder, SerializerSet.Builder, ParserSet.Builder, and EncoderSet.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:

  1. The extension never reflects on the test class to find MyResource / MockRestClient references. The wiring is explicit — one line: .overridingBeanStore(store).
  2. The TestBeanStore store parameter is resolved by the extension's ParameterResolver. The same store can be retrieved via JuneauBeanStoreExtension.getStore() from @BeforeAll / @BeforeEach bodies if the extension is registered via @RegisterExtension instead of @ExtendWith.
  3. 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:

BuilderSetter
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, then popOverlay(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 and detach().

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, MicroserviceListener instances broadcast on start() / stop(), named beans looked up by qualifier.
  • Mode-INJECT-only (boot-time pinned): RestDebugFormatter, SerializerSet, ParserSet, EncoderSet, MarshallingContext, SwaggerProvider, OpenApiProvider, and the other RestContext framework 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 MockRestClient per test with .overridingBeanStore(store). The cache cost is amortized for simple resources and is the safer default.
  • Use Mode OVERLAY for Microservice tests. 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​