001/* 002 * Licensed to the Apache Software Foundation (ASF) under one or more 003 * contributor license agreements. See the NOTICE file distributed with 004 * this work for additional information regarding copyright ownership. 005 * The ASF licenses this file to You under the Apache License, Version 2.0 006 * (the "License"); you may not use this file except in compliance with 007 * the License. You may obtain a copy of the License at 008 * 009 * http://www.apache.org/licenses/LICENSE-2.0 010 * 011 * Unless required by applicable law or agreed to in writing, software 012 * distributed under the License is distributed on an "AS IS" BASIS, 013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 014 * See the License for the specific language governing permissions and 015 * limitations under the License. 016 */ 017package org.apache.juneau.bct.junit; 018 019import static org.apache.juneau.commons.utils.Shorts.*; 020 021import java.lang.reflect.*; 022import java.util.*; 023 024import org.apache.juneau.commons.inject.*; 025import org.junit.jupiter.api.extension.*; 026 027/** 028 * JUnit 5 extension that discovers {@link TestBean @TestBean}-annotated members on a test class and exposes them as 029 * a {@link TestBeanStore} overlay. 030 * 031 * <a id="ModeInject"></a> 032 * <h5 class='section'>Mode INJECT — fresh-instance with overrides (default):</h5> 033 * <p> 034 * The extension supports two wiring patterns. <b>Mode INJECT</b> is the default and what every 035 * {@code @TestBean}-based test ships with out of the box. The contract: 036 * <ul> 037 * <li>The SUT (a {@code RestContext} via {@code MockRestClient}, a {@code Microservice}, a {@code SerializerSet}, 038 * etc.) is constructed <i>after</i> the overlay is built. The overlay is threaded into the builder via 039 * {@code overridingBeanStore(...)} (see {@link BeanStoreOverridable}), which 040 * installs it in the {@code overridingParent} slot of the SUT's bean store at construction time. 041 * <li>Because the overlay sits at tier 1 of the resolution chain, every framework-managed bean lookup 042 * consults it <i>before</i> any local {@code @Bean} factory or memoizer-backed default. Mode INJECT is 043 * therefore <i>universal</i>: all bean types are eligible for replacement, including those that subsequently 044 * get pinned into a per-op memoizer. 045 * <li>The overlay's lifetime is bounded by the SUT's lifetime. For a per-test fresh SUT (the most common 046 * shape), the overlay is discarded when the SUT goes out of scope at {@code @AfterEach}. For a per-class 047 * shared SUT (built once in {@code @BeforeAll}), the overlay survives until the SUT is torn down at 048 * {@code @AfterAll}. No explicit push/pop is required. 049 * </ul> 050 * 051 * <p> 052 * {@link TestBean @TestBean}, {@link TestBeanStore}, 053 * {@link BeanStoreOverridable BeanStoreOverridable}, and the {@code overridingBeanStore(...)} 054 * setters on {@code MockRestClient.Builder}, {@code Microservice.Builder}, {@code SerializerSet.Builder}, 055 * {@code ParserSet.Builder}, and {@code EncoderSet.Builder} all implement this Mode INJECT contract. 056 * 057 * <a id="ModeOverlay"></a> 058 * <h5 class='section'>Mode OVERLAY — existing-instance with push/pop:</h5> 059 * <p> 060 * <b>Mode OVERLAY</b> is the opt-in pattern for tests that hold a <i>long-lived</i> SUT (typically a 061 * {@code Microservice} booted in {@code @BeforeAll}) and want to apply per-test overlays without rebuilding it. 062 * The test calls {@link #attach(WritableBeanStore)} to point the extension at the 063 * SUT's bean store; the extension then uses 064 * {@link WritableBeanStore#pushOverlay(BeanStore)} 065 * at {@code beforeEach} / {@code beforeAll} and 066 * {@link WritableBeanStore#popOverlay(Snapshot)} 067 * at {@code afterEach} / {@code afterAll} so the SUT's bean store is restored between tests. 068 * 069 * <p> 070 * Mode OVERLAY requires {@code @TestBean(mode = Mode.OVERLAY)} on the participating annotations. Mixing 071 * {@code Mode.INJECT} and {@code Mode.OVERLAY} on the same scope is rejected with a clear 072 * {@link IllegalStateException}. 073 * 074 * <h5 class='section'>Overlay storage:</h5> 075 * <p> 076 * The extension owns one or two {@link TestBeanStore} overlays per test execution: 077 * <ul> 078 * <li>A <b>class-scope</b> overlay built at {@code beforeAll}, containing every {@code @TestBean(scope = CLASS)} 079 * declared on {@code static} fields/methods of the test class hierarchy. Lives until {@code afterAll}. 080 * <li>A <b>method-scope</b> overlay built at {@code beforeEach}, containing every {@code @TestBean(scope = METHOD)} 081 * declared on instance (or static, for convenience) fields/methods. The method-scope overlay's parent is set 082 * to the class-scope overlay (if any), so a method-scope override of {@code (Type, name)} shadows a 083 * class-scope override of the same pair while still letting the class-scope's other entries fall through. 084 * Lives until {@code afterEach}. 085 * </ul> 086 * 087 * <p> 088 * Wiring into the system under test is <i>explicit</i> — the extension never reflects on the test class to 089 * find a {@code RestContext} / {@code Microservice} field and never mutates the SUT after construction. Test 090 * authors retrieve the current overlay via {@link #getStore()} and plug it into the SUT's builder hook: 091 * 092 * <p class='bjava'> 093 * <ja>@ExtendWith</ja>(JuneauBeanStoreExtension.<jk>class</jk>) 094 * <jk>class</jk> MyResourceTest { 095 * <ja>@TestBean</ja> MyService <jv>mockSvc</jv> = Mockito.<jsm>mock</jsm>(MyService.<jk>class</jk>); 096 * 097 * <ja>@Test</ja> 098 * <jk>void</jk> aTest(TestBeanStore <jv>store</jv>) { 099 * <jk>var</jk> <jv>client</jv> = MockRestClient.<jsm>create</jsm>(MyResource.<jk>class</jk>) 100 * .overridingBeanStore(<jv>store</jv>) 101 * .build(); 102 * <jv>client</jv>.get(<js>"/svc"</js>).run().assertStatus().is(200); 103 * } 104 * } 105 * </p> 106 * 107 * <p> 108 * Two registration styles are supported: 109 * <ul> 110 * <li><b>Declarative:</b> {@code @ExtendWith(JuneauBeanStoreExtension.class)} on the test class. 111 * <li><b>Programmatic:</b> a {@code @RegisterExtension} field initialized with {@link #create()}, so the test can 112 * invoke {@link #getStore()} from within {@code @BeforeAll} / {@code @BeforeEach} bodies as well as from test 113 * methods. 114 * </ul> 115 * 116 * <h5 class='section'>See Also:</h5><ul> 117 * <li class='ja'>{@link TestBean} - The annotation discovered by this extension. 118 * <li class='jc'>{@link TestBeanStore} - The overlay type the extension produces. 119 * <li class='je'>{@link Scope} - Per-method vs per-class lifecycle selector. 120 * <li class='je'>{@link Mode} - Mode INJECT (fresh instance) vs Mode OVERLAY (existing instance push/pop) selector. 121 * </ul> 122 * 123 * @since 10.0.0 124 */ 125@SuppressWarnings({ 126 "java:S3011", // setAccessible(true) is required to read package-private/private @TestBean members on user test classes 127 "java:S3776", // populateStaticOverrides() and populateInstanceOverrides() scan annotated fields/methods and modes in one pass; splitting them would obscure the override rules 128 "resource" // BeanStore lookups return Closeables owned by the JUnit lifecycle; Eclipse JDT @Owning warning is by design. 129}) 130public class JuneauBeanStoreExtension implements BeforeAllCallback, AfterAllCallback, BeforeEachCallback, 131 AfterEachCallback, ParameterResolver { 132 133 private static final ExtensionContext.Namespace NAMESPACE = ExtensionContext.Namespace.create(JuneauBeanStoreExtension.class); 134 private static final String KEY_CLASS_STORE = "class-store"; 135 private static final String KEY_METHOD_STORE = "method-store"; 136 private static final String KEY_CLASS_SNAPSHOT = "class-snapshot"; 137 private static final String KEY_METHOD_SNAPSHOT = "method-snapshot"; 138 139 /** 140 * Creates a new, unconfigured extension instance for programmatic registration via {@code @RegisterExtension}. 141 * 142 * <p> 143 * Equivalent to {@code new JuneauBeanStoreExtension()} but reads more naturally at the registration site: 144 * 145 * <p class='bjava'> 146 * <ja>@RegisterExtension</ja> 147 * JuneauBeanStoreExtension <jv>ext</jv> = JuneauBeanStoreExtension.<jsm>create</jsm>(); 148 * </p> 149 * 150 * @return A new extension instance. Never <jk>null</jk>. 151 * @since 10.0.0 152 */ 153 public static JuneauBeanStoreExtension create() { 154 return new JuneauBeanStoreExtension(); 155 } 156 157 private ExtensionContext currentClassContext; 158 private ExtensionContext currentMethodContext; 159 @SuppressWarnings({ 160 "java:S3077" // Publish-once reference: only plain-assigned in attach()/detach() and read-once into a local; volatile suffices for safe publication. The referenced WritableBeanStore manages its own thread-safety. 161 }) 162 private volatile WritableBeanStore attached; 163 164 /** 165 * Constructor. 166 */ 167 public JuneauBeanStoreExtension() { /* no-op */ } 168 169 //----------------------------------------------------------------------------------------------------------------- 170 // Mode OVERLAY attach / detach 171 //----------------------------------------------------------------------------------------------------------------- 172 173 /** 174 * Points this extension at a long-lived SUT's {@link WritableBeanStore} for Mode OVERLAY push/pop wiring. 175 * 176 * <p> 177 * Call from a {@code @BeforeAll} or {@code @BeforeEach} body when the participating {@code @TestBean} declarations 178 * use {@link Mode#OVERLAY}. Once attached, the extension will: 179 * <ul> 180 * <li>{@link WritableBeanStore#pushOverlay(BeanStore) push} the class-scope overlay onto the attached store at 181 * {@code beforeAll} (if a non-empty class-scope overlay exists), and 182 * {@link WritableBeanStore#popOverlay(Snapshot) pop} it at {@code afterAll}. 183 * <li>Push the method-scope overlay at {@code beforeEach} and pop it at {@code afterEach}. 184 * </ul> 185 * 186 * <p> 187 * Calling {@code attach(...)} a second time before {@link #detach()} is permitted — the most recent 188 * attachment wins. If never called, the extension stays in Mode INJECT. 189 * 190 * @param store The bean store to push/pop overlays against. Must not be <jk>null</jk>. 191 * @return This extension, for fluent chaining. 192 * @throws NullPointerException If {@code store} is <jk>null</jk>. 193 * @since 10.0.0 194 */ 195 public JuneauBeanStoreExtension attach(WritableBeanStore store) { 196 Objects.requireNonNull(store, "store must not be null"); 197 attached = store; 198 return this; 199 } 200 201 /** 202 * Clears the {@link #attach(WritableBeanStore) attached} bean store reference. 203 * 204 * <p> 205 * Called automatically at {@code afterAll}; tests may also call it explicitly between scenarios. Has no effect 206 * if the extension is not currently attached. 207 * 208 * @return This extension, for fluent chaining. 209 * @since 10.0.0 210 */ 211 public JuneauBeanStoreExtension detach() { 212 attached = null; 213 return this; 214 } 215 216 /** 217 * Returns the {@link #attach(WritableBeanStore) attached} bean store, or empty if none. 218 * 219 * <p> 220 * Surface for diagnostic / testability purposes — the push/pop lifecycle uses the field directly. 221 * 222 * @return The attached store, or {@link Optional#empty()} if not currently attached. 223 * @since 10.0.0 224 */ 225 public Optional<WritableBeanStore> getAttachedStore() { 226 return o(attached); 227 } 228 229 /** 230 * Returns the current effective overlay — method-scope if a test method is executing, otherwise the 231 * class-scope overlay. 232 * 233 * <p> 234 * This is the accessor most tests plug into builder hooks such as 235 * {@code MockRestClient.Builder.overridingBeanStore(...)}. 236 * 237 * @return The current overlay. Never <jk>null</jk>. 238 * @throws IllegalStateException If no class-scope or method-scope overlay is currently active (e.g. the accessor 239 * is called from a {@code @BeforeAll} body and the extension is not yet bound to a test class). 240 * @since 10.0.0 241 */ 242 public TestBeanStore getStore() { 243 var s = currentMethodStore(); 244 if (s != null) 245 return s; 246 s = currentClassStore(); 247 if (s != null) 248 return s; 249 throw new IllegalStateException("JuneauBeanStoreExtension is not currently bound to a test class or test method."); 250 } 251 252 /** 253 * Returns the overlay for the specified scope, if one is currently active. 254 * 255 * <p> 256 * Allows a test to reach past the current effective overlay and inspect a specific lifecycle layer. For 257 * example, a test running under both a class-scope and a method-scope overlay can use 258 * {@code getStore(Scope.CLASS)} to retrieve the shared per-class layer directly. 259 * 260 * @param scope The lifecycle scope to query. Must not be <jk>null</jk>. 261 * @return The overlay for that scope, or {@link Optional#empty()} if no such overlay is active. 262 * @since 10.0.0 263 */ 264 public Optional<TestBeanStore> getStore(Scope scope) { 265 Objects.requireNonNull(scope, "scope must not be null"); 266 return o(scope == Scope.METHOD ? currentMethodStore() : currentClassStore()); 267 } 268 269 //----------------------------------------------------------------------------------------------------------------- 270 // Lifecycle callbacks 271 //----------------------------------------------------------------------------------------------------------------- 272 273 @Override /* BeforeAllCallback */ 274 public void beforeAll(ExtensionContext context) { 275 currentClassContext = context; 276 var testClass = context.getRequiredTestClass(); 277 var scoped = buildClassScopeStoreWithMode(testClass); 278 context.getStore(NAMESPACE).put(KEY_CLASS_STORE, scoped.store()); 279 // Mode OVERLAY push (if attached) — only fires when at least one class-scope @TestBean declared 280 // mode = Mode.OVERLAY. All declarations in the scope must agree on the mode (mixing throws in 281 // buildClassScopeStoreWithMode). 282 var snapshot = pushOverlayIfOverlayMode(scoped); 283 if (snapshot != null) 284 context.getStore(NAMESPACE).put(KEY_CLASS_SNAPSHOT, snapshot); 285 } 286 287 @Override /* AfterAllCallback */ 288 public void afterAll(ExtensionContext context) { 289 popOverlayIfPresent(context, KEY_CLASS_SNAPSHOT); 290 context.getStore(NAMESPACE).remove(KEY_CLASS_STORE); 291 currentClassContext = null; 292 } 293 294 @Override /* BeforeEachCallback */ 295 public void beforeEach(ExtensionContext context) { 296 currentMethodContext = context; 297 var classStore = readClassStore(context); 298 var testInstance = context.getRequiredTestInstance(); 299 var scoped = buildMethodScopeStoreWithMode(testInstance, classStore); 300 context.getStore(NAMESPACE).put(KEY_METHOD_STORE, scoped.store()); 301 var snapshot = pushOverlayIfOverlayMode(scoped); 302 if (snapshot != null) 303 context.getStore(NAMESPACE).put(KEY_METHOD_SNAPSHOT, snapshot); 304 } 305 306 @Override /* AfterEachCallback */ 307 public void afterEach(ExtensionContext context) { 308 popOverlayIfPresent(context, KEY_METHOD_SNAPSHOT); 309 context.getStore(NAMESPACE).remove(KEY_METHOD_STORE); 310 currentMethodContext = null; 311 } 312 313 //----------------------------------------------------------------------------------------------------------------- 314 // Mode OVERLAY push/pop helpers 315 //----------------------------------------------------------------------------------------------------------------- 316 317 private PushedOverlay pushOverlayIfOverlayMode(ScopedStore scoped) { 318 if (scoped.mode() != Mode.OVERLAY || scoped.empty()) 319 return null; 320 var store = attached; 321 if (store == null) 322 throw new IllegalStateException( 323 "@TestBean(mode = Mode.OVERLAY) declared but JuneauBeanStoreExtension was not attached to a" 324 + " WritableBeanStore. Call extension.attach(sut.getBeanStore()) from @BeforeAll / @BeforeEach" 325 + " before the test runs."); 326 var snapshot = store.pushOverlay(scoped.store()); 327 return new PushedOverlay(store, snapshot); 328 } 329 330 private static void popOverlayIfPresent(ExtensionContext context, String key) { 331 var pushed = (PushedOverlay) context.getStore(NAMESPACE).remove(key); 332 if (pushed == null) 333 return; 334 // Pop against the original store reference captured at push time, so the pop works even if the test 335 // has since detach()-ed the extension or attach()-ed it to a different store. 336 pushed.store().popOverlay(pushed.snapshot()); 337 } 338 339 /** Captures the {@link WritableBeanStore} an overlay was pushed against, plus the resulting {@link Snapshot}. */ 340 private record PushedOverlay(WritableBeanStore store, Snapshot snapshot) { /* no body */ } 341 342 @Override /* ParameterResolver */ 343 public boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext) { 344 return parameterContext.getParameter().getType() == TestBeanStore.class; 345 } 346 347 @Override /* ParameterResolver */ 348 public Object resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext) { 349 // Prefer the method-scope store from the supplied extensionContext (the parameter being resolved belongs to 350 // the test method currently executing); fall back to class-scope when the parameter is resolved outside a 351 // method context (rare — e.g. constructor injection on a TestInstance.Lifecycle.PER_METHOD test class). 352 var methodStore = (TestBeanStore) extensionContext.getStore(NAMESPACE).get(KEY_METHOD_STORE); 353 if (methodStore != null) 354 return methodStore; 355 var classStore = readClassStore(extensionContext); 356 if (classStore != null) 357 return classStore; 358 throw new ParameterResolutionException( 359 "JuneauBeanStoreExtension cannot resolve TestBeanStore parameter: no overlay is active."); 360 } 361 362 //----------------------------------------------------------------------------------------------------------------- 363 // Discovery 364 //----------------------------------------------------------------------------------------------------------------- 365 366 /** 367 * Builds a class-scope overlay for the supplied test class. 368 * 369 * <p> 370 * Walks the class hierarchy parent-to-child registering every {@code @TestBean(scope = CLASS)} declared on a 371 * {@code static} field or {@code static} method. Non-static {@code scope = CLASS} declarations throw an 372 * {@link ExtensionContextException} — instance state isn't available at {@code beforeAll} time. 373 * 374 * <p> 375 * Package-private to allow direct unit testing of the discovery logic without driving the full JUnit lifecycle. 376 */ 377 static TestBeanStore buildClassScopeStore(Class<?> testClass) { 378 return buildClassScopeStoreWithMode(testClass).store(); 379 } 380 381 /** 382 * Builds a method-scope overlay for the supplied test instance, optionally chained on top of a class-scope 383 * overlay. 384 * 385 * <p> 386 * Package-private to allow direct unit testing of the discovery logic without driving the full JUnit lifecycle. 387 */ 388 static TestBeanStore buildMethodScopeStore(Object testInstance, TestBeanStore classScopeParent) { 389 return buildMethodScopeStoreWithMode(testInstance, classScopeParent).store(); 390 } 391 392 /** 393 * {@link #buildClassScopeStore(Class)} plus the unified mode (INJECT or OVERLAY) of all class-scope 394 * declarations. 395 * 396 * <p> 397 * Mixing {@code Mode.INJECT} and {@code Mode.OVERLAY} declarations in the same scope throws an 398 * {@link IllegalStateException}. 399 */ 400 static ScopedStore buildClassScopeStoreWithMode(Class<?> testClass) { 401 var store = new TestBeanStore(); 402 var modeTracker = new ModeTracker(Scope.CLASS); 403 populateStaticOverrides(store, testClass, modeTracker); 404 return new ScopedStore(store, modeTracker.resolve(), modeTracker.empty()); 405 } 406 407 /** 408 * {@link #buildMethodScopeStore(Object, TestBeanStore)} plus the unified mode (INJECT or OVERLAY) of all 409 * method-scope declarations. 410 * 411 * <p> 412 * Mixing {@code Mode.INJECT} and {@code Mode.OVERLAY} declarations in the same scope throws an 413 * {@link IllegalStateException}. 414 */ 415 static ScopedStore buildMethodScopeStoreWithMode(Object testInstance, TestBeanStore classScopeParent) { 416 var store = new TestBeanStore(classScopeParent); 417 var modeTracker = new ModeTracker(Scope.METHOD); 418 populateInstanceOverrides(store, testInstance, modeTracker); 419 return new ScopedStore(store, modeTracker.resolve(), modeTracker.empty()); 420 } 421 422 /** 423 * Discovers the {@code @TestBean} overrides declared on a test instance (and its class hierarchy) and returns 424 * them as a single {@link OverrideSet} — the public composition entry point for other JUnit 5 extensions 425 * (e.g. {@code @MicroserviceTest}) that need the {@code @TestBean} substrate without driving this extension's 426 * own lifecycle. 427 * 428 * <p> 429 * Both {@code @TestBean(scope = CLASS)} (static) and {@code @TestBean(scope = METHOD)} (instance) declarations 430 * are collected into one flattened overlay (method-scope chained over class-scope), so a composing extension 431 * that boots a single system-under-test per scope sees every declared override. The returned 432 * {@link OverrideSet#mode()} is the unified mode of all declarations (defaulting to {@link Mode#INJECT} when 433 * none are present); mixing {@code INJECT} and {@code OVERLAY} in one scope throws {@link IllegalStateException}. 434 * 435 * @param testInstance The test-class instance to scan. Must not be <jk>null</jk>. 436 * @return The discovered overrides + their unified mode. Never <jk>null</jk>; {@link OverrideSet#isEmpty()} is 437 * <jk>true</jk> when no {@code @TestBean} members were found. 438 * @since 10.0.0 439 */ 440 public static OverrideSet discoverOverrides(Object testInstance) { 441 reqnn("testInstance", testInstance); 442 var classScoped = buildClassScopeStoreWithMode(testInstance.getClass()); 443 var methodScoped = buildMethodScopeStoreWithMode(testInstance, classScoped.store()); 444 // Unify the two scopes' modes: an empty scope contributes no constraint; a populated one does. 445 var mode = methodScoped.empty() ? classScoped.mode() : methodScoped.mode(); 446 if (! classScoped.empty() && ! methodScoped.empty() && classScoped.mode() != methodScoped.mode()) 447 throw new IllegalStateException( 448 "Mixed @TestBean modes across scopes: CLASS declares Mode." + classScoped.mode() 449 + " but METHOD declares Mode." + methodScoped.mode() 450 + ". All @TestBean declarations must use the same mode for a single-boot SUT."); 451 var empty = classScoped.empty() && methodScoped.empty(); 452 return new OverrideSet(methodScoped.store(), mode, empty); 453 } 454 455 /** 456 * The flattened result of {@link #discoverOverrides(Object)}: the {@code @TestBean} overlay store, the unified 457 * {@link Mode} of all declarations, and whether any were found. 458 * 459 * @param store The overlay {@link BeanStore} carrying the discovered overrides (method-scope chained over 460 * class-scope). Usable directly as a builder {@code overridingBeanStore(...)} argument for {@link Mode#INJECT}, 461 * or as a {@code pushOverlay(...)} argument for {@link Mode#OVERLAY}. 462 * @param mode The unified declaration mode ({@link Mode#INJECT} by default). 463 * @param empty Whether no {@code @TestBean} members were discovered. 464 * @since 10.0.0 465 */ 466 public record OverrideSet(TestBeanStore store, Mode mode, boolean empty) { 467 468 /** 469 * Returns whether no {@code @TestBean} members were discovered. 470 * 471 * @return <jk>true</jk> if the override set is empty. 472 */ 473 public boolean isEmpty() { 474 return empty; 475 } 476 } 477 478 /** 479 * Walks the test-class hierarchy and registers every {@code @TestBean(scope = CLASS)} declared on a 480 * {@code static} field or {@code static} method. 481 * 482 * <p> 483 * Non-static {@code scope = CLASS} declarations are rejected with an {@link ExtensionContextException} — 484 * instance state isn't available at {@code beforeAll} time. 485 */ 486 private static void populateStaticOverrides(TestBeanStore store, Class<?> testClass, ModeTracker modeTracker) { 487 for (var c : classHierarchy(testClass)) { 488 for (var f : c.getDeclaredFields()) { 489 var ann = f.getAnnotation(TestBean.class); 490 if (ann == null || ann.scope() != Scope.CLASS) 491 continue; 492 if (!Modifier.isStatic(f.getModifiers())) 493 throw new ExtensionContextException( 494 "@TestBean(scope = CLASS) field must be static: " + describe(f)); 495 modeTracker.observe(ann.mode(), describe(f)); 496 register(store, f, null, ann); 497 } 498 for (var m : c.getDeclaredMethods()) { 499 var ann = m.getAnnotation(TestBean.class); 500 if (ann == null || ann.scope() != Scope.CLASS) 501 continue; 502 if (!Modifier.isStatic(m.getModifiers())) 503 throw new ExtensionContextException( 504 "@TestBean(scope = CLASS) method must be static: " + describe(m)); 505 modeTracker.observe(ann.mode(), describe(m)); 506 register(store, m, null, ann); 507 } 508 } 509 } 510 511 /** 512 * Walks the test-instance class hierarchy and registers every {@code @TestBean(scope = METHOD)} declared on a 513 * field or method. 514 * 515 * <p> 516 * Honors both instance and static members ("behave consistently with 517 * non-static" for {@code scope = METHOD} on static fields). 518 */ 519 private static void populateInstanceOverrides(TestBeanStore store, Object testInstance, ModeTracker modeTracker) { 520 var testClass = testInstance.getClass(); 521 for (var c : classHierarchy(testClass)) { 522 for (var f : c.getDeclaredFields()) { 523 var ann = f.getAnnotation(TestBean.class); 524 if (ann == null || ann.scope() != Scope.METHOD) 525 continue; 526 modeTracker.observe(ann.mode(), describe(f)); 527 register(store, f, Modifier.isStatic(f.getModifiers()) ? null : testInstance, ann); 528 } 529 for (var m : c.getDeclaredMethods()) { 530 var ann = m.getAnnotation(TestBean.class); 531 if (ann == null || ann.scope() != Scope.METHOD) 532 continue; 533 modeTracker.observe(ann.mode(), describe(m)); 534 register(store, m, Modifier.isStatic(m.getModifiers()) ? null : testInstance, ann); 535 } 536 } 537 } 538 539 /** Carries the discovered overlay plus the resolved {@link Mode} for a single scope. */ 540 record ScopedStore(TestBeanStore store, Mode mode, boolean empty) { /* no body */ } 541 542 /** 543 * Tracks the {@link Mode} of {@code @TestBean} declarations encountered within a single scope and rejects 544 * mixed-mode declarations. 545 */ 546 private static final class ModeTracker { 547 private final Scope scope; 548 private Mode observed; 549 private String firstMember; 550 private boolean empty = true; 551 552 ModeTracker(Scope scope) { 553 this.scope = scope; 554 } 555 556 void observe(Mode mode, String memberDesc) { 557 empty = false; 558 if (observed == null) { 559 observed = mode; 560 firstMember = memberDesc; 561 return; 562 } 563 if (observed != mode) 564 throw new IllegalStateException( 565 "Mixed @TestBean modes in " + scope + " scope: " + firstMember + " declares Mode." + observed 566 + " but " + memberDesc + " declares Mode." + mode 567 + ". All @TestBean declarations in a single scope must use the same mode."); 568 } 569 570 Mode resolve() { 571 return observed == null ? Mode.INJECT : observed; 572 } 573 574 boolean empty() { 575 return empty; 576 } 577 } 578 579 private static void register(TestBeanStore store, Field field, Object instance, TestBean ann) { 580 Object value; 581 try { 582 field.setAccessible(true); 583 value = field.get(instance); 584 } catch (IllegalAccessException e) { 585 throw new ExtensionContextException("Could not read @TestBean field: " + describe(field), e); 586 } 587 var type = ann.type() == Object.class ? field.getType() : ann.type(); 588 registerValue(store, type, value, ann.name(), describe(field)); 589 } 590 591 private static void register(TestBeanStore store, Method method, Object instance, TestBean ann) { 592 if (method.getParameterCount() != 0) 593 throw new ExtensionContextException( 594 "@TestBean factory method must be parameterless: " + describe(method)); 595 Object value; 596 try { 597 method.setAccessible(true); 598 value = method.invoke(instance); 599 } catch (ReflectiveOperationException e) { 600 throw new ExtensionContextException( 601 "Failed to invoke @TestBean factory method: " + describe(method), 602 or(e.getCause(), e)); 603 } 604 var type = ann.type() == Object.class ? method.getReturnType() : ann.type(); 605 registerValue(store, type, value, ann.name(), describe(method)); 606 } 607 608 @SuppressWarnings({ 609 "unchecked" // The type token came from either the annotated member's declared type or ann.type(); the value's runtime class is compatible by construction. 610 }) 611 private static void registerValue(TestBeanStore store, Class<?> type, Object value, String name, String memberDesc) { 612 if (value != null && !type.isInstance(value)) 613 throw new ExtensionContextException( 614 "@TestBean value is not assignable to declared type " + type.getName() + ": " + memberDesc); 615 store.override((Class<Object>) type, value, name.isEmpty() ? null : name); 616 } 617 618 //----------------------------------------------------------------------------------------------------------------- 619 // Internal helpers 620 //----------------------------------------------------------------------------------------------------------------- 621 622 private TestBeanStore currentMethodStore() { 623 return currentMethodContext == null ? null : (TestBeanStore) currentMethodContext.getStore(NAMESPACE).get(KEY_METHOD_STORE); 624 } 625 626 private TestBeanStore currentClassStore() { 627 return currentClassContext == null ? null : readClassStore(currentClassContext); 628 } 629 630 /** 631 * Reads the class-scope store from the nearest enclosing context that owns one. 632 * 633 * <p> 634 * JUnit's {@code ExtensionContext.Store} keys are scoped per context. When a method-scope callback fires, the 635 * incoming context is the method-level context whose own store does <i>not</i> contain the class-scope entry; 636 * the class-scope entry lives on the parent (class-level) context. This helper walks parents until it finds 637 * one whose store contains the class-scope entry, or returns {@code null} if none does. 638 */ 639 private static TestBeanStore readClassStore(ExtensionContext context) { 640 for (var c = context; c != null; c = c.getParent().orElse(null)) { 641 var s = (TestBeanStore) c.getStore(NAMESPACE).get(KEY_CLASS_STORE); 642 if (s != null) 643 return s; 644 } 645 return null; 646 } 647 648 private static List<Class<?>> classHierarchy(Class<?> testClass) { 649 // Walk parent-to-child so a subclass's @TestBean of the same (type, name) wins over the parent's via the 650 // "last write wins" semantics of BasicBeanStore.addBean(...). 651 var hierarchy = new ArrayList<Class<?>>(); 652 for (var c = testClass; c != null && c != Object.class; c = c.getSuperclass()) 653 hierarchy.add(c); 654 Collections.reverse(hierarchy); 655 return hierarchy; 656 } 657 658 private static String describe(Field f) { 659 return f.getDeclaringClass().getName() + "#" + f.getName(); 660 } 661 662 private static String describe(Method m) { 663 return m.getDeclaringClass().getName() + "#" + m.getName() + "()"; 664 } 665}