Skip to content

Commit

Permalink
Honor MockReset without @⁠MockitoBean or @⁠MockitoSpyBean fields
Browse files Browse the repository at this point in the history
Prior to this commit, the static factory methods in MockReset (such as
MockReset.before() and MockReset.after()) could only be applied to
beans within the ApplicationContext if the test class declared at least
one field annotated with either @⁠MockitoBean or @⁠MockitoSpyBean.

However, the Javadoc states that it should be possible to apply
MockReset directly to any mock in the ApplicationContext using the
static methods in MockReset.

To address that, this commit reworks the "enabled" logic in
MockitoResetTestExecutionListener as follows.

- We no longer check for the presence of annotations from the
  org.springframework.test.context.bean.override.mockito package to
  determine if MockReset is enabled.

- Instead, we now rely on a new isEnabled() method to determine if
  MockReset is enabled.

The logic in the isEnabled() method still relies on the mockitoPresent
flag as an initial check; however, mockitoPresent only determines if
Mockito is present in the classpath. It does not determine if Mockito
can actually be used. For example, it does not detect if the necessary
reachability metadata has been registered to use Mockito within a
GraalVM native image.

To address that last point, the isEnabled() method performs an
additional check to determine if Mockito can be used in the current
environment. Specifically, it invokes Mockito.mockingDetails().isMock()
which in turn initializes core Mockito classes without actually
attempting to create a mock. If that fails, that means that Mockito
cannot actually be used in the current environment, which typically
indicates that GraalVM reachability metadata has not been registered
for the org.mockito.plugins.MockMaker in use (such as the
ProxyMockMaker).

In addition, isEnabled() lazily determines if Mockito can be
initialized, since attempting to detect that during static
initialization results in a GraalVM native image error stating that
Mockito internals were "unintentionally initialized at build time".

If Mockito cannot be initialized, MockitoResetTestExecutionListener
logs a DEBUG level message providing access to the corresponding stack
trace, and MockReset support is disabled.

Closes gh-33829
  • Loading branch information
sbrannen committed Oct 31, 2024
1 parent 0846706 commit ba692aa
Show file tree
Hide file tree
Showing 3 changed files with 67 additions and 70 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,12 @@

package org.springframework.test.context.bean.override.mockito;

import java.lang.reflect.AnnotatedElement;
import java.lang.reflect.Field;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Set;
import java.util.function.Predicate;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.mockito.Mockito;

import org.springframework.beans.factory.BeanFactory;
Expand All @@ -33,12 +32,8 @@
import org.springframework.context.ApplicationContext;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.MergedAnnotation;
import org.springframework.core.annotation.MergedAnnotations;
import org.springframework.core.annotation.MergedAnnotations.SearchStrategy;
import org.springframework.lang.Nullable;
import org.springframework.test.context.TestContext;
import org.springframework.test.context.TestContextAnnotationUtils;
import org.springframework.test.context.support.AbstractTestExecutionListener;
import org.springframework.util.ClassUtils;

