Repository: avaje/avaje-inject
Pull request #492 (2024-01-29 05:17:37)

Fix Field Assisted Inject
Fix cases where field assists generate incorrectly.

- now field assist will filter assisted elements by FIELD kind
- adds back validation logic for mismatched number of parameters
- support functional interfaces and abstract classes with non-abstract methods


Patch:
diff --git a/README.md b/README.md
index 20e6215a..cc6f7688 100644
--- a/README.md
+++ b/README.md
@@ -101,19 +101,19 @@ module org.example {
 
 ## Spring DI comparison
 
-| Spring | Avaje
+|  Avaje | Spring
 | :--- | :---  |
-| @Component, @Service, @Repository | [@Singleton](https://avaje.io/inject/#singleton) |
-| FactoryBean&lt;T> | [Provider&lt;T>](https://avaje.io/inject/#provider) |
-| @Inject, @Autowired | [@Inject](https://avaje.io/inject/#inject) |
-| @Autowired(required=false) |[@Inject @Nullable](https://avaje.io/inject/#nullable) or [@Inject Optional&lt;T>](https://avaje.io/inject/#optional) |
-| @PostConstruct| [@PostConstruct](https://avaje.io/inject/#post-construct) |
-| @PreDestroy| [@PreDestroy](https://avaje.io/inject/#pre-destroy) |
-| @Configuration and @Bean | [@Factory and @Bean](https://avaje.io/inject/#factory) |
-| @Conditional | [@RequiresBean and @RequiresProperty](https://avaje.io/inject/#conditional) |
-| @Primary | [@Primary](https://avaje.io/inject/#primary) |
-| @Secondary | [@Secondary](https://avaje.io/inject/#secondary) |
-| @Profile | [@Profile](https://avaje.io/inject/#profile) |
+| [@Singleton](https://avaje.io/inject/#singleton) | @Component, @Service, @Repository |
+| [Provider&lt;T>](https://avaje.io/inject/#provider) | FactoryBean&lt;T> |
+| [@Inject](https://avaje.io/inject/#inject) | @Inject, @Autowired |
+| [@Inject @Nullable](https://avaje.io/inject/#nullable) or [@Inject Optional&lt;T>](https://avaje.io/inject/#optional) | @Autowired(required=false) |
+| [@PostConstruct](https://avaje.io/inject/#post-construct) | @PostConstruct|
+| [@PreDestroy](https://avaje.io/inject/#pre-destroy) | @PreDestroy |
+| [@Factory and @Bean](https://avaje.io/inject/#factory) | @Configuration and @Bean |
+| [@RequiresBean and @RequiresProperty](https://avaje.io/inject/#conditional) | @Conditional |
+| [@Primary](https://avaje.io/inject/#primary) | @Primary |
+| [@Secondary](https://avaje.io/inject/#secondary) | @Secondary |
+| [@AssistFactory](https://avaje.io/inject/#assistInject) | - |
 
 ## Generated Code
 
diff --git a/inject-generator/src/main/java/io/avaje/inject/generator/AssistBeanReader.java b/inject-generator/src/main/java/io/avaje/inject/generator/AssistBeanReader.java
index 7a5c5995..49d2bcb6 100644
--- a/inject-generator/src/main/java/io/avaje/inject/generator/AssistBeanReader.java
+++ b/inject-generator/src/main/java/io/avaje/inject/generator/AssistBeanReader.java
@@ -1,12 +1,17 @@
 package io.avaje.inject.generator;
 
+import io.avaje.inject.generator.MethodReader.MethodParam;
+
+import javax.lang.model.element.*;
+import javax.lang.model.util.ElementFilter;
 import java.util.ArrayList;
 import java.util.List;
+import java.util.Optional;
 import java.util.Set;
 
-import javax.lang.model.element.*;
-
-import io.avaje.inject.generator.MethodReader.MethodParam;
+import static java.util.function.Predicate.not;
+import static java.util.stream.Collectors.joining;
+import static java.util.stream.Collectors.toSet;
 
 final class AssistBeanReader {
 
@@ -23,7 +28,7 @@ final class AssistBeanReader {
   private final TypeReader typeReader;
   private final TypeElement targetType;
   private final String qualifierName;
-  private ExecutableElement factoryMethod;
+  private final ExecutableElement factoryMethod;
 
   AssistBeanReader(TypeElement beanType) {
     this.beanType = beanType;
@@ -38,14 +43,7 @@ final class AssistBeanReader {
     this.constructor = typeReader.constructor();
 
     AssistFactoryPrism instanceOn = AssistFactoryPrism.getInstanceOn(beanType);
-    targetType = APContext.asTypeElement(instanceOn.value());
-    validateTarget(targetType);
-
-    for (Element enclosedElement : targetType.getEnclosedElements()) {
-      if (enclosedElement.getKind() == ElementKind.METHOD) {
-        factoryMethod = (ExecutableElement) enclosedElement;
-      }
-    }
+    var factoryType = APContext.asTypeElement(instanceOn.value());
 
     constructor.params().stream()
       .filter(MethodParam::assisted)
@@ -61,12 +59,65 @@ final class AssistBeanReader {
       .filter(MethodParam::assisted)
       .map(MethodParam::element)
       .forEach(assistedElements::add);
+
+    factoryMethod =
+      ElementFilter.methodsIn(factoryType.getEnclosedElements()).stream()
+        .filter(e -> e.getModifiers().contains(Modifier.ABSTRACT))
+        .findFirst()
+        .orElse(null);
+
+    validateTarget(factoryType);
+    this.targetType = factoryType;
   }
 
   private void validateTarget(TypeElement t) {
-    if (t.getKind() != ElementKind.INTERFACE || !t.getModifiers().contains(Modifier.ABSTRACT)) {
-      APContext.logError(type, "@AssistFactory targets must be abstract");
+    var methods = ElementFilter.methodsIn(t.getEnclosedElements());
+    if (!APContext.elements().isFunctionalInterface(t)) {
+      if (!t.getModifiers().contains(Modifier.ABSTRACT)) {
+        APContext.logError(type, "@AssistFactory targets must be abstract");
+      } else if (checkAbstractMethodCount(methods)) {
+        APContext.logError(type, "@AssistFactory targets must have only one abstract method");
+      }
     }
+    var sb = new StringBuilder(String.format("@AssistFactory targets for type %s must have an abstract method with form '%s <methodName>(", shortName(), shortName()));
+    var assistNames = new ArrayList<String>();
+    for (var iterator = assistedElements.iterator(); iterator.hasNext(); ) {
+      var element = iterator.next();
+      var typeName = UType.parse(element.asType());
+      sb.append(String.format("%s %s", typeName.shortWithoutAnnotations(), element.getSimpleName()));
+      if (iterator.hasNext()) {
+        sb.append(", ");
+      }
+      assistNames.add(String.format("%s %s", typeName.shortWithoutAnnotations(), element.getSimpleName()));
+    }
+    var errorMsg = sb.append(")' method.").toString();
+
+    Optional.ofNullable(factoryMethod).stream()
+      .map(ExecutableElement::getParameters)
+      .findAny()
+      .ifPresentOrElse(params -> {
+          var mismatched = params.size() != assistedElements.size();
+          if (mismatched) {
+            APContext.logError(t, errorMsg);
+            return;
+          }
+
+          var paramTypes = params.stream()
+            .map(v -> String.format("%s %s", UType.parse(v.asType()).shortWithoutAnnotations(), v.getSimpleName()))
+            .collect(toSet());
+
+          var missingParams = assistNames.stream().filter(not(paramTypes::contains)).collect(joining(", "));
+          if (!missingParams.isBlank()) {
+            APContext.logError(factoryMethod, "factory method missing required parameters: %s", missingParams);
+          }
+        },
+        () -> APContext.logError(t, errorMsg));
+  }
+
+  private static boolean checkAbstractMethodCount(List<ExecutableElement> methods) {
+    return methods.stream()
+      .filter(e -> e.getModifiers().contains(Modifier.ABSTRACT))
+      .count() != 1;
   }
 
   @Override
@@ -90,7 +141,8 @@ final class AssistBeanReader {
 
   private Set<String> importTypes() {
     importTypes.add("io.avaje.inject.AssistFactory");
-    importTypes.add(targetType.getQualifiedName().toString());
+    Optional.ofNullable(targetType).ifPresent(t -> importTypes.add(t.getQualifiedName().toString()));
+
     if (Util.validImportType(type)) {
       importTypes.add(type);
     }
diff --git a/inject-generator/src/main/java/io/avaje/inject/generator/SimpleAssistWriter.java b/inject-generator/src/main/java/io/avaje/inject/generator/SimpleAssistWriter.java
index 413794bd..c05f3a7c 100644
--- a/inject-generator/src/main/java/io/avaje/inject/generator/SimpleAssistWriter.java
+++ b/inject-generator/src/main/java/io/avaje/inject/generator/SimpleAssistWriter.java
@@ -1,19 +1,20 @@
 package io.avaje.inject.generator;
 
-import static io.avaje.inject.generator.APContext.createSourceFile;
-import static java.util.function.Predicate.not;
+import io.avaje.inject.generator.MethodReader.MethodParam;
 
+import javax.lang.model.element.*;
+import javax.tools.JavaFileObject;
 import java.io.IOException;
 import java.io.Writer;
 import java.util.List;
 import java.util.stream.Collectors;
 
-import javax.lang.model.element.*;
-import javax.tools.JavaFileObject;
-
-import io.avaje.inject.generator.MethodReader.MethodParam;
+import static io.avaje.inject.generator.APContext.createSourceFile;
+import static java.util.function.Predicate.not;
 
-/** Write the source code for the bean. */
+/**
+ * Write the source code for the bean.
+ */
 final class SimpleAssistWriter {
 
   private static final String CODE_COMMENT = "/**\n * Generated source - Factory for %s.\n */";
@@ -25,14 +26,20 @@ final class SimpleAssistWriter {
   private final String suffix;
   private Append writer;
   private final List<Element> assistedElements;
+  private final boolean hasNoConstructorParams;
 
-    SimpleAssistWriter(AssistBeanReader beanReader) {
+  SimpleAssistWriter(AssistBeanReader beanReader) {
     this.beanReader = beanReader;
     this.packageName = beanReader.packageName();
     this.shortName = beanReader.shortName();
     this.suffix = "$AssistFactory";
     this.assistedElements = beanReader.assistElements();
     this.originName = packageName + "." + shortName;
+    this.hasNoConstructorParams =
+      beanReader.constructor().params().stream()
+        .filter(not(MethodParam::assisted))
+        .findAny()
+        .isEmpty();
   }
 
   private Writer createFileWriter() throws IOException {
@@ -110,7 +117,9 @@ final class SimpleAssistWriter {
       var type = UType.parse(element.asType());
       writer.append("  %s %s$field;", type.shortType(), field.fieldName()).eol().eol();
     }
-    writer.eol();
+    if (beanReader.injectMethods().isEmpty() && hasNoConstructorParams) {
+      writer.eol();
+    }
   }
 
   private void writeMethodFields() {
@@ -120,15 +129,13 @@ final class SimpleAssistWriter {
     beanReader.injectMethods().stream()
       .flatMap(m -> m.params().stream())
       .filter(not(MethodParam::assisted))
-      .forEach(
-        p -> {
-          var element = p.element();
-          writer
-            .append("  private %s %s$method;", UType.parse(element.asType()).shortType(), p.simpleName())
-            .eol()
-            .eol();
-        });
-    writer.eol();
+      .forEach(p -> {
+        var element = p.element();
+        writer.append("  private %s %s$method;", UType.parse(element.asType()).shortType(), p.simpleName()).eol();
+      });
+    if (hasNoConstructorParams) {
+      writer.eol();
+    }
   }
 
   private void writeConstructor() {
@@ -146,7 +153,7 @@ final class SimpleAssistWriter {
     if (beanReader.beanType().getNestingKind().isNested()) {
       shortName = shortName.replace(".", "$");
     }
-    writer.append("  ").append(shortName).append(suffix).append("(");
+    writer.eol().append("  ").append(shortName).append(suffix).append("(");
 
     for (var iterator = injectParams.iterator(); iterator.hasNext(); ) {
       var p = iterator.next();
@@ -170,7 +177,7 @@ final class SimpleAssistWriter {
     for (MethodParam p : injectParams) {
       var element = p.element();
       var type = UType.parse(element.asType()).shortType();
-      writer.append("  private final %s %s;", type, p.simpleName()).eol().eol();
+      writer.append("  private final %s %s;", type, p.simpleName()).eol();
     }
   }
 
@@ -229,9 +236,13 @@ final class SimpleAssistWriter {
       writer.indent("    ").append("bean.%s = %s;", fieldName, getDependency).eol();
     }
 
-    for (var field : assistedElements) {
-      writer.indent("    ").append("bean.%s = %s;", field.getSimpleName(), field.getSimpleName()).eol();
-    }
+    assistedElements.stream()
+      .filter(e -> e.getKind() == ElementKind.FIELD)
+      .forEach(field ->
+        writer
+          .indent("    ")
+          .append("bean.%s = %s;", field.getSimpleName(), field.getSimpleName())
+          .eol());
   }
 
   private void injectMethods() {
diff --git a/inject/src/main/java/io/avaje/inject/AssistFactory.java b/inject/src/main/java/io/avaje/inject/AssistFactory.java
index b0e263cc..e10e1473 100644
--- a/inject/src/main/java/io/avaje/inject/AssistFactory.java
+++ b/inject/src/main/java/io/avaje/inject/AssistFactory.java
@@ -6,42 +6,37 @@ import java.lang.annotation.RetentionPolicy;
 import java.lang.annotation.Target;
 
 /**
- * Annotate a bean which we want avaje-inject to generate the factory for.
- * <p>
- * The bean will have some properties that are normal dependency injection
- * components that will be injected and others that will be parameters
- * to the method on the factory - these parameters are annotated with
+ * Annotate a bean for which we want avaje-inject to generate a factory.
+ *
+ * <p>The bean will have some properties of normal dependency injection components and others that
+ * will be parameters to the method on the factory - these parameters are annotated with
  * {@code @Assisted}.
  *
  * <h3>Example</h3>
- * <p>
- * Have an interface which we want avaje-inject to generate an implementation.
- * We desire this because the factory has some dependencies that are components
- * that avaje-inject should inject for us and some other dependencies that are
- * provided by the application (and annotated with {@code Assisted}).
- * <p>
- * The factory interface must only have 1 method defined.
  *
- * <pre>{@code
+ * <p>Create an interface which we want avaje-inject to generate an implementation. We desire this
+ * because the factory has some managed dependencies that should be injected for us and some
+ * dependencies that are provided by the application (and annotated with {@code Assisted}).
  *
+ * <p>The factory supertype must be a functional interface, or an abstract class with only one
+ * abstract method defined.
+ *
+ * <pre>{@code
  * public interface CssFactory {
  *
  *   Scanner scanner(Path myPath);
  * }
  *
  * }</pre>
- * <p>
- * Have a bean annotated with {@code @AssistFactory} that specifies the factory.
- * Any dependencies that are annotated with {@code Assisted} will be parameters
- * of the factory method, the other dependencies are normal components that
- * will be injected.
- * <p>
- * The {@code Assisted} parameters must match the factory method parameters
- * by name and type. In this example {@code Path myPath} match in both CssScanner
- * and in CssFactory.
  *
- * <pre>{@code
+ * <p>Create a bean annotated with {@code @AssistFactory} that specifies the factory. Any
+ * dependencies that are annotated with {@code Assisted} will be parameters of the factory method,
+ * the other dependencies will be managed and injected by avaje-inject.
+ *
+ * <p>The {@code Assisted} parameters must match the factory method parameters by name and type. In
+ * this example, {@code Path myPath} match in both CssScanner and in CssFactory.
  *
+ * <pre>{@code
  * @AssistFactory(CssFactory.class)
  * class CssScanner implements Scanner {
  *
@@ -62,8 +57,6 @@ import java.lang.annotation.Target;
 @Retention(RetentionPolicy.RUNTIME)
 public @interface AssistFactory {
 
-  /**
-   * Specify the factory interface for which the implementation will be generated.
-   */
+  /** Specify the factory interface for which the implementation will be generated. */
   Class<?> value();
 }
diff --git a/inject/src/main/java/io/avaje/inject/spi/DBeanMap.java b/inject/src/main/java/io/avaje/inject/spi/DBeanMap.java
index f2c5622b..d3fb9194 100644
--- a/inject/src/main/java/io/avaje/inject/spi/DBeanMap.java
+++ b/inject/src/main/java/io/avaje/inject/spi/DBeanMap.java
@@ -139,8 +139,15 @@ final class DBeanMap {
     if (parent == null) {
       return map(type);
     }
-    Map<String, Object> result = parent.map(type);
-    result.putAll(map(type));
+    Map<String, Object> parentMap = parent.map(type);
+    Map<String, Object> localMap = map(type);
+    if (parentMap.isEmpty()) {
+      return localMap;
+    } else if (localMap.isEmpty()) {
+      return parentMap;
+    }
+    Map<String, Object> result = new LinkedHashMap<>(parentMap);
+    result.putAll(localMap);
     return result;
   }
 


Test patch:
diff --git a/blackbox-test-inject/src/main/java/org/example/myapp/assist/droid/ACar.java b/blackbox-test-inject/src/main/java/org/example/myapp/assist/droid/ACar.java
new file mode 100644
index 00000000..03ad6b78
--- /dev/null
+++ b/blackbox-test-inject/src/main/java/org/example/myapp/assist/droid/ACar.java
@@ -0,0 +1,30 @@
+package org.example.myapp.assist.droid;
+
+import java.util.List;
+
+import io.avaje.inject.AssistFactory;
+import io.avaje.inject.Assisted;
+import io.avaje.lang.Nullable;
+import jakarta.inject.Inject;
+import jakarta.inject.Named;
+
+@Named("tomato")
+@AssistFactory(ACarFactory.class)
+public class ACar {
+
+  final Paint paint;
+  final Engine engine;
+  @Assisted List<String> type;
+  @Inject Wheel wheel;
+  Radio radio;
+
+  public ACar(@Assisted Paint paint, @Nullable Engine engine) {
+    this.paint = paint;
+    this.engine = engine;
+  }
+
+  @Inject
+  void injectMethod(@Assisted int size, Radio radio) {
+    this.radio = radio;
+  }
+}
diff --git a/blackbox-test-inject/src/main/java/org/example/myapp/assist/droid/ACarFactory.java b/blackbox-test-inject/src/main/java/org/example/myapp/assist/droid/ACarFactory.java
new file mode 100644
index 00000000..3a848d9e
--- /dev/null
+++ b/blackbox-test-inject/src/main/java/org/example/myapp/assist/droid/ACarFactory.java
@@ -0,0 +1,10 @@
+package org.example.myapp.assist.droid;
+
+import java.util.List;
+
+public abstract class ACarFactory {
+
+  public abstract ACar construct(Paint paint, int size, List<String> type);
+
+  public void nonFactory() {}
+}
diff --git a/blackbox-test-inject/src/main/java/org/example/myapp/assist/droid/ACarThing.java b/blackbox-test-inject/src/main/java/org/example/myapp/assist/droid/ACarThing.java
new file mode 100644
index 00000000..f821b0d6
--- /dev/null
+++ b/blackbox-test-inject/src/main/java/org/example/myapp/assist/droid/ACarThing.java
@@ -0,0 +1,19 @@
+package org.example.myapp.assist.droid;
+
+import jakarta.inject.Singleton;
+
+import java.util.List;
+
+@Singleton
+class ACarThing {
+
+  final ACarFactory factory;
+
+  ACarThing(ACarFactory factory) {
+    this.factory = factory;
+  }
+
+  ACar doIt(Paint paint, int size, List<String> other) {
+    return factory.construct(paint, size, other);
+  }
+}
diff --git a/blackbox-test-inject/src/main/java/org/example/myapp/assist/droid/DroidFactory.java b/blackbox-test-inject/src/main/java/org/example/myapp/assist/droid/DroidFactory.java
index 00fadb5c..e32b4056 100644
--- a/blackbox-test-inject/src/main/java/org/example/myapp/assist/droid/DroidFactory.java
+++ b/blackbox-test-inject/src/main/java/org/example/myapp/assist/droid/DroidFactory.java
@@ -4,6 +4,8 @@ public interface DroidFactory {
 
   Droid createDroid(int personality, Model model);
 
+  default void defaultMethod() {}
+
   interface Droid {
 
     int personality();
diff --git a/blackbox-test-inject/src/main/java/org/example/myapp/assist/droid/Wheel.java b/blackbox-test-inject/src/main/java/org/example/myapp/assist/droid/Wheel.java
new file mode 100644
index 00000000..dc983973
--- /dev/null
+++ b/blackbox-test-inject/src/main/java/org/example/myapp/assist/droid/Wheel.java
@@ -0,0 +1,6 @@
+package org.example.myapp.assist.droid;
+
+import jakarta.inject.Singleton;
+
+@Singleton
+public class Wheel {}
diff --git a/blackbox-test-inject/src/test/java/org/example/myapp/assist/droid/ACarTest.java b/blackbox-test-inject/src/test/java/org/example/myapp/assist/droid/ACarTest.java
new file mode 100644
index 00000000..a13c0a6d
--- /dev/null
+++ b/blackbox-test-inject/src/test/java/org/example/myapp/assist/droid/ACarTest.java
@@ -0,0 +1,27 @@
+package org.example.myapp.assist.droid;
+
+import io.avaje.inject.BeanScope;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class ACarTest {
+
+  @Test
+  void test() {
+    try (BeanScope testScope = BeanScope.builder().build()) {
+      ACarThing carThing = testScope.get(ACarThing.class);
+      Paint p = new Paint() {};
+      List<String> stringList = List.of("a");
+      ACar aCar = carThing.doIt(p, 42, stringList);
+
+      assertThat(aCar.paint).isSameAs(p);
+      assertThat(aCar.type).isSameAs(stringList);
+      assertThat(aCar.engine).isNotNull();
+      assertThat(aCar.wheel).isNotNull();
+      assertThat(aCar.radio).isNotNull();
+    }
+  }
+}
diff --git a/inject-test/src/main/java/io/avaje/inject/test/GlobalTestScope.java b/inject-test/src/main/java/io/avaje/inject/test/GlobalTestScope.java
index 40f03734..24824be4 100644
--- a/inject-test/src/main/java/io/avaje/inject/test/GlobalTestScope.java
+++ b/inject-test/src/main/java/io/avaje/inject/test/GlobalTestScope.java
@@ -12,15 +12,15 @@ import static java.lang.System.Logger.Level.TRACE;
 /**
  * Holds the global BeanScope used for all tests.
  */
-final class GlobalTestScope implements  ExtensionContext.Store.CloseableResource {
+final class GlobalTestScope implements ExtensionContext.Store.CloseableResource {
 
   private static final System.Logger log = AppLog.getLogger("io.avaje.inject");
 
   private final ReentrantLock lock = new ReentrantLock();
   private boolean started;
-  private BeanScope globalBeanScope;
+  private Pair globalBeanScope;
 
-  BeanScope obtain(ExtensionContext context) {
+  Pair obtain(ExtensionContext context) {
     lock.lock();
     try {
       if (!started) {
@@ -34,11 +34,9 @@ final class GlobalTestScope implements  ExtensionContext.Store.CloseableResource
   }
 
   private void initialise(ExtensionContext context) {
-    globalBeanScope = TestBeanScope.init(false);
-    if (globalBeanScope != null) {
-      log.log(TRACE, "register global test BeanScope with beans {0}", globalBeanScope);
-      context.getRoot().getStore(ExtensionContext.Namespace.GLOBAL).put(InjectExtension.class.getCanonicalName(), this);
-    }
+    globalBeanScope = TSBuild.initialise(false);
+    log.log(TRACE, "register global test BeanScope with beans {0}", globalBeanScope);
+    context.getRoot().getStore(ExtensionContext.Namespace.GLOBAL).put(InjectExtension.class.getCanonicalName(), this);
   }
 
   /**
@@ -57,4 +55,55 @@ final class GlobalTestScope implements  ExtensionContext.Store.CloseableResource
     }
   }
 
+  /**
+   * The pair of BeanScopes that can be used for InjectTests.
+   */
+  static final class Pair {
+
+    /**
+     * 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.
+     */
+    private final BeanScope allScope;
+
+    /**
+     * The TestScope beans, used as the parent scope when a new BeanScope
+     * needs to be wired for a test (due to mocks, spies or setup methods).
+     */
+    private final BeanScope baseScope;
+
+    Pair(BeanScope allScope, BeanScope baseScope) {
+      this.allScope = allScope;
+      this.baseScope = baseScope;
+    }
+
+    void close() {
+      if (allScope != null) {
+        allScope.close();
+      }
+      if (baseScope != null) {
+        baseScope.close();
+      }
+    }
+
+    BeanScope allScope() {
+      return allScope;
+    }
+
+    BeanScope baseScope() {
+      return baseScope;
+    }
+
+    Pair newPair(BeanScope newAllScope) {
+      return new Pair(newAllScope, baseScope);
+    }
+
+    @Override
+    public String toString() {
+      return "All[" + allScope + "] Test[" + baseScope + "]";
+    }
+
+  }
+
 }
diff --git a/inject-test/src/main/java/io/avaje/inject/test/InjectExtension.java b/inject-test/src/main/java/io/avaje/inject/test/InjectExtension.java
index a01c71fd..52bbca2e 100644
--- a/inject-test/src/main/java/io/avaje/inject/test/InjectExtension.java
+++ b/inject-test/src/main/java/io/avaje/inject/test/InjectExtension.java
@@ -21,7 +21,7 @@ public final class InjectExtension implements BeforeAllCallback, AfterAllCallbac
   private static final String META = "META";
   private static final GlobalTestScope GLOBAL = new GlobalTestScope();
 
-  private BeanScope globalBeanScope;
+  private GlobalTestScope.Pair globalBeanScope;
 
   @Override
   public void beforeAll(ExtensionContext context) {
@@ -64,9 +64,8 @@ public final class InjectExtension implements BeforeAllCallback, AfterAllCallbac
     if (metaInfo.hasInstanceInjection()) {
 
       // if (static fields) then (class scope) else (globalTestScope)
-      final BeanScope parent = metaInfo.hasStaticInjection() ? getClassScope(context) : globalBeanScope;
-
-      AutoCloseable beanScope = metaInfo.buildForInstance(parent, context.getRequiredTestInstance());
+      final GlobalTestScope.Pair pair = metaInfo.hasStaticInjection() ? globalBeanScope.newPair(getClassScope(context)) : globalBeanScope;
+      AutoCloseable beanScope = metaInfo.buildForInstance(pair, context.getRequiredTestInstance());
 
       // put method level test scope
       Method testMethod = context.getRequiredTestMethod();
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 02d2ae63..7c785dc9 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
@@ -27,38 +27,48 @@ final class MetaInfo {
   /**
    * Build for static fields class level scope.
    */
-  Scope buildForClass(BeanScope globalTestScope) {
+  Scope buildForClass(GlobalTestScope.Pair globalTestScope) {
     return buildSet(globalTestScope, null);
   }
 
   /**
    * Build test instance per test scope.
    */
-  Scope buildForInstance(BeanScope globalTestScope, Object testInstance) {
+  Scope buildForInstance(GlobalTestScope.Pair globalTestScope, Object testInstance) {
     return buildSet(globalTestScope, testInstance);
   }
 
-  private Scope buildSet(BeanScope parent, Object testInstance) {
-    final BeanScopeBuilder builder = BeanScope.builder();
-    if (parent != null) {
-      builder.parent(parent, false);
+  private Scope buildSet(GlobalTestScope.Pair parent, Object testInstance) {
+    // wiring profiles
+    String[] profiles = Optional.ofNullable(testInstance)
+      .map(Object::getClass)
+      .map(c -> c.getAnnotation(InjectTest.class))
+      .map(InjectTest::profiles)
+      .orElse(new String[0]);
+
+    boolean newScope = false;
+    final BeanScope beanScope;
+    if (profiles.length > 0 || reader.hasMocksOrSpies(testInstance)) {
+      // need to build a BeanScope for this using testScope() as the parent
+      final BeanScopeBuilder builder = BeanScope.builder();
+      if (parent != null) {
+        builder.parent(parent.baseScope(), false);
+        if (profiles.length > 0) {
+          builder.profiles(profiles);
+        }
+      }
+      // register mocks and spies local to this test
+      reader.build(builder, testInstance);
+      // wire with local mocks, spies, and globalTestScope
+      beanScope = builder.build();
+      newScope = true;
+    } else {
+      // just use the all scope
+      beanScope = parent.allScope();
     }
 
-    //set wiring profile
-    Optional.ofNullable(testInstance)
-        .map(Object::getClass)
-        .map(c -> c.getAnnotation(InjectTest.class))
-        .map(InjectTest::profiles)
-        .ifPresent(builder::profiles);
-
-    // register mocks and spies local to this test
-    reader.build(builder, testInstance);
-
-    // wire with local mocks, spies, and globalTestScope
-    final BeanScope beanScope = builder.build();
-
     // set inject, spy, mock fields from beanScope
-    return reader.setFromScope(beanScope, testInstance);
+    return reader.setFromScope(beanScope, testInstance, newScope);
   }
 
   /**
@@ -69,10 +79,12 @@ final class MetaInfo {
 
     private final BeanScope beanScope;
     private final Plugin.Scope pluginScope;
+    private final boolean newScope;
 
-    Scope(BeanScope beanScope, Plugin.Scope pluginScope) {
+    Scope(BeanScope beanScope, Plugin.Scope pluginScope, boolean newScope) {
       this.beanScope = beanScope;
       this.pluginScope = pluginScope;
+      this.newScope = newScope;
     }
 
     BeanScope beanScope() {
@@ -81,7 +93,9 @@ final class MetaInfo {
 
     @Override
     public void close() {
-      beanScope.close();
+      if (newScope) {
+        beanScope.close();
+      }
       if (pluginScope != null) {
         pluginScope.close();
       }
diff --git a/inject-test/src/main/java/io/avaje/inject/test/MetaReader.java b/inject-test/src/main/java/io/avaje/inject/test/MetaReader.java
index ebf7b23d..e0b1b6df 100644
--- a/inject-test/src/main/java/io/avaje/inject/test/MetaReader.java
+++ b/inject-test/src/main/java/io/avaje/inject/test/MetaReader.java
@@ -17,6 +17,7 @@ import java.lang.reflect.Field;
 import java.lang.reflect.Modifier;
 import java.lang.reflect.Type;
 import java.util.ArrayList;
+import java.util.LinkedList;
 import java.util.List;
 
 final class MetaReader {
@@ -38,10 +39,50 @@ final class MetaReader {
 
   MetaReader(Class<?> testClass, Plugin plugin) {
     this.plugin = plugin;
-    this.methodFinder = new SetupMethods(testClass);
-    for (Field field : testClass.getDeclaredFields()) {
-      readField(field);
+    final var hierarchy = typeHierarchy(testClass);
+    this.methodFinder = new SetupMethods(hierarchy);
+    for (Class<?> aTestClass : hierarchy) {
+      for (Field field : aTestClass.getDeclaredFields()) {
+        readField(field);
+      }
+    }
+  }
+
+  boolean hasMocksOrSpies(Object testInstance) {
+    if (testInstance == null) {
+      return hasStaticMocksOrSpies() || methodFinder.hasStaticMethods();
+    } else {
+      return hasInstanceMocksOrSpies(testInstance) || methodFinder.hasInstanceMethods();
+    }
+  }
+
+  private boolean hasInstanceMocksOrSpies(Object testInstance) {
+    return !mocks.isEmpty() || !spies.isEmpty() || hasInjectMock(injection, testInstance);
+  }
+
+  private boolean hasStaticMocksOrSpies() {
+    return !staticMocks.isEmpty() || !staticSpies.isEmpty() || hasInjectMock(staticInjection, null);
+  }
+
+  private boolean hasInjectMock(List<FieldTarget> fields, Object testInstance) {
+    for (FieldTarget target : fields) {
+      Object existingValue = target.get(testInstance);
+      if (existingValue != null) {
+        // an assigned injection field is a mock
+        return true;
+      }
+    }
+    return false;
+  }
+
+  private static LinkedList<Class<?>> typeHierarchy(Class<?> testClass) {
+    var hierarchy = new LinkedList<Class<?>>();
+    var analyzedClass = testClass;
+    while (analyzedClass != null && !analyzedClass.equals(Object.class)) {
+      hierarchy.addFirst(analyzedClass);
+      analyzedClass = analyzedClass.getSuperclass();
     }
+    return hierarchy;
   }
 
   boolean hasClassInjection() {
@@ -128,15 +169,15 @@ final class MetaReader {
     return null;
   }
 
-  MetaInfo.Scope setFromScope(BeanScope beanScope, Object testInstance) {
+  MetaInfo.Scope setFromScope(BeanScope beanScope, Object testInstance, boolean newScope) {
     if (testInstance != null) {
-      return setForInstance(beanScope, testInstance);
+      return setForInstance(beanScope, testInstance, newScope);
     } else {
-      return setForStatics(beanScope);
+      return setForStatics(beanScope, newScope);
     }
   }
 
-  private MetaInfo.Scope setForInstance(BeanScope beanScope, Object testInstance) {
+  private MetaInfo.Scope setForInstance(BeanScope beanScope, Object testInstance, boolean newScope) {
     try {
       Plugin.Scope pluginScope = instancePlugin ? plugin.createScope(beanScope) : null;
 
@@ -157,14 +198,14 @@ final class MetaReader {
           target.setFromScope(beanScope, testInstance);
         }
       }
-      return new MetaInfo.Scope(beanScope, pluginScope);
+      return new MetaInfo.Scope(beanScope, pluginScope, newScope);
 
     } catch (IllegalAccessException e) {
       throw new RuntimeException(e);
     }
   }
 
-  private MetaInfo.Scope setForStatics(BeanScope beanScope) {
+  private MetaInfo.Scope setForStatics(BeanScope beanScope, boolean newScope) {
     try {
       Plugin.Scope pluginScope = staticPlugin ? plugin.createScope(beanScope) : null;
 
@@ -182,7 +223,7 @@ final class MetaReader {
           target.setFromScope(beanScope, null);
         }
       }
-      return new MetaInfo.Scope(beanScope, pluginScope);
+      return new MetaInfo.Scope(beanScope, pluginScope, newScope);
     } catch (IllegalAccessException e) {
       throw new RuntimeException(e);
     }
diff --git a/inject-test/src/main/java/io/avaje/inject/test/SetupMethods.java b/inject-test/src/main/java/io/avaje/inject/test/SetupMethods.java
index 1007d045..90e0a278 100644
--- a/inject-test/src/main/java/io/avaje/inject/test/SetupMethods.java
+++ b/inject-test/src/main/java/io/avaje/inject/test/SetupMethods.java
@@ -14,13 +14,7 @@ final class SetupMethods {
   private final List<Method> staticMethods = new ArrayList<>();
   private final List<Method> instanceMethods = new ArrayList<>();
 
-  SetupMethods(Class<?> testClass) {
-    var analyzedClass = testClass;
-    var hierarchy = new LinkedList<Class<?>>();
-    while (analyzedClass != null && !analyzedClass.equals(Object.class)) {
-      hierarchy.addFirst(analyzedClass);
-      analyzedClass = analyzedClass.getSuperclass();
-    }
+  SetupMethods(LinkedList<Class<?>> hierarchy) {
     for (Class<?> aClass : hierarchy) {
       for (Method method : aClass.getDeclaredMethods()) {
         if (method.getDeclaredAnnotation(Setup.class) != null) {
diff --git a/inject-test/src/main/java/io/avaje/inject/test/TSBuild.java b/inject-test/src/main/java/io/avaje/inject/test/TSBuild.java
index f889dd1e..ecbc1332 100644
--- a/inject-test/src/main/java/io/avaje/inject/test/TSBuild.java
+++ b/inject-test/src/main/java/io/avaje/inject/test/TSBuild.java
@@ -21,7 +21,7 @@ import java.util.concurrent.locks.ReentrantLock;
 final class TSBuild {
 
   private static final ReentrantLock lock = new ReentrantLock();
-  private static BeanScope SCOPE;
+  private static GlobalTestScope.Pair SCOPE;
 
   private final boolean shutdownHook;
 
@@ -30,19 +30,18 @@ final class TSBuild {
    * time this method is called.
    */
   @Nullable
-  static BeanScope create(boolean shutdownHook) {
+  static BeanScope createTestBaseScope(boolean shutdownHook) {
     return new TSBuild(shutdownHook).build();
   }
 
   /**
    * Return the test BeanScope only creating once.
    */
-  @Nullable
-  static BeanScope initialise(boolean shutdownHook) {
+  static GlobalTestScope.Pair initialise(boolean shutdownHook) {
     lock.lock();
     try {
       if (SCOPE == null) {
-        SCOPE = create(shutdownHook);
+        SCOPE = createScopes(shutdownHook);
       }
       return SCOPE;
     } finally {
@@ -54,6 +53,18 @@ final class TSBuild {
     this.shutdownHook = shutdownHook;
   }
 
+  private static GlobalTestScope.Pair createScopes(boolean shutdownHook) {
+    BeanScope testBaseScope = createTestBaseScope(shutdownHook);
+    BeanScope testAllScope = createTestAllScope(testBaseScope);
+    return new GlobalTestScope.Pair(testAllScope, testBaseScope);
+  }
+
+  private static BeanScope createTestAllScope(BeanScope testBaseScope) {
+      return BeanScope.builder()
+        .parent(testBaseScope, false)
+        .build();
+  }
+
   @Nullable
   private BeanScope build() {
     List<TestModule> testModules = new ArrayList<>();
diff --git a/inject-test/src/main/java/io/avaje/inject/test/TestBeanScope.java b/inject-test/src/main/java/io/avaje/inject/test/TestBeanScope.java
index 767115af..6c7a698a 100644
--- a/inject-test/src/main/java/io/avaje/inject/test/TestBeanScope.java
+++ b/inject-test/src/main/java/io/avaje/inject/test/TestBeanScope.java
@@ -38,8 +38,7 @@ public abstract class TestBeanScope {
    * @return A new test BeanScope with the global "test scope" as its parent.
    */
   public static BeanScopeBuilder builder() {
-    BeanScope globalTestScope = init(true);
-    return BeanScope.builder().parent(globalTestScope, false);
+    return BeanScope.builder().parent(initialise(), false);
   }
 
   /**
@@ -60,7 +59,7 @@ public abstract class TestBeanScope {
    */
   @Nullable
   public static BeanScope initialise() {
-    return init(true);
+    return TSBuild.initialise(true).baseScope();
   }
 
   /**
@@ -74,12 +73,7 @@ public abstract class TestBeanScope {
    */
   @Nullable
   public static BeanScope create(boolean shutdownHook) {
-    return TSBuild.create(shutdownHook);
-  }
-
-  @Nullable
-  static BeanScope init(boolean shutdownHook) {
-    return TSBuild.initialise(shutdownHook);
+    return TSBuild.createTestBaseScope(shutdownHook);
   }
 
 }
diff --git a/inject-test/src/test/java/io/avaje/inject/test/MetaReaderTest.java b/inject-test/src/test/java/io/avaje/inject/test/MetaReaderTest.java
index 321dbe67..8b28c025 100644
--- a/inject-test/src/test/java/io/avaje/inject/test/MetaReaderTest.java
+++ b/inject-test/src/test/java/io/avaje/inject/test/MetaReaderTest.java
@@ -39,7 +39,7 @@ class MetaReaderTest {
     assertThat(metaReader.instancePlugin).isTrue();
 
     HelloBean helloBean = new HelloBean();
-    metaReader.setFromScope(Mockito.mock(BeanScope.class), helloBean);
+    metaReader.setFromScope(Mockito.mock(BeanScope.class), helloBean, true);
 
     assertThat(helloBean.client).isNotNull();
   }
diff --git a/inject-test/src/test/java/org/example/coffee/BeanScopeBuilderAddTest.java b/inject-test/src/test/java/org/example/coffee/BeanScopeBuilderAddTest.java
index 283a2556..91dd0e47 100644
--- a/inject-test/src/test/java/org/example/coffee/BeanScopeBuilderAddTest.java
+++ b/inject-test/src/test/java/org/example/coffee/BeanScopeBuilderAddTest.java
@@ -12,7 +12,7 @@ import static org.assertj.core.api.Assertions.assertThat;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.mockito.Mockito.verify;
 
-class BeanScopeBuilderAddTest {
+public class BeanScopeBuilderAddTest {
 
   @Test
   void withModules_excludingThisOne() {
@@ -28,7 +28,7 @@ class BeanScopeBuilderAddTest {
     }
   }
 
-  static class SillyModule implements Module {
+  public static class SillyModule implements Module {
 
     @Override
     public Class<?>[] requires() {
diff --git a/inject-test/src/test/java/org/example/coffee/qualifier/StoreManagerWithNamedTest.java b/inject-test/src/test/java/org/example/coffee/qualifier/StoreManagerWithNamedTest.java
index 769ecc11..1dde3bfc 100644
--- a/inject-test/src/test/java/org/example/coffee/qualifier/StoreManagerWithNamedTest.java
+++ b/inject-test/src/test/java/org/example/coffee/qualifier/StoreManagerWithNamedTest.java
@@ -2,6 +2,7 @@ package org.example.coffee.qualifier;
 
 import io.avaje.inject.BeanScope;
 import org.example.autonamed.MyAutoB2;
+import org.example.coffee.BeanScopeBuilderAddTest;
 import org.junit.jupiter.api.Test;
 
 import java.util.Map;
@@ -31,4 +32,25 @@ class StoreManagerWithNamedTest {
       assertThat(mapWithUnnamed).hasSize(1);
     }
   }
+
+  @Test
+  void mapParent() {
+    try (BeanScope parent = BeanScope.builder()
+      .bean(SomeStore.class, new LocalStore())
+      .modules(new BeanScopeBuilderAddTest.SillyModule())
+      .build()) {
+
+      try (BeanScope beanScope = BeanScope.builder().parent(parent).build()) {
+        Map<String, SomeStore> stores = beanScope.map(SomeStore.class);
+        assertThat(stores).hasSize(3);
+      }
+    }
+  }
+
+  static final class LocalStore implements SomeStore {
+    @Override
+    public String store() {
+      return "foo";
+    }
+  }
 }
diff --git a/inject-test/src/test/java/org/example/inherit/MyOneAbstract.java b/inject-test/src/test/java/org/example/inherit/MyOneAbstract.java
new file mode 100644
index 00000000..055486b4
--- /dev/null
+++ b/inject-test/src/test/java/org/example/inherit/MyOneAbstract.java
@@ -0,0 +1,10 @@
+package org.example.inherit;
+
+import jakarta.inject.Inject;
+import org.example.coffee.core.Steamer;
+
+public abstract class MyOneAbstract {
+
+  @Inject
+  Steamer steamer;
+}
diff --git a/inject-test/src/test/java/org/example/inherit/MyOneTest.java b/inject-test/src/test/java/org/example/inherit/MyOneTest.java
new file mode 100644
index 00000000..97358843
--- /dev/null
+++ b/inject-test/src/test/java/org/example/inherit/MyOneTest.java
@@ -0,0 +1,22 @@
+package org.example.inherit;
+
+import io.avaje.inject.test.InjectExtension;
+import jakarta.inject.Inject;
+import org.example.inherit.notpublic.PubExposed;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+@ExtendWith(InjectExtension.class)
+public class MyOneTest extends MyOneAbstract {
+
+  @Inject
+  PubExposed something;
+
+  @Test
+  void test_expect_inheritedFieldIsInjected() {
+    assertThat(something).isNotNull();
+    assertThat(steamer).isNotNull();
+  }
+}