From 1c893e63541b5b79c995bb0d57eb104c825b8767 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Basl=C3=A9?= Date: Wed, 7 Aug 2024 14:57:15 +0200 Subject: [PATCH] Add `@MockitoBeanSettings`, use MockitoSession with strict stubs default This commit changes the way the `MockitoTestExecutionListener` sets up mockito, now using the `MockitoSession` feature. Additionally, stubbing now defaults to a STRICT mode which can be overruled with a newly introduced annotation: `@MockitoBeanSettings`. Closes gh-33318 --- .../annotation-mockitobean.adoc | 3 + .../override/mockito/MockitoBeanSettings.java | 46 +++++++++ .../mockito/MockitoTestExecutionListener.java | 63 +++++++++--- ...toBeanSettingsLenientIntegrationTests.java | 44 +++++++++ ...itoBeanSettingsStrictIntegrationTests.java | 96 +++++++++++++++++++ 5 files changed, 239 insertions(+), 13 deletions(-) create mode 100644 spring-test/src/main/java/org/springframework/test/context/bean/override/mockito/MockitoBeanSettings.java create mode 100644 spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoBeanSettingsLenientIntegrationTests.java create mode 100644 spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoBeanSettingsStrictIntegrationTests.java diff --git a/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-mockitobean.adoc b/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-mockitobean.adoc index fc604d0f0ed..5cce74f5d88 100644 --- a/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-mockitobean.adoc +++ b/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-mockitobean.adoc @@ -23,6 +23,9 @@ creating unnecessary contexts. ==== Each annotation also defines Mockito-specific attributes to fine-tune the mocking details. +During the test class lifecycle, Mockito is set up via the `Mockito#mockitoSession()` +mechanism. Notably, it enables `STRICT_STUBS` mode by default. This can be changed on +individual test classes with the `@MockitoBeanSettings` annotation. The `@MockitoBean` annotation uses the `REPLACE_OR_CREATE_DEFINITION` xref:testing/testcontext-framework/bean-overriding.adoc#testcontext-bean-overriding-custom[strategy for test bean overriding]. diff --git a/spring-test/src/main/java/org/springframework/test/context/bean/override/mockito/MockitoBeanSettings.java b/spring-test/src/main/java/org/springframework/test/context/bean/override/mockito/MockitoBeanSettings.java new file mode 100644 index 00000000000..33f0f5f8a25 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/context/bean/override/mockito/MockitoBeanSettings.java @@ -0,0 +1,46 @@ +/* + * 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.mockito; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.mockito.quality.Strictness; + +/** + * Configure a test class that uses {@link MockitoBean} or {@link MockitoSpyBean} + * to set up Mockito with an explicitly specified stubbing strictness. + * + * @author Simon Baslé + * @since 6.2 + * @see MockitoTestExecutionListener + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface MockitoBeanSettings { + + /** + * The stubbing strictness to apply for all Mockito mocks in the annotated + * class. + */ + Strictness value(); + +} diff --git a/spring-test/src/main/java/org/springframework/test/context/bean/override/mockito/MockitoTestExecutionListener.java b/spring-test/src/main/java/org/springframework/test/context/bean/override/mockito/MockitoTestExecutionListener.java index fdc82091aea..61785432dac 100644 --- a/spring-test/src/main/java/org/springframework/test/context/bean/override/mockito/MockitoTestExecutionListener.java +++ b/spring-test/src/main/java/org/springframework/test/context/bean/override/mockito/MockitoTestExecutionListener.java @@ -21,9 +21,12 @@ import java.lang.reflect.Field; import java.util.LinkedHashSet; import java.util.Set; -import org.mockito.Captor; -import org.mockito.MockitoAnnotations; +import org.mockito.BDDMockito; +import org.mockito.Mockito; +import org.mockito.MockitoSession; +import org.mockito.quality.Strictness; +import org.springframework.core.annotation.AnnotationUtils; import org.springframework.test.context.TestContext; import org.springframework.test.context.support.AbstractTestExecutionListener; import org.springframework.test.context.support.DependencyInjectionTestExecutionListener; @@ -32,10 +35,14 @@ import org.springframework.util.ReflectionUtils; import org.springframework.util.ReflectionUtils.FieldCallback; /** - * {@code TestExecutionListener} that enables {@link MockitoBean @MockitoBean} and - * {@link MockitoSpyBean @MockitoSpyBean} support. Also triggers - * {@link MockitoAnnotations#openMocks(Object)} when any Mockito annotations are - * used, primarily to support {@link Captor @Captor} annotations. + * {@code TestExecutionListener} that enables {@link MockitoBean @MockitoBean} + * and {@link MockitoSpyBean @MockitoSpyBean} support. Also triggers Mockito set + * up of a {@link Mockito#mockitoSession() session} for each test class that + * uses these annotations (or any annotation in that package). + * + *

