From 7cfe9009ed08d31cd7e6eee51576de6ad773b6b2 Mon Sep 17 00:00:00 2001 From: Josiah Noel <32279667+SentryMan@users.noreply.github.com> Date: Mon, 17 Aug 2026 08:32:26 -0400 Subject: [PATCH 1/2] Add the ability to pick test modules --- .../multi/main/BoundedCustomModuleTest.java | 26 ++++++++++ .../modules/BoundedModulesStaticTest.java | 31 +++++++++++ .../myapp/modules/BoundedModulesTest.java | 51 +++++++++++++++++++ .../avaje/inject/test/GlobalInitialise.java | 11 +--- .../io/avaje/inject/test/GlobalTestBeans.java | 32 +++++++++++- .../java/io/avaje/inject/test/InjectTest.java | 10 ++++ .../java/io/avaje/inject/test/MetaInfo.java | 13 +++++ .../io/avaje/inject/test/SelectedModules.java | 43 ++++++++++++++++ inject-test/src/main/java/module-info.java | 1 + 9 files changed, 206 insertions(+), 12 deletions(-) create mode 100644 blackbox-multi-scope/src/test/java/org/multi/main/BoundedCustomModuleTest.java create mode 100644 blackbox-test-inject/src/test/java/org/example/myapp/modules/BoundedModulesStaticTest.java create mode 100644 blackbox-test-inject/src/test/java/org/example/myapp/modules/BoundedModulesTest.java create mode 100644 inject-test/src/main/java/io/avaje/inject/test/SelectedModules.java diff --git a/blackbox-multi-scope/src/test/java/org/multi/main/BoundedCustomModuleTest.java b/blackbox-multi-scope/src/test/java/org/multi/main/BoundedCustomModuleTest.java new file mode 100644 index 000000000..f8be54dc9 --- /dev/null +++ b/blackbox-multi-scope/src/test/java/org/multi/main/BoundedCustomModuleTest.java @@ -0,0 +1,26 @@ +package org.multi.main; + +import io.avaje.inject.test.InjectTest; +import jakarta.inject.Inject; +import org.junit.jupiter.api.Test; +import org.multi.parents.BeanIn1; +import org.multi.parents.Mod1Module; + +import static org.junit.jupiter.api.Assertions.assertNotNull; + +/** + * Bound the wiring to a custom scope module. Custom scope modules are not registered as an {@code + * InjectExtension} service, so this covers the reflection fallback in {@code SelectedModules}. + */ +@InjectTest(modules = Mod1Module.class) +class BoundedCustomModuleTest { + + static int constructedBefore; + + @Inject BeanIn1 beanIn1; + + @Test + void onlyTheSelectedModuleIsWired() { + assertNotNull(beanIn1); + } +} diff --git a/blackbox-test-inject/src/test/java/org/example/myapp/modules/BoundedModulesStaticTest.java b/blackbox-test-inject/src/test/java/org/example/myapp/modules/BoundedModulesStaticTest.java new file mode 100644 index 000000000..42fbc630d --- /dev/null +++ b/blackbox-test-inject/src/test/java/org/example/myapp/modules/BoundedModulesStaticTest.java @@ -0,0 +1,31 @@ +package org.example.myapp.modules; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Optional; + +import org.example.external.aspect.sub.ExampleExternalAspectModule; +import org.example.myapp.HelloService; +import org.junit.jupiter.api.Test; + +import io.avaje.inject.aop.MethodInterceptor; +import io.avaje.inject.test.InjectTest; +import jakarta.inject.Inject; + +/** + * The selected modules must also bound the class level BeanScope, which is built without a test + * instance - so {@code MetaInfo} has to read them from the test class. + */ +@InjectTest(modules = ExampleExternalAspectModule.class) +class BoundedModulesStaticTest { + + @Inject static MethodInterceptor interceptor; + + @Inject static Optional helloService; + + @Test + void classLevelScopeIsAlsoBounded() { + assertThat(interceptor).isNotNull(); + assertThat(helloService).isEmpty(); + } +} diff --git a/blackbox-test-inject/src/test/java/org/example/myapp/modules/BoundedModulesTest.java b/blackbox-test-inject/src/test/java/org/example/myapp/modules/BoundedModulesTest.java new file mode 100644 index 000000000..e02fca703 --- /dev/null +++ b/blackbox-test-inject/src/test/java/org/example/myapp/modules/BoundedModulesTest.java @@ -0,0 +1,51 @@ +package org.example.myapp.modules; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Optional; + +import org.example.external.aspect.sub.ExampleExternalAspectModule; +import org.example.myapp.HelloData; +import org.example.myapp.HelloService; +import org.junit.jupiter.api.Test; +import org.other.one.OtherComponent; + +import io.avaje.inject.aop.MethodInterceptor; +import io.avaje.inject.test.InjectTest; +import jakarta.inject.Inject; + +/** + * Bound the wiring to a single module. {@code ExampleExternalAspectModule} is registered as an + * {@code InjectExtension} service so this covers the ServiceLoader path in {@code SelectedModules}. + */ +@InjectTest(modules = ExampleExternalAspectModule.class) +class BoundedModulesTest { + + /** Provided by the selected module. */ + @Inject MethodInterceptor interceptor; + + /** Provided by the global test scope (parent), so still available. */ + @Inject HelloData helloData; + + /** Provided by MyappModule, which was not selected. */ + @Inject Optional helloService; + + /** Provided by OneModule (blackbox-other), which was not selected. */ + @Inject Optional otherComponent; + + @Test + void selectedModuleIsWired() { + assertThat(interceptor).isNotNull(); + } + + @Test + void parentTestScopeIsStillInherited() { + assertThat(helloData.helloData()).isEqualTo("TestHelloData"); + } + + @Test + void unselectedModulesAreNotWired() { + assertThat(helloService).isEmpty(); + assertThat(otherComponent).isEmpty(); + } +} diff --git a/inject-test/src/main/java/io/avaje/inject/test/GlobalInitialise.java b/inject-test/src/main/java/io/avaje/inject/test/GlobalInitialise.java index f1b2b438a..99688eefd 100644 --- a/inject-test/src/main/java/io/avaje/inject/test/GlobalInitialise.java +++ b/inject-test/src/main/java/io/avaje/inject/test/GlobalInitialise.java @@ -54,16 +54,7 @@ static GlobalTestBeans.Beans initialise(boolean shutdownHook) { } private static GlobalTestBeans.Beans createScopes(boolean shutdownHook) { - BeanScope testBaseScope = createTestBaseScope(shutdownHook); - BeanScope testAllScope = createTestAllScope(testBaseScope); - Plugin.Scope pluginAll = PluginMgr.scope(testAllScope); - return new GlobalTestBeans.Beans(pluginAll, testAllScope, testBaseScope); - } - - private static BeanScope createTestAllScope(BeanScope testBaseScope) { - return BeanScope.builder() - .parent(testBaseScope, false) - .build(); + return new GlobalTestBeans.Beans(createTestBaseScope(shutdownHook)); } @Nullable diff --git a/inject-test/src/main/java/io/avaje/inject/test/GlobalTestBeans.java b/inject-test/src/main/java/io/avaje/inject/test/GlobalTestBeans.java index 51bb6ae9a..abb598771 100644 --- a/inject-test/src/main/java/io/avaje/inject/test/GlobalTestBeans.java +++ b/inject-test/src/main/java/io/avaje/inject/test/GlobalTestBeans.java @@ -61,14 +61,19 @@ public void close() { */ static final class Beans { - private final Plugin.Scope plugin; + private final ReentrantLock allBeansLock = new ReentrantLock(); + + private Plugin.Scope plugin; /** * Entire application wired (with testScope as parent replacing those beans). * This can be used when a test only injects beans and there are no mocks, * spies, or setup methods. + *

+ * Wired lazily on first use as a test using {@code @InjectTest(modules = ...)} + * only wires the modules it selects and never uses this. */ - private final BeanScope allBeans; + private BeanScope allBeans; /** * The TestScope beans, used as the parent scope when a new BeanScope @@ -76,6 +81,10 @@ static final class Beans { */ private final BeanScope baseBeans; + Beans(BeanScope baseBeans) { + this.baseBeans = baseBeans; + } + Beans(Plugin.Scope plugin, BeanScope allBeans, BeanScope baseBeans) { this.plugin = plugin; this.allBeans = allBeans; @@ -95,13 +104,32 @@ void close() { } Plugin.Scope allPlugin() { + initAllBeans(); return plugin; } BeanScope allBeans() { + initAllBeans(); return allBeans; } + private void initAllBeans() { + if (allBeans == null) { + allBeansLock.lock(); + try { + if (allBeans == null) { + log.log(DEBUG, "Wiring all beans for the test BeanScope"); + allBeans = BeanScope.builder() + .parent(baseBeans, false) + .build(); + plugin = PluginMgr.scope(allBeans); + } + } finally { + allBeansLock.unlock(); + } + } + } + BeanScope baseBeans() { return baseBeans; } diff --git a/inject-test/src/main/java/io/avaje/inject/test/InjectTest.java b/inject-test/src/main/java/io/avaje/inject/test/InjectTest.java index 360404232..fdf39f0a2 100644 --- a/inject-test/src/main/java/io/avaje/inject/test/InjectTest.java +++ b/inject-test/src/main/java/io/avaje/inject/test/InjectTest.java @@ -7,6 +7,8 @@ import org.junit.jupiter.api.extension.ExtendWith; +import io.avaje.inject.spi.AvajeModule; + /** * An avaje-inject test supporting {@code @Inject} along with Mockito annotations - * {@code @Mock, @Spy, @Captor}. @@ -23,4 +25,12 @@ /** Create a new test beanscope for each test method */ boolean scopePerTest() default false; + + /** + * Limit the wiring to only these modules, rather than every {@code AvajeModule} found on the test + * classpath. + * + *

This is the equivalent of {@code BeanScope.builder().modules(...)} + */ + Class[] modules() default {}; } diff --git a/inject-test/src/main/java/io/avaje/inject/test/MetaInfo.java b/inject-test/src/main/java/io/avaje/inject/test/MetaInfo.java index 0ccc140df..a9c85ec84 100644 --- a/inject-test/src/main/java/io/avaje/inject/test/MetaInfo.java +++ b/inject-test/src/main/java/io/avaje/inject/test/MetaInfo.java @@ -4,16 +4,25 @@ import io.avaje.inject.BeanScope; import io.avaje.inject.BeanScopeBuilder; +import io.avaje.inject.spi.AvajeModule; /** * Wraps the underlying metadata (fields with annotations @Mock, @Spy, @Inject, @Captor). */ final class MetaInfo { + @SuppressWarnings("unchecked") + private static final Class[] NO_MODULES = new Class[0]; + private final MetaReader reader; + private final Class[] modules; MetaInfo(Class testClass, Plugin plugin) { this.reader = new MetaReader(testClass, plugin); + final InjectTest injectTest = testClass.getAnnotation(InjectTest.class); + // read from the test class rather than the instance so that the selected modules also + // bound the class level scope of a test that only has static injection + this.modules = injectTest == null ? NO_MODULES : injectTest.modules(); } boolean hasStaticInjection() { @@ -54,6 +63,7 @@ private TestBeans buildTestBeans(GlobalTestBeans.Beans parent, Object testInstan String[] profiles = injectTest.map(InjectTest::profiles).orElse(new String[0]); if (profiles.length > 0 + || modules.length > 0 || injectTest.map(InjectTest::scopePerTest).orElse(false) || reader.hasMocksOrSpies(testInstance)) { // need to build a BeanScope for this using baseBeans() as the parent @@ -64,6 +74,9 @@ private TestBeans buildTestBeans(GlobalTestBeans.Beans parent, Object testInstan builder.profiles(profiles); } } + if (modules.length > 0) { + builder.modules(SelectedModules.instances(modules)); + } // register mocks and spies local to this test reader.build(builder, testInstance); // wire with local mocks, spies, and TestScope beans diff --git a/inject-test/src/main/java/io/avaje/inject/test/SelectedModules.java b/inject-test/src/main/java/io/avaje/inject/test/SelectedModules.java new file mode 100644 index 000000000..456f67b8d --- /dev/null +++ b/inject-test/src/main/java/io/avaje/inject/test/SelectedModules.java @@ -0,0 +1,43 @@ +package io.avaje.inject.test; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.ServiceLoader; +import io.avaje.inject.spi.AvajeModule; +import io.avaje.inject.spi.InjectExtension; + +/** Resolves the {@code AvajeModule} instances for {@code @InjectTest(modules = ...)}. */ +final class SelectedModules { + + private static final AvajeModule[] EMPTY = {}; + + private SelectedModules() {} + + /** Return the module instances for the given module classes. */ + static AvajeModule[] instances(Class[] moduleClasses) { + final var wanted = new LinkedHashSet<>(List.of(moduleClasses)); + final List modules = new ArrayList<>(wanted.size()); + ServiceLoader.load(InjectExtension.class).stream() + .filter(provider -> wanted.contains(provider.type())) + .forEach( + provider -> { + wanted.remove(provider.type()); + modules.add((AvajeModule) provider.get()); + }); + // fall back to reflection if the module is not registered as a service. + for (var cls : wanted) { + modules.add(newInstance(cls)); + } + return modules.toArray(EMPTY); + } + + private static AvajeModule newInstance(Class cls) { + try { + return cls.getDeclaredConstructor().newInstance(); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException( + "Failed to create @InjectTest(modules = " + cls.getTypeName() + ")", e); + } + } +} diff --git a/inject-test/src/main/java/module-info.java b/inject-test/src/main/java/module-info.java index 6b4dbba67..beac8f325 100644 --- a/inject-test/src/main/java/module-info.java +++ b/inject-test/src/main/java/module-info.java @@ -14,6 +14,7 @@ requires static io.avaje.inject.aop; requires static io.avaje.inject.events; + uses io.avaje.inject.spi.InjectExtension; uses io.avaje.inject.test.TestModule; uses io.avaje.inject.test.Plugin; uses io.avaje.inject.test.LookupProvider; From 6c01849be9b2ec10fd7327fbafabe525a275959f Mon Sep 17 00:00:00 2001 From: Josiah Noel <32279667+SentryMan@users.noreply.github.com> Date: Fri, 28 Aug 2026 00:02:38 -0400 Subject: [PATCH 2/2] Pr comments --- .../multi/main/BoundedCustomModuleTest.java | 2 -- .../io/avaje/inject/test/GlobalTestBeans.java | 35 ++++++++++--------- .../java/io/avaje/inject/test/MetaInfo.java | 33 +++++++++-------- 3 files changed, 38 insertions(+), 32 deletions(-) diff --git a/blackbox-multi-scope/src/test/java/org/multi/main/BoundedCustomModuleTest.java b/blackbox-multi-scope/src/test/java/org/multi/main/BoundedCustomModuleTest.java index f8be54dc9..20a0751f9 100644 --- a/blackbox-multi-scope/src/test/java/org/multi/main/BoundedCustomModuleTest.java +++ b/blackbox-multi-scope/src/test/java/org/multi/main/BoundedCustomModuleTest.java @@ -15,8 +15,6 @@ @InjectTest(modules = Mod1Module.class) class BoundedCustomModuleTest { - static int constructedBefore; - @Inject BeanIn1 beanIn1; @Test diff --git a/inject-test/src/main/java/io/avaje/inject/test/GlobalTestBeans.java b/inject-test/src/main/java/io/avaje/inject/test/GlobalTestBeans.java index abb598771..44f67226e 100644 --- a/inject-test/src/main/java/io/avaje/inject/test/GlobalTestBeans.java +++ b/inject-test/src/main/java/io/avaje/inject/test/GlobalTestBeans.java @@ -104,29 +104,32 @@ void close() { } Plugin.Scope allPlugin() { - initAllBeans(); - return plugin; + allBeansLock.lock(); + try { + initAllBeans(); + return plugin; + } finally { + allBeansLock.unlock(); + } } BeanScope allBeans() { - initAllBeans(); - return allBeans; + allBeansLock.lock(); + try { + initAllBeans(); + return allBeans; + } finally { + allBeansLock.unlock(); + } } private void initAllBeans() { if (allBeans == null) { - allBeansLock.lock(); - try { - if (allBeans == null) { - log.log(DEBUG, "Wiring all beans for the test BeanScope"); - allBeans = BeanScope.builder() - .parent(baseBeans, false) - .build(); - plugin = PluginMgr.scope(allBeans); - } - } finally { - allBeansLock.unlock(); - } + log.log(DEBUG, "Wiring all beans for the test BeanScope"); + allBeans = BeanScope.builder() + .parent(baseBeans, false) + .build(); + plugin = PluginMgr.scope(allBeans); } } diff --git a/inject-test/src/main/java/io/avaje/inject/test/MetaInfo.java b/inject-test/src/main/java/io/avaje/inject/test/MetaInfo.java index a9c85ec84..2f1db6c2a 100644 --- a/inject-test/src/main/java/io/avaje/inject/test/MetaInfo.java +++ b/inject-test/src/main/java/io/avaje/inject/test/MetaInfo.java @@ -1,7 +1,5 @@ package io.avaje.inject.test; -import java.util.Optional; - import io.avaje.inject.BeanScope; import io.avaje.inject.BeanScopeBuilder; import io.avaje.inject.spi.AvajeModule; @@ -16,13 +14,28 @@ final class MetaInfo { private final MetaReader reader; private final Class[] modules; + private final String[] profiles; + private final boolean scopePerTest; MetaInfo(Class testClass, Plugin plugin) { this.reader = new MetaReader(testClass, plugin); - final InjectTest injectTest = testClass.getAnnotation(InjectTest.class); - // read from the test class rather than the instance so that the selected modules also - // bound the class level scope of a test that only has static injection + // read from the test class (walking enclosing classes for @Nested tests) rather than + // the instance so that the selected modules also bound the class level scope of a test + // that only has static injection + final InjectTest injectTest = injectTestAnnotation(testClass); this.modules = injectTest == null ? NO_MODULES : injectTest.modules(); + this.profiles = injectTest == null ? new String[0] : injectTest.profiles(); + this.scopePerTest = injectTest != null && injectTest.scopePerTest(); + } + + private static InjectTest injectTestAnnotation(Class testClass) { + for (Class c = testClass; c != null; c = c.getEnclosingClass()) { + final InjectTest injectTest = c.getAnnotation(InjectTest.class); + if (injectTest != null) { + return injectTest; + } + } + return null; } boolean hasStaticInjection() { @@ -54,17 +67,9 @@ private TestBeans buildSet(GlobalTestBeans.Beans parent, Object testInstance) { } private TestBeans buildTestBeans(GlobalTestBeans.Beans parent, Object testInstance) { - var injectTest = - Optional.ofNullable(testInstance) - .map(Object::getClass) - .map(c -> c.getAnnotation(InjectTest.class)); - - // wiring profiles - String[] profiles = injectTest.map(InjectTest::profiles).orElse(new String[0]); - if (profiles.length > 0 || modules.length > 0 - || injectTest.map(InjectTest::scopePerTest).orElse(false) + || scopePerTest || reader.hasMocksOrSpies(testInstance)) { // need to build a BeanScope for this using baseBeans() as the parent final BeanScopeBuilder builder = BeanScope.builder();