You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@juneau.apache.org by ja...@apache.org on 2026/05/23 16:47:57 UTC

(juneau) branch docs updated: docs: TODO-35 BeanStore test injection topic + 9.5 release notes + sidebar

This is an automated email from the ASF dual-hosted git repository.

jamesbognar pushed a commit to branch docs
in repository https://gitbox.apache.org/repos/asf/juneau.git


The following commit(s) were added to refs/heads/docs by this push:
     new d123ca9079 docs: TODO-35 BeanStore test injection topic + 9.5 release notes + sidebar
d123ca9079 is described below

commit d123ca907961128f788756867ed24826a9d24ff7
Author: James Bognar <ja...@jamesbog-ltmjqf2.internal.salesforce.com>
AuthorDate: Sat May 23 12:47:52 2026 -0400

    docs: TODO-35 BeanStore test injection topic + 9.5 release notes + sidebar
    
    New topic 10.20d.RestServerTestBeanInjection covers the JuneauBeanStoreExtension,
    @TestBean (mode/scope/name), TestBeanStore overlay builder, Mode INJECT
    (construction-time wiring) vs Mode OVERLAY (push/pop on a live SUT), and the
    BeanStoreOverridable<B> surface across MockRestClient, Microservice,
    SerializerSet, ParserSet, and EncoderSet. Sidebar registers the new page;
    9.5.0 release notes add a TODO-35 entry under juneau-rest-server.
    
    Co-authored-by: Cursor <cu...@cursor.com>
---
 pages/release-notes/9.5.0.md                       |  13 +
 pages/topics/10.20d.RestServerTestBeanInjection.md | 268 +++++++++++++++++++++
 sidebars.ts                                        |   5 +
 3 files changed, 286 insertions(+)

diff --git a/pages/release-notes/9.5.0.md b/pages/release-notes/9.5.0.md
index 749c678b8a..5d51815214 100644
--- a/pages/release-notes/9.5.0.md
+++ b/pages/release-notes/9.5.0.md
@@ -1986,6 +1986,19 @@ String name
 
 ### juneau-rest-server
 
+#### Test-time Bean Injection (TODO-35)
+
+A new JUnit 5 extension and `@TestBean` annotation enable Spring-style test-time bean substitution for `MockRestClient`, `Microservice`, `SerializerSet`, `ParserSet`, and `EncoderSet` — without CGLIB, AOT bytecode generation, or any Mockito dependency. See [REST Server — Test-time Bean Injection](/docs/topics/RestServerTestBeanInjection) for the full reference.
+
+- New module **`juneau-junit5`** with `JuneauBeanStoreExtension`, `@TestBean(name, type, scope, mode)`, `Scope`, `Mode`, and `TestBeanStore` (a fluent `BasicBeanStore` overlay-builder).
+- New `BeanStoreOverridable<B>` marker interface in `juneau-commons` plus `overridingBeanStore(BeanStore)` setters on `MockRestClient.Builder`, `Microservice.Builder`, `SerializerSet.Builder`, `ParserSet.Builder`, and `EncoderSet.Builder`. The overlay is installed in the `overridingParent` slot of the builder's bean store, putting overrides at tier 1 of the resolution chain.
+- New `Args.overridingParent` slot on `RestContext.Args` for threading the overlay into the bean store the `RestContext` constructs internally.
+- **Two wiring modes:**
+  - **Mode INJECT** (default) — construction-time wiring with overrides. The overlay is layered into the SUT's bean store at construction time. Universal — every bean type is eligible for replacement.
+  - **Mode OVERLAY** (opt-in via `@TestBean(mode = Mode.OVERLAY)`) — push/pop overlays on a live SUT. The extension is `attach(...)`-ed to a long-lived SUT's bean store; per-test overlays are push'd at `@BeforeEach` / `@BeforeAll` and pop'd at `@AfterEach` / `@AfterAll`. Backed by new `WritableBeanStore.pushOverlay(BeanStore)` / `popOverlay(Snapshot)` primitives.
+- **Per-test and per-class scopes** via `@TestBean(scope = METHOD)` (default) and `@TestBean(scope = CLASS)` (must be on `static` members). Method-scope overlays chain on top of class-scope overlays.
+- **Named-bean qualifier** via `@TestBean(name = "...")` matching the framework's existing `@Bean(name = "...")` parameter resolution.
+
 #### Rate-Limit Guard + Request-Id Filter (TODO-66)
 
 `juneau-rest-server` now ships two opt-in operational primitives — both purely additive, both wired through existing `@Bean` / `@RestStartCall` extension points. See [REST Server — Rate-Limiting and Request-Id Propagation](/docs/topics/RestServerRateLimitAndRequestId) for the full reference.
diff --git a/pages/topics/10.20d.RestServerTestBeanInjection.md b/pages/topics/10.20d.RestServerTestBeanInjection.md
new file mode 100644
index 0000000000..d37a3da3b1
--- /dev/null
+++ b/pages/topics/10.20d.RestServerTestBeanInjection.md
@@ -0,0 +1,268 @@
+---
+title: "Test-time Bean Injection"
+slug: RestServerTestBeanInjection
+---
+
+`juneau-junit5` 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:
+
+- A new module — [`juneau-junit5`](https://search.maven.org/artifact/org.apache.juneau/juneau-junit5) — 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`
+
+```java
+@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:
+
+```java
+@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:
+
+```java
+@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:
+
+```java
+@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).
+
+```java
+@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:
+
+```java
+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
+
+```java
+@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):** `CallLogger`, `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 the documented limitation captured in TODO-35's open question OQ3 — 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
+
+- [REST Server — Mixins and Multi-Mount Paths](/docs/topics/RestServerCompositionMixinsAndPaths) — the `@Rest(mixins=...)` composition primitive that also benefits from `@TestBean` overlays.
+- [REST Server — RFC 7807 / 9457 Problem Details](/docs/topics/RestServerProblemDetails)
+- [REST Server — Conditional-GET / ETag Helpers](/docs/topics/RestServerConditionalGet)
+- [REST Server — Rate-Limiting and Request-Id Propagation](/docs/topics/RestServerRateLimitAndRequestId)
+- Apache Juneau Javadoc:
+  - [`JuneauBeanStoreExtension`](/site/apidocs/org/apache/juneau/junit5/JuneauBeanStoreExtension.html)
+  - [`TestBean`](/site/apidocs/org/apache/juneau/junit5/TestBean.html)
+  - [`TestBeanStore`](/site/apidocs/org/apache/juneau/junit5/TestBeanStore.html)
+  - [`BeanStoreOverridable`](/site/apidocs/org/apache/juneau/commons/inject/BeanStoreOverridable.html)
+  - [`Snapshot`](/site/apidocs/org/apache/juneau/commons/inject/Snapshot.html)
diff --git a/sidebars.ts b/sidebars.ts
index 29d4f55422..bf7dd96d43 100644
--- a/sidebars.ts
+++ b/sidebars.ts
@@ -1496,6 +1496,11 @@ const sidebars: SidebarsConfig = {
 							id: 'topics/10.20c.RestServerRateLimitAndRequestId',
 							label: '10.20c. Rate-Limiting and Request-Id Propagation',
 						},
+						{
+							type: 'doc',
+							id: 'topics/10.20d.RestServerTestBeanInjection',
+							label: '10.20d. Test-time Bean Injection',
+						},
 						{
 							type: 'doc',
 							id: 'topics/10.21.BuiltInParameters',