Skip to content

Commit cc00287

Browse files
committed
Support fully-qualified factory method names in @⁠TestBean
Prior to this commit, @⁠TestBean factory methods had to be defined in the test class, one of its superclasses, or in an implemented interface. However, users may wish to define common factory methods in external classes that can be shared easily across multiple test classes simply by referencing an external method via a fully-qualified method name. To address that, this commit introduces support for referencing a @⁠TestBean factory method via its fully-qualified method name following the syntax <fully-qualified class name>#<method name>. Closes gh-33125
1 parent c2f8d48 commit cc00287

File tree

5 files changed

+170
-11
lines changed

5 files changed

+170
-11
lines changed

framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-testbean.adoc

+9-2
Original file line numberDiff line numberDiff line change
@@ -78,5 +78,12 @@ Java::
7878
<2> The result of this static method will be used as the instance and injected into the field.
7979
======
8080

81-
NOTE: Spring searches for the factory method to invoke in the test class, in the test
82-
class hierarchy, and in the enclosing class hierarchy for a `@Nested` test class.
81+
[NOTE]
82+
====
83+
Spring searches for the factory method to invoke in the test class, in the test class
84+
hierarchy, and in the enclosing class hierarchy for a `@Nested` test class.
85+
86+
Alternatively, a factory method in an external class can be referenced via its
87+
fully-qualified method name following the syntax `<fully-qualified class name>#<method name>`
88+
– for example, `methodName = "org.example.TestUtils#createCustomService"`.
89+
====

spring-test/src/main/java/org/springframework/test/context/bean/override/convention/TestBean.java

