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 <[email protected]>
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 <[email protected]>
---
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',