diff --git a/spring-test/src/main/java/org/springframework/test/context/CacheAwareContextLoaderDelegate.java b/spring-test/src/main/java/org/springframework/test/context/CacheAwareContextLoaderDelegate.java index fc2a4cc832..9e5def712c 100644 --- a/spring-test/src/main/java/org/springframework/test/context/CacheAwareContextLoaderDelegate.java +++ b/spring-test/src/main/java/org/springframework/test/context/CacheAwareContextLoaderDelegate.java @@ -103,7 +103,9 @@ public interface CacheAwareContextLoaderDelegate { * context consistently fails to load — for example, due to a configuration * error that prevents the context from successfully loading — this * method should preemptively throw an {@link IllegalStateException} if the - * configured failure threshold has been exceeded. + * configured failure threshold has been exceeded. Note that the {@code ContextCache} + * provides support for tracking and incrementing the failure count for a given + * context cache key. *

The cache statistics should be logged by invoking * {@link org.springframework.test.context.cache.ContextCache#logStatistics()}. * @param mergedConfig the merged context configuration to use to load the diff --git a/spring-test/src/main/java/org/springframework/test/context/cache/ContextCache.java b/spring-test/src/main/java/org/springframework/test/context/cache/ContextCache.java index fcdff6d041..d63715d04c 100644 --- a/spring-test/src/main/java/org/springframework/test/context/cache/ContextCache.java +++ b/spring-test/src/main/java/org/springframework/test/context/cache/ContextCache.java @@ -31,6 +31,11 @@ import org.springframework.test.context.MergedContextConfiguration; * with a {@linkplain ContextCacheUtils#retrieveMaxCacheSize maximum size} and * a custom eviction policy. * + *

As of Spring Framework 6.1, this SPI includes optional support for + * {@linkplain #getFailureCount(MergedContextConfiguration) tracking} and + * {@linkplain #incrementFailureCount(MergedContextConfiguration) incrementing} + * failure counts. + * *

Rationale

*

Context caching can have significant performance benefits if context * initialization is complex. Although the initialization of a Spring context @@ -117,6 +122,37 @@ public interface ContextCache { */ void remove(MergedContextConfiguration key, @Nullable HierarchyMode hierarchyMode); + /** + * Get the failure count for the given key. + *

A failure is any attempt to load the {@link ApplicationContext} + * for the given key that results in an exception. + *

The default implementation of this method always returns {@code 0}. + * Concrete implementations are therefore highly encouraged to override this + * method and {@link #incrementFailureCount(MergedContextConfiguration)} with + * appropriate behavior. Note that the standard {@code ContextContext} + * implementation in Spring overrides these methods appropriately. + * @param key the context key; never {@code null} + * @since 6.1 + * @see #incrementFailureCount(MergedContextConfiguration) + */ + default int getFailureCount(MergedContextConfiguration key) { + return 0; + } + + /** + * Increment the failure count for the given key. + *

The default implementation of this method does nothing. Concrete + * implementations are therefore highly encouraged to override this + * method and {@link #getFailureCount(MergedContextConfiguration)} with + * appropriate behavior. Note that the standard {@code ContextContext} + * implementation in Spring overrides these methods appropriately. + * @param key the context key; never {@code null} + * @since 6.1 + * @see #getFailureCount(MergedContextConfiguration) + */ + default void incrementFailureCount(MergedContextConfiguration key) { + } + /** * Determine the number of contexts currently stored in the cache. *

If the cache contains more than {@code Integer.MAX_VALUE} elements, diff --git a/spring-test/src/main/java/org/springframework/test/context/cache/DefaultCacheAwareContextLoaderDelegate.java b/spring-test/src/main/java/org/springframework/test/context/cache/DefaultCacheAwareContextLoaderDelegate.java index 3db2cb6259..53f281c4ae 100644 --- a/spring-test/src/main/java/org/springframework/test/context/cache/DefaultCacheAwareContextLoaderDelegate.java +++ b/spring-test/src/main/java/org/springframework/test/context/cache/DefaultCacheAwareContextLoaderDelegate.java @@ -16,9 +16,7 @@ package org.springframework.test.context.cache; -import java.util.HashMap; import java.util.List; -import java.util.Map; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; @@ -80,12 +78,6 @@ public class DefaultCacheAwareContextLoaderDelegate implements CacheAwareContext private final ContextCache contextCache; - /** - * Map of context keys to context load failure counts. - * @since 6.1 - */ - private final Map failureCounts = new HashMap<>(32); - /** * The configured failure threshold for errors encountered while attempting to * load an {@link ApplicationContext}. @@ -144,7 +136,7 @@ public class DefaultCacheAwareContextLoaderDelegate implements CacheAwareContext synchronized (this.contextCache) { ApplicationContext context = this.contextCache.get(mergedConfig); if (context == null) { - Integer failureCount = this.failureCounts.getOrDefault(mergedConfig, 0); + Integer failureCount = this.contextCache.getFailureCount(mergedConfig); if (failureCount >= this.failureThreshold) { throw new IllegalStateException(""" ApplicationContext failure threshold (%d) exceeded: \ @@ -165,7 +157,7 @@ public class DefaultCacheAwareContextLoaderDelegate implements CacheAwareContext this.contextCache.put(mergedConfig, context); } catch (Exception ex) { - this.failureCounts.put(mergedConfig, ++failureCount); + this.contextCache.incrementFailureCount(mergedConfig); Throwable cause = ex; if (ex instanceof ContextLoadException cle) { cause = cle.getCause(); diff --git a/spring-test/src/main/java/org/springframework/test/context/cache/DefaultContextCache.java b/spring-test/src/main/java/org/springframework/test/context/cache/DefaultContextCache.java index b66fd3f2e0..37e687e2b7 100644 --- a/spring-test/src/main/java/org/springframework/test/context/cache/DefaultContextCache.java +++ b/spring-test/src/main/java/org/springframework/test/context/cache/DefaultContextCache.java @@ -72,6 +72,14 @@ public class DefaultContextCache implements ContextCache { private final Map> hierarchyMap = new ConcurrentHashMap<>(32); + /** + * Map of context keys to context load failure counts. + * @since 6.1 + */ + private final Map failureCounts = new ConcurrentHashMap<>(32); + + private final AtomicInteger totalFailureCount = new AtomicInteger(); + private final int maxSize; private final AtomicInteger hitCount = new AtomicInteger(); @@ -209,6 +217,23 @@ public class DefaultContextCache implements ContextCache { removedContexts.add(key); } + /** + * {@inheritDoc} + */ + @Override + public int getFailureCount(MergedContextConfiguration key) { + return this.failureCounts.getOrDefault(key, 0); + } + + /** + * {@inheritDoc} + */ + @Override + public void incrementFailureCount(MergedContextConfiguration key) { + this.totalFailureCount.incrementAndGet(); + this.failureCounts.merge(key, 1, Integer::sum); + } + /** * {@inheritDoc} */ @@ -256,6 +281,8 @@ public class DefaultContextCache implements ContextCache { synchronized (this.contextMap) { clear(); clearStatistics(); + this.totalFailureCount.set(0); + this.failureCounts.clear(); } } @@ -306,6 +333,7 @@ public class DefaultContextCache implements ContextCache { .append("parentContextCount", getParentContextCount()) .append("hitCount", getHitCount()) .append("missCount", getMissCount()) + .append("failureCount", this.totalFailureCount) .toString(); } diff --git a/spring-test/src/test/java/org/springframework/test/context/cache/ContextFailureThresholdTests.java b/spring-test/src/test/java/org/springframework/test/context/cache/ContextFailureThresholdTests.java index a240637e16..d6a51618f6 100644 --- a/spring-test/src/test/java/org/springframework/test/context/cache/ContextFailureThresholdTests.java +++ b/spring-test/src/test/java/org/springframework/test/context/cache/ContextFailureThresholdTests.java @@ -18,13 +18,9 @@ package org.springframework.test.context.cache; import java.util.concurrent.atomic.AtomicInteger; -import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.MethodOrderer.OrderAnnotation; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.TestMethodOrder; import org.junit.platform.testkit.engine.EngineTestKit; import org.springframework.context.annotation.Bean; @@ -49,104 +45,61 @@ import static org.springframework.test.context.cache.ContextCacheTestUtils.reset */ class ContextFailureThresholdTests { - private static final AtomicInteger loadCount = new AtomicInteger(0); + private static final AtomicInteger passingLoadCount = new AtomicInteger(0); + private static final AtomicInteger failingLoadCount = new AtomicInteger(0); @BeforeEach @AfterEach void resetFlag() { - loadCount.set(0); + resetContextCache(); + passingLoadCount.set(0); + failingLoadCount.set(0); SpringProperties.setProperty(CONTEXT_FAILURE_THRESHOLD_PROPERTY_NAME, null); } @Test void defaultThreshold() { - assertThat(loadCount.get()).isZero(); - - EngineTestKit.engine("junit-jupiter")// - .selectors(selectClass(PassingTestCase.class))// 2 passing - .selectors(selectClass(FailingTestCase.class))// 3 failing - .execute()// - .testEvents()// - .assertStatistics(stats -> stats.started(5).succeeded(2).failed(3)); - assertThat(loadCount.get()).isEqualTo(DEFAULT_CONTEXT_FAILURE_THRESHOLD); + runTests(); + assertThat(passingLoadCount.get()).isEqualTo(1); + assertThat(failingLoadCount.get()).isEqualTo(DEFAULT_CONTEXT_FAILURE_THRESHOLD); } @Test void customThreshold() { - assertThat(loadCount.get()).isZero(); + int customThreshold = 2; + SpringProperties.setProperty(CONTEXT_FAILURE_THRESHOLD_PROPERTY_NAME, Integer.toString(customThreshold)); - int threshold = 2; - SpringProperties.setProperty(CONTEXT_FAILURE_THRESHOLD_PROPERTY_NAME, Integer.toString(threshold)); - - EngineTestKit.engine("junit-jupiter")// - .selectors(selectClass(PassingTestCase.class))// 2 passing - .selectors(selectClass(FailingTestCase.class))// 3 failing - .execute()// - .testEvents()// - .assertStatistics(stats -> stats.started(5).succeeded(2).failed(3)); - assertThat(loadCount.get()).isEqualTo(threshold); + runTests(); + assertThat(passingLoadCount.get()).isEqualTo(1); + assertThat(failingLoadCount.get()).isEqualTo(customThreshold); } @Test void thresholdEffectivelyDisabled() { - assertThat(loadCount.get()).isZero(); - SpringProperties.setProperty(CONTEXT_FAILURE_THRESHOLD_PROPERTY_NAME, "999999"); + runTests(); + assertThat(passingLoadCount.get()).isEqualTo(1); + assertThat(failingLoadCount.get()).isEqualTo(6); + } + + private static void runTests() { EngineTestKit.engine("junit-jupiter")// - .selectors(selectClass(PassingTestCase.class))// 2 passing - .selectors(selectClass(FailingTestCase.class))// 3 failing - .execute()// - .testEvents()// - .assertStatistics(stats -> stats.started(5).succeeded(2).failed(3)); - assertThat(loadCount.get()).isEqualTo(3); + .selectors(// + selectClass(PassingTestCase.class), // 3 passing + selectClass(FailingConfigTestCase.class), // 3 failing + selectClass(SharedFailingConfigTestCase.class) // 3 failing + )// + .execute()// + .testEvents()// + .assertStatistics(stats -> stats.started(9).succeeded(3).failed(6)); + assertContextCacheStatistics(1, 2, (1 + 3 + 3)); } - @SpringJUnitConfig @TestExecutionListeners(DependencyInjectionTestExecutionListener.class) - static class PassingTestCase { - - @BeforeAll - static void verifyInitialCacheState() { - resetContextCache(); - assertContextCacheStatistics("BeforeAll", 0, 0, 0); - } - - @AfterAll - static void verifyFinalCacheState() { - assertContextCacheStatistics("AfterAll", 1, 1, 1); - resetContextCache(); - } - - @Test - void test1() {} - - @Test - void test2() {} - - @Configuration - static class PassingConfig { - } - } - - @SpringJUnitConfig - @TestExecutionListeners(DependencyInjectionTestExecutionListener.class) - @TestMethodOrder(OrderAnnotation.class) - static class FailingTestCase { - - @BeforeAll - static void verifyInitialCacheState() { - resetContextCache(); - assertContextCacheStatistics("BeforeAll", 0, 0, 0); - } - - @AfterAll - static void verifyFinalCacheState() { - assertContextCacheStatistics("AfterAll", 0, 0, 3); - resetContextCache(); - } + static abstract class BaseTestCase { @Test void test1() {} @@ -156,18 +109,38 @@ class ContextFailureThresholdTests { @Test void test3() {} + } - @Configuration - static class FailingConfig { + @SpringJUnitConfig(PassingConfig.class) + static class PassingTestCase extends BaseTestCase { + } - FailingConfig() { - loadCount.incrementAndGet(); - } + @SpringJUnitConfig(FailingConfig.class) + static class FailingConfigTestCase extends BaseTestCase { + } - @Bean - String explosiveString() { - throw new RuntimeException("Boom!"); - } + @SpringJUnitConfig(FailingConfig.class) + static class SharedFailingConfigTestCase extends BaseTestCase { + } + + @Configuration + static class PassingConfig { + + PassingConfig() { + passingLoadCount.incrementAndGet(); + } + } + + @Configuration + static class FailingConfig { + + FailingConfig() { + failingLoadCount.incrementAndGet(); + } + + @Bean + String explosiveString() { + throw new RuntimeException("Boom!"); } }