Skip to content

Commit

Permalink
Support @⁠MockitoBean at the type level on test classes
Browse files Browse the repository at this point in the history
Prior to this commit, @⁠MockitoBean could only be declared on fields
within test classes, which prevented developers from being able to
easily reuse mock configuration across a test suite.

With this commit, @⁠MockitoBean is now supported at the type level on
test classes, their superclasses, and interfaces implemented by those
classes. @⁠MockitoBean is also supported on enclosing classes for
@⁠Nested test classes, their superclasses, and interfaces implemented
by those classes, while honoring @⁠NestedTestConfiguration semantics.

In addition, @⁠MockitoBean:

- has a new `types` attribute that can be used to declare the type or
  types to mock when @⁠MockitoBean is declared at the type level

- can be declared as a repeatable annotation at the type level

- can be declared as a meta-annotation on a custom composed annotation
  which can be reused across a test suite (see the @⁠SharedMocks
  example in the reference manual)

To support these new features, this commit also includes the following
changes.

- The `field` property in BeanOverrideHandler is now @⁠Nullable.

- BeanOverrideProcessor has a new `default` createHandlers() method
  which is invoked when a @⁠BeanOverride annotation is found at the
  type level.

- MockitoBeanOverrideProcessor implements the new createHandlers()
  method.

- The internal findHandlers() method in BeanOverrideHandler has been
  completely overhauled.

- The @⁠MockitoBean and @⁠MockitoSpyBean section of the reference
  manual has been completely overhauled.

Closes gh-33925
  • Loading branch information
sbrannen committed Jan 15, 2025
1 parent 8b6523a commit 9181cce
Show file tree
Hide file tree
Showing 36 changed files with 1,379 additions and 144 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -11,29 +11,21 @@ If multiple candidates match, `@Qualifier` can be provided to narrow the candida
override. Alternatively, a candidate whose bean name matches the name of the field will
match.

When using `@MockitoBean`, a new bean will be created if a corresponding bean does not
exist. However, if you would like for the test to fail when a corresponding bean does not
exist, you can set the `enforceOverride` attribute to `true` – for example,
`@MockitoBean(enforceOverride = true)`.

To use a by-name override rather than a by-type override, specify the `name` attribute
of the annotation.

[WARNING]
====
Qualifiers, including the name of the field, are used to determine if a separate
`ApplicationContext` needs to be created. If you are using this feature to mock or spy
the same bean in several tests, make sure to name the field consistently to avoid
the same bean in several test classes, make sure to name the field consistently to avoid
creating unnecessary contexts.
====

Each annotation also defines Mockito-specific attributes to fine-tune the mocking details.
Each annotation also defines Mockito-specific attributes to fine-tune the mocking behavior.

By default, the `@MockitoBean` annotation uses the `REPLACE_OR_CREATE`
The `@MockitoBean` annotation uses the `REPLACE_OR_CREATE`
xref:testing/testcontext-framework/bean-overriding.adoc#testcontext-bean-overriding-custom[strategy for test bean overriding].
If no existing bean matches, a new bean is created on the fly. As mentioned previously,
you can switch to the `REPLACE` strategy by setting the `enforceOverride` attribute to
`true`.
If no existing bean matches, a new bean is created on the fly. However, you can switch to
the `REPLACE` strategy by setting the `enforceOverride` attribute to `true`. See the
following section for an example.

