Skip to content

Commit d6422d3

Browse files
committed
Revise @⁠TestBean support
See gh-29917
1 parent 21ed8aa commit d6422d3

File tree

3 files changed

+124
-72
lines changed

3 files changed

+124
-72
lines changed

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

+33-22
Original file line numberDiff line numberDiff line change
@@ -22,23 +22,24 @@
2222
import java.lang.annotation.RetentionPolicy;
2323
import java.lang.annotation.Target;
2424

25+
import org.springframework.core.annotation.AliasFor;
2526
import org.springframework.test.context.bean.override.BeanOverride;
2627

2728
/**
2829
* Mark a field to override a bean instance in the {@code BeanFactory}.
2930
*
30-
* <p>The instance is created from a no-arg static method in the declaring
31+
* <p>The instance is created from a no-arg static factory method in the test
3132
* class whose return type is compatible with the annotated field. The method
32-
* is deduced as follows:
33+
* is deduced as follows.
3334
* <ul>
34-
* <li>if the {@link #methodName()} is specified, look for a static method with
35+
* <li>If the {@link #methodName()} is specified, look for a static method with
3536
* that name.</li>
36-
* <li>if not, look for exactly one static method named with a suffix equal to
37-
* {@value #CONVENTION_SUFFIX} and either starting with the annotated field
38-
* name, or starting with the bean name.</li>
37+
* <li>If a method name is not specified, look for exactly one static method named
38+
* with a suffix equal to {@value #CONVENTION_SUFFIX} and starting with either the
39+
* name of the annotated field or the name of the bean.</li>
3940
* </ul>
4041
*
41-
* <p>Consider the following example:
42+
* <p>Consider the following example.
4243
*
4344
* <pre><code>
4445
* class CustomerServiceTests {
@@ -54,13 +55,13 @@
5455
* }</code></pre>
5556
*
5657
* <p>In the example above, the {@code repository} bean is replaced by the
57-
* instance generated by the {@code repositoryTestOverride} method. Not only
58-
* the overridden instance is injected in the {@code repository} field, but it
58+
* instance generated by the {@code repositoryTestOverride()} method. Not only
59+
* is the overridden instance injected into the {@code repository} field, but it
5960
* is also replaced in the {@code BeanFactory} so that other injection points
60-
* for that bean use the override.
61+
* for that bean use the overridden bean instance.
6162
*
6263
* <p>To make things more explicit, the method name can be set, as shown in the
63-
* following example:
64+
* following example.
6465
*
6566
* <pre><code>
6667
* class CustomerServiceTests {
@@ -75,11 +76,13 @@
7576
* }
7677
* }</code></pre>
7778
*
78-
* <p>By default, the name of the bean is inferred from the name of the annotated
79-
* field. To use a different bean name, set the {@link #name()} property.
79+
* <p>By default, the name of the bean to override is inferred from the name of
80+
* the annotated field. To use a different bean name, set the {@link #name()}
81+
* attribute.
8082
*
8183
* @author Simon Baslé
8284
* @author Stephane Nicoll
85+
* @author Sam Brannen
8386
* @since 6.2
8487
* @see TestBeanOverrideProcessor
8588
*/
@@ -90,24 +93,32 @@
9093
public @interface TestBean {
9194

9295
/**
93-
* Required suffix for a method that overrides a bean instance that is
94-
* detected by convention.
96+
* Required suffix for a factory method that overrides a bean instance that
97+
* is detected by convention.
9598
*/
9699
String CONVENTION_SUFFIX = "TestOverride";
97100

101+
98102
/**
99-
* Name of a static method to look for in the test, which will be used to
100-
* instantiate the bean to override.
101-
* <p>Default to {@code ""} (the empty String), which detects the method
102-
* to us by convention.
103+
* Alias for {@link #name()}.
103104
*/
104-
String methodName() default "";
105+
@AliasFor("name")
106+
String value() default "";
105107

106108
/**
107109
* Name of the bean to override.
108-
* <p>Default to {@code ""} (the empty String) to use the name of the
109-
* annotated field.
110+
* <p>Defaults to {@code ""} (the empty String) to signal that the name of
111+
* the annotated field should be used as the bean name.
110112
*/
113+
@AliasFor("value")
111114
String name() default "";
112115

116+
/**
117+
* Name of a static factory method to look for in the test class, which will
118+
* be used to instantiate the bean to override.
119+
* <p>Defaults to {@code ""} (the empty String) to signal that the factory
120+
* method should be detected based on convention.
121+
*/
122+
String methodName() default "";
123+
113124
}

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