+10-2
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,12 @@
4040
* test class whose return type is compatible with the annotated field. In the
4141
* case of a nested test class, the enclosing class hierarchy is also searched.
4242
* Similarly, if the test class extends from a base class or implements any
43-
* interfaces, the entire type hierarchy is searched. The method is deduced as
44-
* follows.
43+
* interfaces, the entire type hierarchy is searched. Alternatively, a factory
44+
* method in an external class can be referenced via its fully-qualified method
45+
* name following the syntax {@code <fully-qualified class name>#<method name>}
46+
* &mdash; for example, {@code "org.example.TestUtils#createCustomerRepository"}.
47+
*
48+
* <p>The factory method is deduced as follows.
4549
*
4650
* <ul>
4751
* <li>If the {@link #methodName()} is specified, look for a static method with
@@ -125,6 +129,10 @@
125129
* <p>A search will be performed to find the factory method in the test class,
126130
* in one of its superclasses, or in any implemented interfaces. In the case
127131
* of a nested test class, the enclosing class hierarchy will also be searched.
132+
* <p>Alternatively, a factory method in an external class can be referenced
133+
* via its fully-qualified method name following the syntax
134+
* {@code <fully-qualified class name>#<method name>} &mdash; for example,
135+
* {@code "org.example.TestUtils#createCustomerRepository"}.
128136
* <p>If left unspecified, the name of the factory method will be detected
129137
* based either on the name of the annotated field or the name of the bean.
130138
*/

spring-test/src/main/java/org/springframework/test/context/bean/override/convention/TestBeanOverrideProcessor.java

+36-6
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@
3232
import org.springframework.test.context.TestContextAnnotationUtils;
3333
import org.springframework.test.context.bean.override.BeanOverrideProcessor;
3434
import org.springframework.util.Assert;
35+
import org.springframework.util.ClassUtils;
36+
import org.springframework.util.ReflectionUtils;
3537
import org.springframework.util.ReflectionUtils.MethodFilter;
3638
import org.springframework.util.StringUtils;
3739

@@ -109,12 +111,40 @@ Method findTestBeanFactoryMethod(Class<?> clazz, Class<?> methodReturnType, Stri
109111
*/
110112
Method findTestBeanFactoryMethod(Class<?> clazz, Class<?> methodReturnType, Collection<String> methodNames) {
111113
Assert.notEmpty(methodNames, "At least one candidate method name is required");
112-
Set<String> supportedNames = new LinkedHashSet<>(methodNames);
114+
Set<Method> methods = new LinkedHashSet<>();
115+
Set<String> originalNames = new LinkedHashSet<>(methodNames);
116+
117+
// Process fully-qualified method names first.
118+
for (String methodName : methodNames) {
119+
int indexOfHash = methodName.lastIndexOf('#');
120+
if (indexOfHash != -1) {
121+
String className = methodName.substring(0, indexOfHash).trim();
122+
Assert.hasText(className, () -> "No class name present in fully-qualified method name: " + methodName);
123+
String methodNameToUse = methodName.substring(indexOfHash + 1).trim();
124+
Assert.hasText(methodNameToUse, () -> "No method name present in fully-qualified method name: " + methodName);
125+
Class<?> declaringClass;
126+
try {
127+
declaringClass = ClassUtils.forName(className, getClass().getClassLoader());
128+
}
129+
catch (ClassNotFoundException | LinkageError ex) {
130+
throw new IllegalStateException(
131+
"Failed to load class for fully-qualified method name: " + methodName, ex);
132+
}
133+
Method externalMethod = ReflectionUtils.findMethod(declaringClass, methodNameToUse);
134+
Assert.state(externalMethod != null && Modifier.isStatic(externalMethod.getModifiers()) &&
135+
methodReturnType.isAssignableFrom(externalMethod.getReturnType()), () ->
136+
"No static method found named %s in %s with return type %s".formatted(
137+
methodNameToUse, className, methodReturnType.getName()));
138+
methods.add(externalMethod);
139+
originalNames.remove(methodName);
140+
}
141+
}
142+
143+
Set<String> supportedNames = new LinkedHashSet<>(originalNames);
113144
MethodFilter methodFilter = method -> (Modifier.isStatic(method.getModifiers()) &&
114145
supportedNames.contains(method.getName()) &&
115146
methodReturnType.isAssignableFrom(method.getReturnType()));
116-
117-
Set<Method> methods = findMethods(clazz, methodFilter);
147+
findMethods(methods, clazz, methodFilter);
118148

119149
String methodNamesDescription = supportedNames.stream()
120150
.map(name -> name + "()").collect(Collectors.joining(" or "));
@@ -130,10 +160,10 @@ Method findTestBeanFactoryMethod(Class<?> clazz, Class<?> methodReturnType, Coll
130160
return methods.iterator().next();
131161
}
132162

133-
private static Set<Method> findMethods(Class<?> clazz, MethodFilter methodFilter) {
134-
Set<Method> methods = MethodIntrospector.selectMethods(clazz, methodFilter);
163+
private static Set<Method> findMethods(Set<Method> methods, Class<?> clazz, MethodFilter methodFilter) {
164+
methods.addAll(MethodIntrospector.selectMethods(clazz, methodFilter));
135165
if (methods.isEmpty() && TestContextAnnotationUtils.searchEnclosingClass(clazz)) {
136-
methods = findMethods(clazz.getEnclosingClass(), methodFilter);
166+
findMethods(methods, clazz.getEnclosingClass(), methodFilter);
137167
}
138168
return methods;
139169
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
/*
2+
* Copyright 2002-2024 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.test.context.bean.override.convention;
18+
19+
import org.junit.jupiter.api.Test;
20+
21+
import org.springframework.context.annotation.Bean;
22+
import org.springframework.context.annotation.Configuration;
23+
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
24+
25+
import static org.assertj.core.api.Assertions.assertThat;
26+
27+
/**
28+
* Integration tests for {@link TestBean} that use bean factory methods defined
29+
* in external classes.
30+
*
31+
* @author Sam Brannen
32+
* @since 6.2
33+
*/
34+
@SpringJUnitConfig
35+
class TestBeanForExternalFactoryMethodIntegrationTests {
36+
37+
@TestBean(methodName = "org.springframework.test.context.bean.override.example.TestBeanFactory#createTestMessage")
38+
String message;
39+
40+
41+
@Test
42+
void test() {
43+
assertThat(message).isEqualTo("test");
44+
}
45+
46+
47+
@Configuration
48+
static class Config {
49+
50+
@Bean
51+
String message() {
52+
return "prod";
53+
}
54+
}
55+
56+
}

spring-test/src/test/java/org/springframework/test/context/bean/override/convention/TestBeanOverrideProcessorTests.java

+59-1
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import org.springframework.core.annotation.AnnotationUtils;
2525
import org.springframework.lang.NonNull;
2626
import org.springframework.test.context.bean.override.example.ExampleService;
27+
import org.springframework.test.context.bean.override.example.TestBeanFactory;
2728
import org.springframework.util.ReflectionUtils;
2829

2930
import static org.assertj.core.api.Assertions.assertThat;
@@ -91,6 +92,64 @@ void findTestBeanFactoryMethodNoNameProvided() {
9192
.withMessage("At least one candidate method name is required");
9293
}
9394

95+
@Test
96+
void findTestBeanFactoryMethodByFullyQualifiedName() {
97+
Class<?> clazz = getClass();
98+
Class<?> returnType = String.class;
99+
String methodName = TestBeanFactory.class.getName() + "#createTestMessage";
100+
101+
Method method = this.processor.findTestBeanFactoryMethod(clazz, returnType, methodName);
102+
103+
assertThat(method).isEqualTo(ReflectionUtils.findMethod(TestBeanFactory.class, "createTestMessage"));
104+
}
105+
106+
@Test
107+
void findTestBeanFactoryMethodByFullyQualifiedNameWithNonexistentMethod() {
108+
Class<?> clazz = getClass();
109+
Class<?> returnType = String.class;
110+
String factoryClassName = TestBeanFactory.class.getName();
111+
String methodName = factoryClassName + "#bogus";
112+
113+
assertThatIllegalStateException()
114+
.isThrownBy(() -> this.processor.findTestBeanFactoryMethod(clazz, returnType, methodName))
115+
.withMessage("No static method found named %s in %s with return type %s",
116+
"bogus", factoryClassName, returnType.getName());
117+
}
118+
119+
@Test
120+
void findTestBeanFactoryMethodByFullyQualifiedNameWithNonexistentClass() {
121+
Class<?> clazz = getClass();
122+
Class<?> returnType = String.class;
123+
String methodName = "org.example.Bogus#createTestBean";
124+
125+
assertThatIllegalStateException()
126+
.isThrownBy(() -> this.processor.findTestBeanFactoryMethod(clazz, returnType, methodName))
127+
.withMessage("Failed to load class for fully-qualified method name: %s", methodName)
128+
.withCauseInstanceOf(ClassNotFoundException.class);
129+
}
130+
131+
@Test
132+
void findTestBeanFactoryMethodByFullyQualifiedNameWithMissingMethodName() {
133+
Class<?> clazz = getClass();
134+
Class<?> returnType = String.class;
135+
String methodName = TestBeanFactory.class.getName() + "#";
136+
137+
assertThatIllegalArgumentException()
138+
.isThrownBy(() -> this.processor.findTestBeanFactoryMethod(clazz, returnType, methodName))
139+
.withMessage("No method name present in fully-qualified method name: %s", methodName);
140+
}
141+
142+
@Test
143+
void findTestBeanFactoryMethodByFullyQualifiedNameWithMissingClassName() {
144+
Class<?> clazz = getClass();
145+
Class<?> returnType = String.class;
146+
String methodName = "#createTestBean";
147+
148+
assertThatIllegalArgumentException()
149+
.isThrownBy(() -> this.processor.findTestBeanFactoryMethod(clazz, returnType, methodName))
150+
.withMessage("No class name present in fully-qualified method name: %s", methodName);
151+
}
152+
94153
@Test
95154
void createMetaDataForUnknownExplicitMethod() throws Exception {
96155
Class<?> clazz = ExplicitMethodNameTestCase.class;
@@ -176,7 +235,6 @@ static ExampleService explicit2() {
176235

177236
static class BaseTestCase {
178237

179-
@TestBean(methodName = "factory")
180238
public String field;
181239

182240
static String factory() {

0 commit comments

Comments
 (0)