The {@link MockitoSession#setStrictness(Strictness) strictness} of the + * session defaults to {@link Strictness#STRICT_STUBS}. Use + * {@link MockitoBeanSettings} to specify a different strictness. * *

The automatic reset support for {@code @MockBean} and {@code @SpyBean} is * handled by the {@link MockitoResetTestExecutionListener}. @@ -97,37 +104,67 @@ public class MockitoTestExecutionListener extends AbstractTestExecutionListener private void initMocks(TestContext testContext) { if (hasMockitoAnnotations(testContext)) { Object testInstance = testContext.getTestInstance(); - testContext.setAttribute(MOCKS_ATTRIBUTE_NAME, MockitoAnnotations.openMocks(testInstance)); + MockitoBeanSettings annotation = AnnotationUtils.findAnnotation(testInstance.getClass(), + MockitoBeanSettings.class); + testContext.setAttribute(MOCKS_ATTRIBUTE_NAME, initMockitoSession(testInstance, + annotation == null ? Strictness.STRICT_STUBS: annotation.value())); } } + private MockitoSession initMockitoSession(Object testInstance, Strictness strictness) { + return BDDMockito.mockitoSession() + .initMocks(testInstance) + .strictness(strictness) + .startMocking(); + } + private void closeMocks(TestContext testContext) throws Exception { Object mocks = testContext.getAttribute(MOCKS_ATTRIBUTE_NAME); - if (mocks instanceof AutoCloseable closeable) { + if (mocks instanceof MockitoSession session) { + session.finishMocking(); + } + else if (mocks instanceof AutoCloseable closeable) { closeable.close(); } } private boolean hasMockitoAnnotations(TestContext testContext) { MockitoAnnotationCollector collector = new MockitoAnnotationCollector(); - ReflectionUtils.doWithFields(testContext.getTestClass(), collector); + collector.collect(testContext.getTestClass()); return collector.hasAnnotations(); } /** - * {@link FieldCallback} that collects Mockito annotations. + * Utility class that collects {@code org.mockito} annotations and the + * annotations in this package (like {@link MockitoBeanSettings}). */ private static final class MockitoAnnotationCollector implements FieldCallback { + private static final String MOCKITO_BEAN_PACKAGE = MockitoBean.class.getPackageName(); + private static final String ORG_MOCKITO_PACKAGE = "org.mockito"; + private final Set annotations = new LinkedHashSet<>(); + public void collect(Class clazz) { + ReflectionUtils.doWithFields(clazz, this); + for (Annotation annotation : clazz.getAnnotations()) { + collect(annotation); + } + } + @Override public void doWith(Field field) throws IllegalArgumentException { for (Annotation annotation : field.getAnnotations()) { - if (annotation.annotationType().getPackageName().startsWith("org.mockito")) { - this.annotations.add(annotation); - } + collect(annotation); + } + } + + private void collect(Annotation annotation) { + String packageName = annotation.annotationType().getPackageName(); + if (packageName.startsWith(MOCKITO_BEAN_PACKAGE) || + packageName.startsWith(ORG_MOCKITO_PACKAGE)) { + this.annotations.add(annotation); } } diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoBeanSettingsLenientIntegrationTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoBeanSettingsLenientIntegrationTests.java new file mode 100644 index 00000000000..880da6f40ca --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoBeanSettingsLenientIntegrationTests.java @@ -0,0 +1,44 @@ +/* + * 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.mockito; + +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.mockito.quality.Strictness; + +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +/** + * Integration tests for explicitly-defined {@link MockitoBeanSettings} with + * lenient stubbing. + * + * @author Simon Baslé + * @since 6.2 + */ +@SpringJUnitConfig(MockitoBeanForByNameLookupIntegrationTests.Config.class) +@MockitoBeanSettings(Strictness.LENIENT) +public class MockitoBeanSettingsLenientIntegrationTests { + + @Test + public void unusedStubbingNotReported() { + var list = Mockito.mock(List.class); + Mockito.when(list.get(Mockito.anyInt())).thenReturn(new Object()); + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoBeanSettingsStrictIntegrationTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoBeanSettingsStrictIntegrationTests.java new file mode 100644 index 00000000000..92c6e7d2e8b --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoBeanSettingsStrictIntegrationTests.java @@ -0,0 +1,96 @@ +/* + * 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.mockito; + +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.stream.Stream; + +import org.junit.jupiter.api.Named; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.platform.testkit.engine.EngineTestKit; +import org.junit.platform.testkit.engine.Events; +import org.mockito.Mockito; +import org.mockito.exceptions.misusing.UnnecessaryStubbingException; +import org.mockito.quality.Strictness; + +import org.springframework.test.context.bean.override.mockito.MockitoBeanForByNameLookupIntegrationTests.Config; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import static org.junit.platform.engine.discovery.DiscoverySelectors.selectClass; +import static org.junit.platform.testkit.engine.EventConditions.event; +import static org.junit.platform.testkit.engine.EventConditions.finishedWithFailure; +import static org.junit.platform.testkit.engine.EventConditions.test; +import static org.junit.platform.testkit.engine.TestExecutionResultConditions.instanceOf; +import static org.junit.platform.testkit.engine.TestExecutionResultConditions.message; + +/** + * Integration tests ensuring unnecessary stubbings are reported in various + * cases where a strict style is chosen or assumed. + * + * @author Simon Baslé + * @since 6.2 + */ +public final class MockitoBeanSettingsStrictIntegrationTests { + + private static Stream>> strictCases() { + return Stream.of( + Named.of("explicit strictness", ExplicitStrictness.class), + Named.of("implicit strictness with @MockitoBean on field", ImplicitStrictnessWithMockitoBean.class) + ); + } + + @ParameterizedTest + @MethodSource("strictCases") + public void unusedStubbingIsReported(Class forCase) { + Events events = EngineTestKit.engine("junit-jupiter") + .selectors(selectClass(forCase)) + .execute() + .testEvents() + .assertStatistics(stats -> stats.started(1).failed(1)); + + events.assertThatEvents().haveExactly(1, + event(test("unnecessaryStub"), + finishedWithFailure( + instanceOf(UnnecessaryStubbingException.class), + message(msg -> msg.contains("Unnecessary stubbings detected."))))); + } + + abstract static class BaseCase { + @Test + void unnecessaryStub() { + var list = Mockito.mock(List.class); + Mockito.when(list.get(Mockito.anyInt())).thenReturn(new Object()); + } + } + + @SpringJUnitConfig(Config.class) + @MockitoBeanSettings(Strictness.STRICT_STUBS) + static class ExplicitStrictness extends BaseCase { + } + + @SpringJUnitConfig(Config.class) + static class ImplicitStrictnessWithMockitoBean extends BaseCase { + + @MockitoBean + @SuppressWarnings("unused") + DateTimeFormatter ignoredMock; + } + +}