+41-25
Original file line numberDiff line numberDiff line change
@@ -33,40 +33,58 @@
3333
import org.springframework.test.context.bean.override.BeanOverrideStrategy;
3434
import org.springframework.test.context.bean.override.OverrideMetadata;
3535
import org.springframework.util.Assert;
36+
import org.springframework.util.ReflectionUtils;
3637
import org.springframework.util.StringUtils;
3738

3839
/**
3940
* {@link BeanOverrideProcessor} implementation primarily made to work with
40-
* {@link TestBean @TestBean}, but can work with arbitrary override annotations
41-
* provided the annotated class has a relevant method according to the
42-
* convention documented in {@link TestBean}.
41+
* fields annotated with {@link TestBean @TestBean}, but can also work with
42+
* arbitrary test bean override annotations provided the annotated field's
43+
* declaring class declares an appropriate test bean factory method according
44+
* to the conventions documented in {@link TestBean}.
4345
*
4446
* @author Simon Baslé
47+
* @author Sam Brannen
4548
* @since 6.2
4649
*/
4750
public class TestBeanOverrideProcessor implements BeanOverrideProcessor {
4851

4952
/**
50-
* Ensure the given {@code enclosingClass} has a static, no-arguments method
51-
* with the given {@code expectedMethodReturnType} and exactly one of the
52-
* {@code expectedMethodNames}.
53+
* Find a test bean factory {@link Method} in the given {@link Class} which
54+
* meets the following criteria.
55+
* <ul>
56+
* <li>The method is static.
57+
* <li>The method does not accept any arguments.
58+
* <li>The method's return type matches the supplied {@code methodReturnType}.
59+
* <li>The method's name is one of the supplied {@code methodNames}.
60+
* </ul>
61+
* @param clazz the class in which to search for the factory method
62+
* @param methodReturnType the return type for the factory method
63+
* @param methodNames a set of supported names for the factory method
64+
* @return the corresponding factory method
65+
* @throws IllegalStateException if a single matching factory method cannot
66+
* be found
5367
*/
54-
public static Method ensureMethod(Class<?> enclosingClass, Class<?> expectedMethodReturnType,
55-
String... expectedMethodNames) {
56-
57-
Assert.isTrue(expectedMethodNames.length > 0, "At least one expectedMethodName is required");
58-
Set<String> expectedNames = new LinkedHashSet<>(Arrays.asList(expectedMethodNames));
59-
List<Method> found = Arrays.stream(enclosingClass.getDeclaredMethods())
68+
public static Method findTestBeanFactoryMethod(Class<?> clazz, Class<?> methodReturnType, String... methodNames) {
69+
Assert.isTrue(methodNames.length > 0, "At least one candidate method name is required");
70+
Set<String> supportedNames = new LinkedHashSet<>(Arrays.asList(methodNames));
71+
List<Method> methods = Arrays.stream(clazz.getDeclaredMethods())
6072
.filter(method -> Modifier.isStatic(method.getModifiers()) &&
61-
expectedNames.contains(method.getName()) &&
62-
expectedMethodReturnType.isAssignableFrom(method.getReturnType()))
73+
supportedNames.contains(method.getName()) &&
74+
methodReturnType.isAssignableFrom(method.getReturnType()))
6375
.toList();
6476

65-
Assert.state(found.size() == 1, () -> "Found " + found.size() + " static methods " +
66-
"instead of exactly one, matching a name in " + expectedNames + " with return type " +
67-
expectedMethodReturnType.getName() + " on class " + enclosingClass.getName());
77+
Assert.state(!methods.isEmpty(), () -> """
78+
Failed to find a static test bean factory method in %s with return type %s \
79+
whose name matches one of the supported candidates %s""".formatted(
80+
clazz.getName(), methodReturnType.getName(), supportedNames));
81+
82+
Assert.state(methods.size() == 1, () -> """
83+
Found %d competing static test bean factory methods in %s with return type %s \
84+
whose name matches one of the supported candidates %s""".formatted(
85+
methods.size(), clazz.getName(), methodReturnType.getName(), supportedNames));
6886

69-
return found.get(0);
87+
return methods.get(0);
7088
}
7189

7290
@Override
@@ -77,7 +95,7 @@ public OverrideMetadata createMetadata(Field field, Annotation overrideAnnotatio
7795
Method overrideMethod = null;
7896
String beanName = null;
7997
if (!testBeanAnnotation.methodName().isBlank()) {
80-
overrideMethod = ensureMethod(declaringClass, field.getType(), testBeanAnnotation.methodName());
98+
overrideMethod = findTestBeanFactoryMethod(declaringClass, field.getType(), testBeanAnnotation.methodName());
8199
}
82100
if (!testBeanAnnotation.name().isBlank()) {
83101
beanName = testBeanAnnotation.name();
@@ -89,6 +107,7 @@ public OverrideMetadata createMetadata(Field field, Annotation overrideAnnotatio
89107
return new MethodConventionOverrideMetadata(field, null, null, overrideAnnotation, typeToOverride);
90108
}
91109

110+
92111
static final class MethodConventionOverrideMetadata extends OverrideMetadata {
93112

94113
@Nullable
@@ -124,22 +143,19 @@ protected Object createOverride(String beanName, @Nullable BeanDefinition existi
124143

125144
Method methodToInvoke = this.overrideMethod;
126145
if (methodToInvoke == null) {
127-
methodToInvoke = ensureMethod(field().getDeclaringClass(), field().getType(),
146+
methodToInvoke = findTestBeanFactoryMethod(field().getDeclaringClass(), field().getType(),
128147
beanName + TestBean.CONVENTION_SUFFIX,
129148
field().getName() + TestBean.CONVENTION_SUFFIX);
130149
}
131150

132-
methodToInvoke.setAccessible(true);
133-
Object override;
134151
try {
135-
override = methodToInvoke.invoke(null);
152+
ReflectionUtils.makeAccessible(methodToInvoke);
153+
return methodToInvoke.invoke(null);
136154
}
137155
catch (IllegalAccessException | InvocationTargetException ex) {
138156
throw new IllegalArgumentException("Could not invoke bean overriding method " + methodToInvoke.getName() +
139157
"; a static method with no formal parameters is expected", ex);
140158
}
141-
142-
return override;
143159
}
144160
}
145161

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

+50-25
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
import java.lang.reflect.Field;
2020
import java.lang.reflect.Method;
21+
import java.util.List;
2122
import java.util.Objects;
2223

2324
import org.junit.jupiter.api.Test;
@@ -31,74 +32,98 @@
3132
import static org.assertj.core.api.Assertions.assertThat;
3233
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
3334
import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
35+
import static org.springframework.test.context.bean.override.convention.TestBeanOverrideProcessor.findTestBeanFactoryMethod;
3436

3537
/**
3638
* Tests for {@link TestBeanOverrideProcessor}.
3739
*
3840
* @author Simon Baslé
41+
* @author Sam Brannen
3942
* @since 6.2
4043
*/
4144
class TestBeanOverrideProcessorTests {
4245

4346
@Test
44-
void ensureMethodFindsFromList() {
45-
Method method = TestBeanOverrideProcessor.ensureMethod(MethodConventionConf.class, ExampleService.class,
46-
"example1", "example2", "example3");
47+
void findTestBeanFactoryMethodFindsFromCandidateNames() {
48+
Class<?> clazz = MethodConventionConf.class;
49+
Class<?> returnType = ExampleService.class;
50+
51+
Method method = findTestBeanFactoryMethod(clazz, returnType, "example1", "example2", "example3");
4752

4853
assertThat(method.getName()).isEqualTo("example2");
4954
}
5055

5156
@Test
52-
void ensureMethodNotFound() {
57+
void findTestBeanFactoryMethodNotFound() {
58+
Class<?> clazz = MethodConventionConf.class;
59+
Class<?> returnType = ExampleService.class;
60+
5361
assertThatIllegalStateException()
54-
.isThrownBy(() -> TestBeanOverrideProcessor.ensureMethod(MethodConventionConf.class, ExampleService.class,
55-
"example1", "example3"))
56-
.withMessage("Found 0 static methods instead of exactly one, matching a name in [example1, example3] with return type " +
57-
ExampleService.class.getName() + " on class " + MethodConventionConf.class.getName());
62+
.isThrownBy(() -> findTestBeanFactoryMethod(clazz, returnType, "example1", "example3"))
63+
.withMessage("""
64+
Failed to find a static test bean factory method in %s with return type %s \
65+
whose name matches one of the supported candidates %s""",
66+
clazz.getName(), returnType.getName(), List.of("example1", "example3"));
5867
}
5968

6069
@Test
61-
void ensureMethodTwoFound() {
70+
void findTestBeanFactoryMethodTwoFound() {
71+
Class<?> clazz = MethodConventionConf.class;
72+
Class<?> returnType = ExampleService.class;
73+
6274
assertThatIllegalStateException()
63-
.isThrownBy(() -> TestBeanOverrideProcessor.ensureMethod(MethodConventionConf.class, ExampleService.class,
64-
"example2", "example4"))
65-
.withMessage("Found 2 static methods instead of exactly one, matching a name in [example2, example4] with return type " +
66-
ExampleService.class.getName() + " on class " + MethodConventionConf.class.getName());
75+
.isThrownBy(() -> findTestBeanFactoryMethod(clazz, returnType, "example2", "example4"))
76+
.withMessage("""
77+
Found %d competing static test bean factory methods in %s with return type %s \
78+
whose name matches one of the supported candidates %s""".formatted(
79+
2, clazz.getName(), returnType.getName(), List.of("example2", "example4")));
6780
}
6881

6982
@Test
70-
void ensureMethodNoNameProvided() {
83+
void findTestBeanFactoryMethodNoNameProvided() {
7184
assertThatIllegalArgumentException()
72-
.isThrownBy(() -> TestBeanOverrideProcessor.ensureMethod(MethodConventionConf.class, ExampleService.class))
73-
.withMessage("At least one expectedMethodName is required");
85+
.isThrownBy(() -> findTestBeanFactoryMethod(MethodConventionConf.class, ExampleService.class))
86+
.withMessage("At least one candidate method name is required");
7487
}
7588

7689
@Test
77-
void createMetaDataForUnknownExplicitMethod() throws NoSuchFieldException {
78-
Field field = ExplicitMethodNameConf.class.getField("a");
90+
void createMetaDataForUnknownExplicitMethod() throws Exception {
91+
Class<?> clazz = ExplicitMethodNameConf.class;
92+
Class<?> returnType = ExampleService.class;
93+
Field field = clazz.getField("a");
7994
TestBean overrideAnnotation = Objects.requireNonNull(field.getAnnotation(TestBean.class));
95+
8096
TestBeanOverrideProcessor processor = new TestBeanOverrideProcessor();
8197
assertThatIllegalStateException()
82-
.isThrownBy(() -> processor.createMetadata(field, overrideAnnotation, ResolvableType.forClass(ExampleService.class)))
83-
.withMessage("Found 0 static methods instead of exactly one, matching a name in [explicit1] with return type " +
84-
ExampleService.class.getName() + " on class " + ExplicitMethodNameConf.class.getName());
98+
.isThrownBy(() -> processor.createMetadata(field, overrideAnnotation, ResolvableType.forClass(returnType)))
99+
.withMessage("""
100+
Failed to find a static test bean factory method in %s with return type %s \
101+
whose name matches one of the supported candidates %s""",
102+
clazz.getName(), returnType.getName(), List.of("explicit1"));
85103
}
86104

87105
@Test
88-
void createMetaDataForKnownExplicitMethod() throws NoSuchFieldException {
106+
void createMetaDataForKnownExplicitMethod() throws Exception {
107+
Class<?> returnType = ExampleService.class;
89108
Field field = ExplicitMethodNameConf.class.getField("b");
90109
TestBean overrideAnnotation = Objects.requireNonNull(field.getAnnotation(TestBean.class));
110+
91111
TestBeanOverrideProcessor processor = new TestBeanOverrideProcessor();
92-
assertThat(processor.createMetadata(field, overrideAnnotation, ResolvableType.forClass(ExampleService.class)))
112+
assertThat(processor.createMetadata(field, overrideAnnotation, ResolvableType.forClass(returnType)))
93113
.isInstanceOf(MethodConventionOverrideMetadata.class);
94114
}
95115

96116
@Test
97-
void createMetaDataWithDeferredEnsureMethodCheck() throws NoSuchFieldException {
117+
void createMetaDataWithDeferredCheckForExistenceOfConventionBasedFactoryMethod() throws Exception {
118+
Class<?> returnType = ExampleService.class;
98119
Field field = MethodConventionConf.class.getField("field");
99120
TestBean overrideAnnotation = Objects.requireNonNull(field.getAnnotation(TestBean.class));
121+
100122
TestBeanOverrideProcessor processor = new TestBeanOverrideProcessor();
101-
assertThat(processor.createMetadata(field, overrideAnnotation, ResolvableType.forClass(ExampleService.class)))
123+
// When in convention-based mode, createMetadata() will not verify that
124+
// the factory method actually exists. So, we don't expect an exception
125+
// for this use case.
126+
assertThat(processor.createMetadata(field, overrideAnnotation, ResolvableType.forClass(returnType)))
102127
.isInstanceOf(MethodConventionOverrideMetadata.class);
103128
}
104129

0 commit comments

Comments
 (0)