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!");
}
}