diff --git a/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-testbean.adoc b/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-testbean.adoc index 3712daa068e..1b4d66909a0 100644 --- a/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-testbean.adoc +++ b/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-testbean.adoc @@ -78,5 +78,12 @@ Java:: <2> The result of this static method will be used as the instance and injected into the field. ====== -NOTE: Spring searches for the factory method to invoke in the test class, in the test -class hierarchy, and in the enclosing class hierarchy for a `@Nested` test class. +[NOTE] +==== +Spring searches for the factory method to invoke in the test class, in the test class +hierarchy, and in the enclosing class hierarchy for a `@Nested` test class. + +Alternatively, a factory method in an external class can be referenced via its +fully-qualified method name following the syntax `#` +– for example, `methodName = "org.example.TestUtils#createCustomService"`. +==== diff --git a/spring-test/src/main/java/org/springframework/test/context/bean/override/convention/TestBean.java b/spring-test/src/main/java/org/springframework/test/context/bean/override/convention/TestBean.java index a94ee7a9bcc..03d23ef9f80 100644 --- a/spring-test/src/main/java/org/springframework/test/context/bean/override/convention/TestBean.java +++ b/spring-test/src/main/java/org/springframework/test/context/bean/override/convention/TestBean.java @@ -40,8 +40,12 @@ import org.springframework.test.context.bean.override.BeanOverride; * test class whose return type is compatible with the annotated field. In the * case of a nested test class, the enclosing class hierarchy is also searched. * Similarly, if the test class extends from a base class or implements any - * interfaces, the entire type hierarchy is searched. The method is deduced as - * follows. + * interfaces, the entire type hierarchy is searched. Alternatively, a factory + * method in an external class can be referenced via its fully-qualified method + * name following the syntax {@code #} + * — for example, {@code "org.example.TestUtils#createCustomerRepository"}. + * + *

The factory method is deduced as follows. * *

    *
  • If the {@link #methodName()} is specified, look for a static method with @@ -125,6 +129,10 @@ public @interface TestBean { *

    A search will be performed to find the factory method in the test class, * in one of its superclasses, or in any implemented interfaces. In the case * of a nested test class, the enclosing class hierarchy will also be searched. + *

    Alternatively, a factory method in an external class can be referenced + * via its fully-qualified method name following the syntax + * {@code #} — for example, + * {@code "org.example.TestUtils#createCustomerRepository"}. *

    If left unspecified, the name of the factory method will be detected * based either on the name of the annotated field or the name of the bean. */ diff --git a/spring-test/src/main/java/org/springframework/test/context/bean/override/convention/TestBeanOverrideProcessor.java b/spring-test/src/main/java/org/springframework/test/context/bean/override/convention/TestBeanOverrideProcessor.java index 2769001a48d..b3fdabc3a0a 100644 --- a/spring-test/src/main/java/org/springframework/test/context/bean/override/convention/TestBeanOverrideProcessor.java +++ b/spring-test/src/main/java/org/springframework/test/context/bean/override/convention/TestBeanOverrideProcessor.java @@ -32,6 +32,8 @@ import org.springframework.core.ResolvableType; import org.springframework.test.context.TestContextAnnotationUtils; import org.springframework.test.context.bean.override.BeanOverrideProcessor; import org.springframework.util.Assert; +import org.springframework.util.ClassUtils; +import org.springframework.util.ReflectionUtils; import org.springframework.util.ReflectionUtils.MethodFilter; import org.springframework.util.StringUtils; @@ -109,12 +111,40 @@ class TestBeanOverrideProcessor implements BeanOverrideProcessor { */ Method findTestBeanFactoryMethod(Class clazz, Class methodReturnType, Collection methodNames) { Assert.notEmpty(methodNames, "At least one candidate method name is required"); - Set supportedNames = new LinkedHashSet<>(methodNames); + Set methods = new LinkedHashSet<>(); + Set originalNames = new LinkedHashSet<>(methodNames); + + // Process fully-qualified method names first. + for (String methodName : methodNames) { + int indexOfHash = methodName.lastIndexOf('#'); + if (indexOfHash != -1) { + String className = methodName.substring(0, indexOfHash).trim(); + Assert.hasText(className, () -> "No class name present in fully-qualified method name: " + methodName); + String methodNameToUse = methodName.substring(indexOfHash + 1).trim(); + Assert.hasText(methodNameToUse, () -> "No method name present in fully-qualified method name: " + methodName); + Class declaringClass; + try { + declaringClass = ClassUtils.forName(className, getClass().getClassLoader()); + } + catch (ClassNotFoundException | LinkageError ex) { + throw new IllegalStateException( + "Failed to load class for fully-qualified method name: " + methodName, ex); + } + Method externalMethod = ReflectionUtils.findMethod(declaringClass, methodNameToUse); + Assert.state(externalMethod != null && Modifier.isStatic(externalMethod.getModifiers()) && + methodReturnType.isAssignableFrom(externalMethod.getReturnType()), () -> + "No static method found named %s in %s with return type %s".formatted( + methodNameToUse, className, methodReturnType.getName())); + methods.add(externalMethod); + originalNames.remove(methodName); + } + } + + Set supportedNames = new LinkedHashSet<>(originalNames); MethodFilter methodFilter = method -> (Modifier.isStatic(method.getModifiers()) && supportedNames.contains(method.getName()) && methodReturnType.isAssignableFrom(method.getReturnType())); - - Set methods = findMethods(clazz, methodFilter); + findMethods(methods, clazz, methodFilter); String methodNamesDescription = supportedNames.stream() .map(name -> name + "()").collect(Collectors.joining(" or ")); @@ -130,10 +160,10 @@ class TestBeanOverrideProcessor implements BeanOverrideProcessor { return methods.iterator().next(); } - private static Set findMethods(Class clazz, MethodFilter methodFilter) { - Set methods = MethodIntrospector.selectMethods(clazz, methodFilter); + private static Set findMethods(Set methods, Class clazz, MethodFilter methodFilter) { + methods.addAll(MethodIntrospector.selectMethods(clazz, methodFilter)); if (methods.isEmpty() && TestContextAnnotationUtils.searchEnclosingClass(clazz)) { - methods = findMethods(clazz.getEnclosingClass(), methodFilter); + findMethods(methods, clazz.getEnclosingClass(), methodFilter); } return methods; } diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/convention/TestBeanForExternalFactoryMethodIntegrationTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/convention/TestBeanForExternalFactoryMethodIntegrationTests.java new file mode 100644 index 00000000000..f7701ec287a --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/convention/TestBeanForExternalFactoryMethodIntegrationTests.java @@ -0,0 +1,56 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.test.context.bean.override.convention; + +import org.junit.jupiter.api.Test; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests for {@link TestBean} that use bean factory methods defined + * in external classes. + * + * @author Sam Brannen + * @since 6.2 + */ +@SpringJUnitConfig +class TestBeanForExternalFactoryMethodIntegrationTests { + + @TestBean(methodName = "org.springframework.test.context.bean.override.example.TestBeanFactory#createTestMessage") + String message; + + + @Test + void test() { + assertThat(message).isEqualTo("test"); + } + + + @Configuration + static class Config { + + @Bean + String message() { + return "prod"; + } + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/convention/TestBeanOverrideProcessorTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/convention/TestBeanOverrideProcessorTests.java index 49428aa88cb..ee6e196f53e 100644 --- a/spring-test/src/test/java/org/springframework/test/context/bean/override/convention/TestBeanOverrideProcessorTests.java +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/convention/TestBeanOverrideProcessorTests.java @@ -24,6 +24,7 @@ import org.junit.jupiter.api.Test; import org.springframework.core.annotation.AnnotationUtils; import org.springframework.lang.NonNull; import org.springframework.test.context.bean.override.example.ExampleService; +import org.springframework.test.context.bean.override.example.TestBeanFactory; import org.springframework.util.ReflectionUtils; import static org.assertj.core.api.Assertions.assertThat; @@ -91,6 +92,64 @@ class TestBeanOverrideProcessorTests { .withMessage("At least one candidate method name is required"); } + @Test + void findTestBeanFactoryMethodByFullyQualifiedName() { + Class clazz = getClass(); + Class returnType = String.class; + String methodName = TestBeanFactory.class.getName() + "#createTestMessage"; + + Method method = this.processor.findTestBeanFactoryMethod(clazz, returnType, methodName); + + assertThat(method).isEqualTo(ReflectionUtils.findMethod(TestBeanFactory.class, "createTestMessage")); + } + + @Test + void findTestBeanFactoryMethodByFullyQualifiedNameWithNonexistentMethod() { + Class clazz = getClass(); + Class returnType = String.class; + String factoryClassName = TestBeanFactory.class.getName(); + String methodName = factoryClassName + "#bogus"; + + assertThatIllegalStateException() + .isThrownBy(() -> this.processor.findTestBeanFactoryMethod(clazz, returnType, methodName)) + .withMessage("No static method found named %s in %s with return type %s", + "bogus", factoryClassName, returnType.getName()); + } + + @Test + void findTestBeanFactoryMethodByFullyQualifiedNameWithNonexistentClass() { + Class clazz = getClass(); + Class returnType = String.class; + String methodName = "org.example.Bogus#createTestBean"; + + assertThatIllegalStateException() + .isThrownBy(() -> this.processor.findTestBeanFactoryMethod(clazz, returnType, methodName)) + .withMessage("Failed to load class for fully-qualified method name: %s", methodName) + .withCauseInstanceOf(ClassNotFoundException.class); + } + + @Test + void findTestBeanFactoryMethodByFullyQualifiedNameWithMissingMethodName() { + Class clazz = getClass(); + Class returnType = String.class; + String methodName = TestBeanFactory.class.getName() + "#"; + + assertThatIllegalArgumentException() + .isThrownBy(() -> this.processor.findTestBeanFactoryMethod(clazz, returnType, methodName)) + .withMessage("No method name present in fully-qualified method name: %s", methodName); + } + + @Test + void findTestBeanFactoryMethodByFullyQualifiedNameWithMissingClassName() { + Class clazz = getClass(); + Class returnType = String.class; + String methodName = "#createTestBean"; + + assertThatIllegalArgumentException() + .isThrownBy(() -> this.processor.findTestBeanFactoryMethod(clazz, returnType, methodName)) + .withMessage("No class name present in fully-qualified method name: %s", methodName); + } + @Test void createMetaDataForUnknownExplicitMethod() throws Exception { Class clazz = ExplicitMethodNameTestCase.class; @@ -176,7 +235,6 @@ class TestBeanOverrideProcessorTests { static class BaseTestCase { - @TestBean(methodName = "factory") public String field; static String factory() {