The `@MockitoSpyBean` annotation uses the `WRAP`
xref:testing/testcontext-framework/bean-overriding.adoc#testcontext-bean-overriding-custom[strategy],
Expand Down Expand Up @@ -61,6 +53,17 @@ Such fields can therefore be `public`, `protected`, package-private (default vis
or `private` depending on the needs or coding practices of the project.
====

[[spring-testing-annotation-beanoverriding-mockitobean-examples]]
== `@MockitoBean` Examples

When using `@MockitoBean`, a new bean will be created if a corresponding bean does not
exist. However, if you would like for the test to fail when a corresponding bean does not
exist, you can set the `enforceOverride` attribute to `true` – for example,
`@MockitoBean(enforceOverride = true)`.

To use a by-name override rather than a by-type override, specify the `name` (or `value`)
attribute of the annotation.

The following example shows how to use the default behavior of the `@MockitoBean` annotation:

[tabs]
Expand All @@ -69,11 +72,13 @@ Java::
+
[source,java,indent=0,subs="verbatim,quotes"]
----
class OverrideBeanTests {
@SpringJUnitConfig(TestConfig.class)
class BeanOverrideTests {
@MockitoBean // <1>
CustomService customService;
// test case body...
// tests...
}
----
<1> Replace the bean with type `CustomService` with a Mockito `mock`.
Expand All @@ -82,8 +87,8 @@ Java::
In the example above, we are creating a mock for `CustomService`. If more than one bean
of that type exists, the bean named `customService` is considered. Otherwise, the test
will fail, and you will need to provide a qualifier of some sort to identify which of the
`CustomService` beans you want to override. If no such bean exists, a bean definition
will be created with an auto-generated bean name.
`CustomService` beans you want to override. If no such bean exists, a bean will be
created with an auto-generated bean name.

The following example uses a by-name lookup, rather than a by-type lookup:

Expand All @@ -93,32 +98,114 @@ Java::
+
[source,java,indent=0,subs="verbatim,quotes"]
----
class OverrideBeanTests {
@SpringJUnitConfig(TestConfig.class)
class BeanOverrideTests {
@MockitoBean("service") // <1>
CustomService customService;
// test case body...
// tests...
}
----
<1> Replace the bean named `service` with a Mockito `mock`.
======

If no bean definition named `service` exists, one is created.
If no bean named `service` exists, one is created.

`@MockitoBean` can also be used at the type level:

- on a test class or any superclass or implemented interface in the type hierarchy above
the test class
- on an enclosing class for a `@Nested` test class or on any class or interface in the
type hierarchy or enclosing class hierarchy above the `@Nested` test class

The following example shows how to use the default behavior of the `@MockitoSpyBean` annotation:
When `@MockitoBean` is declared at the type level, the type of bean (or beans) to mock
must be supplied via the `types` attribute – for example,
`@MockitoBean(types = {OrderService.class, UserService.class})`. If multiple candidates
exist in the application context, you can explicitly specify a bean name to mock by
setting the `name` attribute. Note, however, that the `types` attribute must contain a
single type if an explicit bean `name` is configured – for example,
`@MockitoBean(name = "ps1", types = PrintingService.class)`.

To support reuse of mock configuration, `@MockitoBean` may be used as a meta-annotation
to create custom _composed annotations_ — for example, to define common mock
configuration in a single annotation that can be reused across a test suite.
`@MockitoBean` can also be used as a repeatable annotation at the type level — for
example, to mock several beans by name.

The following `@SharedMocks` annotation registers two mocks by-type and one mock by-name.

[tabs]
======
Java::
+
[source,java,indent=0,subs="verbatim,quotes"]
----
class OverrideBeanTests {
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@MockitoBean(types = {OrderService.class, UserService.class}) // <1>
@MockitoBean(name = "ps1", types = PrintingService.class) // <2>
public @interface SharedMocks {
}
----
<1> Register `OrderService` and `UserService` mocks by-type.
<2> Register `PrintingService` mock by-name.
======

The following demonstrates how `@SharedMocks` can be used on a test class.

[tabs]
======
Java::
+
[source,java,indent=0,subs="verbatim,quotes"]
----
@SpringJUnitConfig(TestConfig.class)
@SharedMocks // <1>
class BeanOverrideTests {
@Autowired OrderService orderService; // <2>
@Autowired UserService userService; // <2>
@Autowired PrintingService ps1; // <2>
// Inject other components that rely on the mocks.
@Test
void testThatDependsOnMocks() {
// ...
}
}
----
<1> Register common mocks via the custom `@SharedMocks` annotation.
<2> Optionally inject mocks to _stub_ or _verify_ them.
======

TIP: The mocks can also be injected into `@Configuration` classes or other test-related
components in the `ApplicationContext` in order to configure them with Mockito's stubbing
APIs.

[[spring-testing-annotation-beanoverriding-mockitospybean-examples]]
== `@MockitoSpyBean` Examples

The following example shows how to use the default behavior of the `@MockitoSpyBean`
annotation:

[tabs]
======
Java::
+
[source,java,indent=0,subs="verbatim,quotes"]
----
@SpringJUnitConfig(TestConfig.class)
class BeanOverrideTests {
@MockitoSpyBean // <1>
CustomService customService;
// test case body...
// tests...
}
----
<1> Wrap the bean with type `CustomService` with a Mockito `spy`.
Expand All @@ -137,12 +224,13 @@ Java::
+
[source,java,indent=0,subs="verbatim,quotes"]
----
class OverrideBeanTests {
@SpringJUnitConfig(TestConfig.class)
class BeanOverrideTests {
@MockitoSpyBean("service") // <1>
CustomService customService;
// test case body...
// tests...
}
----
<1> Wrap the bean named `service` with a Mockito `spy`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,19 @@
import org.springframework.aot.hint.annotation.Reflective;

/**
* Mark a composed annotation as eligible for Bean Override processing.
* Mark a <em>composed annotation</em> as eligible for Bean Override processing.
*
* <p>Specifying this annotation registers the configured {@link BeanOverrideProcessor}
* which must be capable of handling the composed annotation and its attributes.
*
* <p>Since the composed annotation should only be applied to non-static fields, it is
* expected that it is meta-annotated with {@link Target @Target(ElementType.FIELD)}.
* <p>Since the composed annotation will typically only be applied to non-static
* fields, it is expected that the composed annotation is meta-annotated with
* {@link Target @Target(ElementType.FIELD)}. However, certain bean override
* annotations may be declared with an additional {@code ElementType.TYPE} target
* for use at the type level, as is the case for {@code @MockitoBean} which can
* be declared on a field, test class, or test interface.
*
* <p>For concrete examples, see
* <p>For concrete examples of such composed annotations, see
* {@link org.springframework.test.context.bean.override.convention.TestBean @TestBean},
* {@link org.springframework.test.context.bean.override.mockito.MockitoBean @MockitoBean}, and
* {@link org.springframework.test.context.bean.override.mockito.MockitoSpyBean @MockitoSpyBean}.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2002-2024 the original author or authors.
* Copyright 2002-2025 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.
Expand Down Expand Up @@ -104,11 +104,10 @@ private void registerBeanOverride(ConfigurableListableBeanFactory beanFactory, B
Set<String> generatedBeanNames) {

String beanName = handler.getBeanName();
Field field = handler.getField();
Assert.state(!BeanFactoryUtils.isFactoryDereference(beanName),() -> """
Unable to override bean '%s' for field '%s.%s': a FactoryBean cannot be overridden. \
To override the bean created by the FactoryBean, remove the '&' prefix.""".formatted(
beanName, field.getDeclaringClass().getSimpleName(), field.getName()));
Assert.state(!BeanFactoryUtils.isFactoryDereference(beanName), () -> """
Unable to override bean '%s'%s: a FactoryBean cannot be overridden. \
To override the bean created by the FactoryBean, remove the '&' prefix."""
.formatted(beanName, forField(handler.getField())));

switch (handler.getStrategy()) {
case REPLACE -> replaceOrCreateBean(beanFactory, handler, generatedBeanNames, true);
Expand All @@ -134,7 +133,6 @@ private void replaceOrCreateBean(ConfigurableListableBeanFactory beanFactory, Be
// 4) Create bean by-name, with a provided name

String beanName = handler.getBeanName();
Field field = handler.getField();
BeanDefinition existingBeanDefinition = null;
if (beanName == null) {
beanName = getBeanNameForType(beanFactory, handler, requireExistingBean);
Expand Down Expand Up @@ -169,11 +167,10 @@ private void replaceOrCreateBean(ConfigurableListableBeanFactory beanFactory, Be
existingBeanDefinition = beanFactory.getBeanDefinition(beanName);
}
else if (requireExistingBean) {
throw new IllegalStateException("""
Unable to replace bean: there is no bean with name '%s' and type %s \
(as required by field '%s.%s')."""
.formatted(beanName, handler.getBeanType(),
field.getDeclaringClass().getSimpleName(), field.getName()));
Field field = handler.getField();
throw new IllegalStateException(
"Unable to replace bean: there is no bean with name '%s' and type %s%s."
.formatted(beanName, handler.getBeanType(), requiredByField(field)));
}
// 4) We are creating a bean by-name with the provided beanName.
}
Expand Down Expand Up @@ -264,13 +261,11 @@ private void wrapBean(ConfigurableListableBeanFactory beanFactory, BeanOverrideH
else {
String message = "Unable to select a bean to wrap: ";
if (candidateCount == 0) {
message += "there are no beans of type %s (as required by field '%s.%s')."
.formatted(beanType, field.getDeclaringClass().getSimpleName(), field.getName());
message += "there are no beans of type %s%s.".formatted(beanType, requiredByField(field));
}
else {
message += "found %d beans of type %s (as required by field '%s.%s'): %s"
.formatted(candidateCount, beanType, field.getDeclaringClass().getSimpleName(),
field.getName(), candidateNames);
message += "found %d beans of type %s%s: %s"
.formatted(candidateCount, beanType, requiredByField(field), candidateNames);
}
throw new IllegalStateException(message);
}
Expand All @@ -281,11 +276,9 @@ private void wrapBean(ConfigurableListableBeanFactory beanFactory, BeanOverrideH
// We are wrapping an existing bean by-name.
Set<String> candidates = getExistingBeanNamesByType(beanFactory, handler, false);
if (!candidates.contains(beanName)) {
throw new IllegalStateException("""
Unable to wrap bean: there is no bean with name '%s' and type %s \
(as required by field '%s.%s')."""
.formatted(beanName, beanType, field.getDeclaringClass().getSimpleName(),
field.getName()));
throw new IllegalStateException(
"Unable to wrap bean: there is no bean with name '%s' and type %s%s."
.formatted(beanName, beanType, requiredByField(field)));
}
}

Expand All @@ -308,8 +301,8 @@ private String getBeanNameForType(ConfigurableListableBeanFactory beanFactory, B
else if (candidateCount == 0) {
if (requireExistingBean) {
throw new IllegalStateException(
"Unable to override bean: there are no beans of type %s (as required by field '%s.%s')."
.formatted(beanType, field.getDeclaringClass().getSimpleName(), field.getName()));
"Unable to override bean: there are no beans of type %s%s."
.formatted(beanType, requiredByField(field)));
}
return null;
}
Expand All @@ -320,14 +313,14 @@ else if (candidateCount == 0) {
}

throw new IllegalStateException(
"Unable to select a bean to override: found %d beans of type %s (as required by field '%s.%s'): %s"
.formatted(candidateCount, beanType, field.getDeclaringClass().getSimpleName(),
field.getName(), candidateNames));
"Unable to select a bean to override: found %d beans of type %s%s: %s"
.formatted(candidateCount, beanType, requiredByField(field), candidateNames));
}

private Set<String> getExistingBeanNamesByType(ConfigurableListableBeanFactory beanFactory, BeanOverrideHandler handler,
boolean checkAutowiredCandidate) {

Field field = handler.getField();
ResolvableType resolvableType = handler.getBeanType();
Class<?> type = resolvableType.toClass();

Expand All @@ -345,16 +338,16 @@ private Set<String> getExistingBeanNamesByType(ConfigurableListableBeanFactory b
}

// Filter out non-matching autowire candidates.
if (checkAutowiredCandidate) {
DependencyDescriptor descriptor = new DependencyDescriptor(handler.getField(), true);
if (field != null && checkAutowiredCandidate) {
DependencyDescriptor descriptor = new DependencyDescriptor(field, true);
beanNames.removeIf(beanName -> !beanFactory.isAutowireCandidate(beanName, descriptor));
}
// Filter out scoped proxy targets.
beanNames.removeIf(ScopedProxyUtils::isScopedTarget);

// In case of multiple matches, fall back on the field's name as a last resort.
if (beanNames.size() > 1) {
String fieldName = handler.getField().getName();
if (field != null && beanNames.size() > 1) {
String fieldName = field.getName();
if (beanNames.contains(fieldName)) {
return Set.of(fieldName);
}
Expand Down Expand Up @@ -452,4 +445,19 @@ private static void destroySingleton(ConfigurableListableBeanFactory beanFactory
dlbf.destroySingleton(beanName);
}

private static String forField(@Nullable Field field) {
if (field == null) {
return "";
}
return " for field '%s.%s'".formatted(field.getDeclaringClass().getSimpleName(), field.getName());
}

private static String requiredByField(@Nullable Field field) {
if (field == null) {
return "";
}
return " (as required by field '%s.%s')".formatted(
field.getDeclaringClass().getSimpleName(), field.getName());
}

}
Loading

0 comments on commit 9181cce

Please sign in to comment.