Expand All @@ -54,13 +49,28 @@
*/
public class MockitoResetTestExecutionListener extends AbstractTestExecutionListener {

private static final Log logger = LogFactory.getLog(MockitoResetTestExecutionListener.class);

/**
* Boolean flag which tracks whether Mockito is present in the classpath.
* @see #mockitoInitialized
* @see #isEnabled()
*/
private static final boolean mockitoPresent = ClassUtils.isPresent("org.mockito.Mockito",
MockitoResetTestExecutionListener.class.getClassLoader());

private static final String SPRING_MOCKITO_PACKAGE = "org.springframework.test.context.bean.override.mockito";

private static final Predicate<MergedAnnotation<?>> isSpringMockitoAnnotation = mergedAnnotation ->
mergedAnnotation.getType().getPackageName().equals(SPRING_MOCKITO_PACKAGE);
/**
* Boolean flag which tracks whether Mockito has been successfully initialized
* in the current environment.
* <p>Even if {@link #mockitoPresent} evaluates to {@code true}, this flag
* may eventually evaluate to {@code false} &mdash; for example, in a GraalVM
* native image if the necessary reachability metadata has not been registered
* for the {@link org.mockito.plugins.MockMaker} in use.
* @see #mockitoPresent
* @see #isEnabled()
*/
@Nullable
private static volatile Boolean mockitoInitialized;


/**
Expand All @@ -73,26 +83,26 @@ public int getOrder() {

@Override
public void beforeTestMethod(TestContext testContext) {
if (mockitoPresent && hasMockitoAnnotations(testContext)) {
if (isEnabled()) {
resetMocks(testContext.getApplicationContext(), MockReset.BEFORE);
}
}

@Override
public void afterTestMethod(TestContext testContext) {
if (mockitoPresent && hasMockitoAnnotations(testContext)) {
if (isEnabled()) {
resetMocks(testContext.getApplicationContext(), MockReset.AFTER);
}
}


private void resetMocks(ApplicationContext applicationContext, MockReset reset) {
private static void resetMocks(ApplicationContext applicationContext, MockReset reset) {
if (applicationContext instanceof ConfigurableApplicationContext configurableContext) {
resetMocks(configurableContext, reset);
}
}

private void resetMocks(ConfigurableApplicationContext applicationContext, MockReset reset) {
private static void resetMocks(ConfigurableApplicationContext applicationContext, MockReset reset) {
ConfigurableListableBeanFactory beanFactory = applicationContext.getBeanFactory();
String[] beanNames = beanFactory.getBeanDefinitionNames();
Set<String> instantiatedSingletons = new HashSet<>(Arrays.asList(beanFactory.getSingletonNames()));
Expand Down Expand Up @@ -139,59 +149,36 @@ private static boolean isStandardBeanOrSingletonFactoryBean(BeanFactory beanFact
}

/**
* Determine if the test class for the supplied {@linkplain TestContext
* test context} uses any of the annotations in this package (such as
* {@link MockitoBean @MockitoBean}).
*/
static boolean hasMockitoAnnotations(TestContext testContext) {
return hasMockitoAnnotations(testContext.getTestClass());
}

/**
* Determine if Mockito annotations are declared on the supplied class, on an
* interface it implements, on a superclass, or on an enclosing class or
* whether a field in any such class is annotated with a Mockito annotation.
* Determine if this listener is enabled in the current environment.
* @see #mockitoPresent
* @see #mockitoInitialized
*/
private static boolean hasMockitoAnnotations(Class<?> clazz) {
// Declared on the class?
if (isAnnotated(clazz)) {
return true;
}

// Declared on a field?
for (Field field : clazz.getDeclaredFields()) {
if (isAnnotated(field)) {
return true;
}
}

// Declared on an interface?
for (Class<?> ifc : clazz.getInterfaces()) {
if (hasMockitoAnnotations(ifc)) {
return true;
}
private static boolean isEnabled() {
if (!mockitoPresent) {
return false;
}

// Declared on a superclass?
Class<?> superclass = clazz.getSuperclass();
if (superclass != null & superclass != Object.class) {
if (hasMockitoAnnotations(superclass)) {
return true;
Boolean enabled = mockitoInitialized;
if (enabled == null) {
try {
// Invoke isMock() on a non-null object to initialize core Mockito classes
// in order to reliably determine if this listener is "enabled" both on the
// JVM as well as within a GraalVM native image.
Mockito.mockingDetails("a string is not a mock").isMock();

// If we got this far, we assume Mockito is usable in the current environment.
enabled = true;
}
}

// Declared on an enclosing class?
if (TestContextAnnotationUtils.searchEnclosingClass(clazz)) {
if (hasMockitoAnnotations(clazz.getEnclosingClass())) {
return true;
catch (Throwable ex) {
enabled = false;
if (logger.isDebugEnabled()) {
logger.debug("""
MockitoResetTestExecutionListener is disabled in the current environment. \
See exception for details.""", ex);
}
}
mockitoInitialized = enabled;
}

return false;
}

private static boolean isAnnotated(AnnotatedElement element) {
return MergedAnnotations.from(element, SearchStrategy.DIRECT).stream().anyMatch(isSpringMockitoAnnotation);
return enabled;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@

import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.BDDMockito.given;

/**
* Integration tests for {@link MockitoResetTestExecutionListener} with a
* {@link MockitoBean @MockitoBean} field.
Expand All @@ -29,15 +32,24 @@
class MockitoResetTestExecutionListenerWithMockitoBeanIntegrationTests
extends MockitoResetTestExecutionListenerWithoutMockitoAnnotationsIntegrationTests {

// The following mock is not used but is currently required to trigger support for MockReset.
// We declare the following to ensure that MockReset is also supported with
// @MockitoBean or @MockitoSpyBean fields present in the test class.
@MockitoBean
StringBuilder unusedVariable;
PuzzleService puzzleService;


// test001() and test002() are in the superclass.

@Test
@Override
void test002() {
super.test002();
void test003() {
given(puzzleService.getAnswer()).willReturn("enigma");
assertThat(puzzleService.getAnswer()).isEqualTo("enigma");
}


interface PuzzleService {

String getAnswer();
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@

package org.springframework.test.context.bean.override.mockito;

import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder;
Expand Down Expand Up @@ -76,7 +75,6 @@ void test001() {
assertThat(context.getBean(NonSingletonFactoryBean.class).getObjectInvocations).isEqualTo(2);
}

@Disabled("MockReset is currently only honored if @MockitoBean or @MockitoSpyBean is used")
@Test
void test002() {
// Should not have been reset.
Expand Down

0 comments on commit ba692aa

Please sign in to comment.