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 843936fcf40..66528de3ead 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 @@ -21,6 +21,7 @@ import org.jspecify.annotations.Nullable; import org.springframework.context.ApplicationContext; import org.springframework.test.annotation.DirtiesContext.HierarchyMode; import org.springframework.test.context.MergedContextConfiguration; +import org.springframework.util.Assert; /** * {@code ContextCache} defines the SPI for caching Spring @@ -102,6 +103,7 @@ public interface ContextCache { * @param key the context key; never {@code null} * @return the corresponding {@code ApplicationContext} instance, or {@code null} * if not found in the cache + * @see #put(MergedContextConfiguration, LoadFunction) * @see #unregisterContextUsage(MergedContextConfiguration, Class) * @see #remove(MergedContextConfiguration, HierarchyMode) */ @@ -113,9 +115,37 @@ public interface ContextCache { * @param key the context key; never {@code null} * @param context the {@code ApplicationContext}; never {@code null} * @see #get(MergedContextConfiguration) + * @see #put(MergedContextConfiguration, LoadFunction) */ void put(MergedContextConfiguration key, ApplicationContext context); + /** + * Explicitly add an {@link ApplicationContext} to the cache under the given + * key, potentially honoring a custom eviction policy. + *

The supplied {@link LoadFunction} will be invoked to load the + * {@code ApplicationContext}. + *

Concrete implementations which honor a custom eviction policy must + * override this method to ensure that an evicted context is removed from the + * cache and closed before a new context is loaded via the supplied + * {@code LoadFunction}. + * @param key the context key; never {@code null} + * @param loadFunction a function which loads the context for the supplied key; + * never {@code null} + * @return the {@code ApplicationContext}; never {@code null} + * @since 7.0 + * @see #get(MergedContextConfiguration) + * @see #put(MergedContextConfiguration, ApplicationContext) + */ + default ApplicationContext put(MergedContextConfiguration key, LoadFunction loadFunction) { + Assert.notNull(key, "Key must not be null"); + Assert.notNull(loadFunction, "LoadFunction must not be null"); + + ApplicationContext applicationContext = loadFunction.loadContext(key); + Assert.state(applicationContext != null, "LoadFunction must return a non-null ApplicationContext"); + put(key, applicationContext); + return applicationContext; + } + /** * Remove the context with the given key from the cache and explicitly * {@linkplain org.springframework.context.ConfigurableApplicationContext#close() close} @@ -281,4 +311,26 @@ public interface ContextCache { */ void logStatistics(); + + /** + * Represents a function that loads an {@link ApplicationContext}. + * + * @since 7.0 + */ + @FunctionalInterface + interface LoadFunction { + + /** + * Load a new {@link ApplicationContext} based on the supplied + * {@link MergedContextConfiguration} and return the context in a fully + * refreshed state. + * @param mergedConfig the merged context configuration to use to load the + * application context + * @return a new application context; never {@code null} + * @see org.springframework.test.context.SmartContextLoader#loadContext(MergedContextConfiguration) + */ + ApplicationContext loadContext(MergedContextConfiguration mergedConfig); + + } + } 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 ac951d7b677..a12ae6b93d8 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 @@ -139,34 +139,44 @@ public class DefaultCacheAwareContextLoaderDelegate implements CacheAwareContext public ApplicationContext loadContext(MergedContextConfiguration mergedConfig) { mergedConfig = replaceIfNecessary(mergedConfig); synchronized (this.contextCache) { - ApplicationContext context = this.contextCache.get(mergedConfig); try { - if (context == null) { - int failureCount = this.contextCache.getFailureCount(mergedConfig); - if (failureCount >= this.failureThreshold) { - throw new IllegalStateException(""" - ApplicationContext failure threshold (%d) exceeded: \ - skipping repeated attempt to load context for %s""" - .formatted(this.failureThreshold, mergedConfig)); + ApplicationContext context = this.contextCache.get(mergedConfig); + if (context != null) { + if (logger.isTraceEnabled()) { + logger.trace("Retrieved ApplicationContext [%s] from cache with key %s".formatted( + System.identityHashCode(context), mergedConfig)); } + return context; + } + + int failureCount = this.contextCache.getFailureCount(mergedConfig); + if (failureCount >= this.failureThreshold) { + throw new IllegalStateException(""" + ApplicationContext failure threshold (%d) exceeded: \ + skipping repeated attempt to load context for %s""" + .formatted(this.failureThreshold, mergedConfig)); + } + + return this.contextCache.put(mergedConfig, key -> { try { - if (mergedConfig instanceof AotMergedContextConfiguration aotMergedConfig) { - context = loadContextInAotMode(aotMergedConfig); + ApplicationContext newContext; + if (key instanceof AotMergedContextConfiguration aotMergedConfig) { + newContext = loadContextInAotMode(aotMergedConfig); } else { - context = loadContextInternal(mergedConfig); + newContext = loadContextInternal(key); } if (logger.isTraceEnabled()) { logger.trace("Storing ApplicationContext [%s] in cache under key %s".formatted( - System.identityHashCode(context), mergedConfig)); + System.identityHashCode(newContext), key)); } - this.contextCache.put(mergedConfig, context); + return newContext; } catch (Exception ex) { if (logger.isTraceEnabled()) { - logger.trace("Incrementing ApplicationContext failure count for " + mergedConfig); + logger.trace("Incrementing ApplicationContext failure count for " + key); } - this.contextCache.incrementFailureCount(mergedConfig); + this.contextCache.incrementFailureCount(key); Throwable cause = ex; if (ex instanceof ContextLoadException cle) { cause = cle.getCause(); @@ -182,22 +192,13 @@ public class DefaultCacheAwareContextLoaderDelegate implements CacheAwareContext } } } - throw new IllegalStateException( - "Failed to load ApplicationContext for " + mergedConfig, cause); + throw new IllegalStateException("Failed to load ApplicationContext for " + key, cause); } - } - else { - if (logger.isTraceEnabled()) { - logger.trace("Retrieved ApplicationContext [%s] from cache with key %s".formatted( - System.identityHashCode(context), mergedConfig)); - } - } + }); } finally { this.contextCache.logStatistics(); } - - return context; } } 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 5451fb5854a..b00c261393b 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 @@ -19,6 +19,7 @@ package org.springframework.test.context.cache; import java.util.ArrayList; import java.util.Collections; import java.util.HashSet; +import java.util.Iterator; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -62,7 +63,7 @@ public class DefaultContextCache implements ContextCache { * Map of context keys to Spring {@code ApplicationContext} instances. */ private final Map contextMap = - Collections.synchronizedMap(new LruCache(32, 0.75f)); + Collections.synchronizedMap(new LinkedHashMap<>(32, 0.75f, true)); /** * Map of parent keys to sets of children keys, representing a top-down tree @@ -157,7 +158,41 @@ public class DefaultContextCache implements ContextCache { Assert.notNull(key, "Key must not be null"); Assert.notNull(context, "ApplicationContext must not be null"); + evictLruContextIfNecessary(); + putInternal(key, context); + } + + @Override + public ApplicationContext put(MergedContextConfiguration key, LoadFunction loadFunction) { + Assert.notNull(key, "Key must not be null"); + Assert.notNull(loadFunction, "LoadFunction must not be null"); + + evictLruContextIfNecessary(); + ApplicationContext context = loadFunction.loadContext(key); + Assert.state(context != null, "LoadFunction must return a non-null ApplicationContext"); + putInternal(key, context); + return context; + } + + /** + * Evict the least recently used (LRU) context if necessary. + * @since 7.0 + */ + private void evictLruContextIfNecessary() { + if (this.contextMap.size() >= this.maxSize) { + Iterator iterator = this.contextMap.keySet().iterator(); + Assert.state(iterator.hasNext(), "Failed to retrieve LRU context"); + // The least recently used (LRU) key is the first/head in a LinkedHashMap + // configured for access-order iteration order. + MergedContextConfiguration lruKey = iterator.next(); + remove(lruKey, HierarchyMode.CURRENT_LEVEL); + } + } + + private void putInternal(MergedContextConfiguration key, ApplicationContext context) { this.contextMap.put(key, context); + + // Update context hierarchy map. MergedContextConfiguration child = key; MergedContextConfiguration parent = child.getParent(); while (parent != null) { @@ -357,37 +392,4 @@ public class DefaultContextCache implements ContextCache { .toString(); } - - /** - * Simple cache implementation based on {@link LinkedHashMap} with a maximum - * size and a least recently used (LRU) eviction policy that - * properly closes application contexts. - * @since 4.3 - */ - @SuppressWarnings("serial") - private class LruCache extends LinkedHashMap { - - /** - * Create a new {@code LruCache} with the supplied initial capacity - * and load factor. - * @param initialCapacity the initial capacity - * @param loadFactor the load factor - */ - LruCache(int initialCapacity, float loadFactor) { - super(initialCapacity, loadFactor, true); - } - - @Override - protected boolean removeEldestEntry(Map.Entry eldest) { - if (this.size() > DefaultContextCache.this.getMaxSize()) { - // Do NOT delete "DefaultContextCache.this."; otherwise, we accidentally - // invoke java.util.Map.remove(Object, Object). - DefaultContextCache.this.remove(eldest.getKey(), HierarchyMode.CURRENT_LEVEL); - } - - // Return false since we invoke a custom eviction algorithm. - return false; - } - } - } diff --git a/spring-test/src/test/java/org/springframework/test/context/cache/LruContextCacheTests.java b/spring-test/src/test/java/org/springframework/test/context/cache/LruContextCacheTests.java index c9edd808d96..8067565fb25 100644 --- a/spring-test/src/test/java/org/springframework/test/context/cache/LruContextCacheTests.java +++ b/spring-test/src/test/java/org/springframework/test/context/cache/LruContextCacheTests.java @@ -16,14 +16,29 @@ package org.springframework.test.context.cache; +import java.util.ArrayList; import java.util.Arrays; +import java.util.HashSet; import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.DisposableBean; +import org.springframework.beans.factory.InitializingBean; import org.springframework.context.ApplicationContext; import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.test.annotation.DirtiesContext.HierarchyMode; import org.springframework.test.context.MergedContextConfiguration; +import org.springframework.test.context.TestContext; +import org.springframework.test.context.TestContextTestUtils; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import static org.assertj.core.api.Assertions.as; import static org.assertj.core.api.Assertions.assertThat; @@ -33,6 +48,7 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; +import static org.springframework.test.context.cache.ContextCacheTestUtils.assertContextCacheStatistics; /** * Tests for the LRU eviction policy in {@link DefaultContextCache}. @@ -65,89 +81,403 @@ class LruContextCacheTests { assertThatIllegalArgumentException().isThrownBy(() -> new DefaultContextCache(0)); } - @Test - void maxCacheSizeOne() { - DefaultContextCache cache = new DefaultContextCache(1); - assertThat(cache.size()).isEqualTo(0); - assertThat(cache.getMaxSize()).isEqualTo(1); - cache.put(fooConfig, fooContext); - assertCacheContents(cache, "Foo"); + @Nested + class PutUnitTests { - cache.put(fooConfig, fooContext); - assertCacheContents(cache, "Foo"); + @Test + void maxCacheSizeOne() { + DefaultContextCache cache = new DefaultContextCache(1); + assertThat(cache.size()).isEqualTo(0); + assertThat(cache.getMaxSize()).isEqualTo(1); - cache.put(barConfig, barContext); - assertCacheContents(cache, "Bar"); + cache.put(fooConfig, fooContext); + assertCacheContents(cache, "Foo"); - cache.put(fooConfig, fooContext); - assertCacheContents(cache, "Foo"); + cache.put(fooConfig, fooContext); + assertCacheContents(cache, "Foo"); + + cache.put(barConfig, barContext); + assertCacheContents(cache, "Bar"); + + cache.put(fooConfig, fooContext); + assertCacheContents(cache, "Foo"); + } + + @Test + void maxCacheSizeThree() { + DefaultContextCache cache = new DefaultContextCache(3); + assertThat(cache.size()).isEqualTo(0); + assertThat(cache.getMaxSize()).isEqualTo(3); + + cache.put(fooConfig, fooContext); + assertCacheContents(cache, "Foo"); + + cache.put(fooConfig, fooContext); + assertCacheContents(cache, "Foo"); + + cache.put(barConfig, barContext); + assertCacheContents(cache, "Foo", "Bar"); + + cache.put(bazConfig, bazContext); + assertCacheContents(cache, "Foo", "Bar", "Baz"); + + cache.put(abcConfig, abcContext); + assertCacheContents(cache, "Bar", "Baz", "Abc"); + } + + @Test + void ensureLruOrderingIsUpdated() { + DefaultContextCache cache = new DefaultContextCache(3); + + // Note: when a new entry is added it is considered the MRU entry and inserted at the tail. + cache.put(fooConfig, fooContext); + cache.put(barConfig, barContext); + cache.put(bazConfig, bazContext); + assertCacheContents(cache, "Foo", "Bar", "Baz"); + + // Note: the MRU entry is moved to the tail when accessed. + cache.get(fooConfig); + assertCacheContents(cache, "Bar", "Baz", "Foo"); + + cache.get(barConfig); + assertCacheContents(cache, "Baz", "Foo", "Bar"); + + cache.get(bazConfig); + assertCacheContents(cache, "Foo", "Bar", "Baz"); + + cache.get(barConfig); + assertCacheContents(cache, "Foo", "Baz", "Bar"); + } + + @Test + void ensureEvictedContextsAreClosed() { + DefaultContextCache cache = new DefaultContextCache(2); + + cache.put(fooConfig, fooContext); + cache.put(barConfig, barContext); + assertCacheContents(cache, "Foo", "Bar"); + + cache.put(bazConfig, bazContext); + assertCacheContents(cache, "Bar", "Baz"); + verify(fooContext, times(1)).close(); + + cache.put(abcConfig, abcContext); + assertCacheContents(cache, "Baz", "Abc"); + verify(barContext, times(1)).close(); + + verify(abcContext, never()).close(); + verify(bazContext, never()).close(); + } } - @Test - void maxCacheSizeThree() { - DefaultContextCache cache = new DefaultContextCache(3); - assertThat(cache.size()).isEqualTo(0); - assertThat(cache.getMaxSize()).isEqualTo(3); + /** + * @since 7.0 + */ + @Nested + class PutWithLoadFunctionUnitTests { - cache.put(fooConfig, fooContext); - assertCacheContents(cache, "Foo"); + @Test + void maxCacheSizeOne() { + DefaultContextCache cache = new DefaultContextCache(1); + assertThat(cache.size()).isEqualTo(0); + assertThat(cache.getMaxSize()).isEqualTo(1); - cache.put(fooConfig, fooContext); - assertCacheContents(cache, "Foo"); + cache.put(fooConfig, key -> fooContext); + assertCacheContents(cache, "Foo"); - cache.put(barConfig, barContext); - assertCacheContents(cache, "Foo", "Bar"); + cache.put(fooConfig, key -> fooContext); + assertCacheContents(cache, "Foo"); - cache.put(bazConfig, bazContext); - assertCacheContents(cache, "Foo", "Bar", "Baz"); + cache.put(barConfig, key -> barContext); + assertCacheContents(cache, "Bar"); - cache.put(abcConfig, abcContext); - assertCacheContents(cache, "Bar", "Baz", "Abc"); + cache.put(fooConfig, key -> fooContext); + assertCacheContents(cache, "Foo"); + } + + @Test + void maxCacheSizeThree() { + DefaultContextCache cache = new DefaultContextCache(3); + assertThat(cache.size()).isEqualTo(0); + assertThat(cache.getMaxSize()).isEqualTo(3); + + cache.put(fooConfig, key -> fooContext); + assertCacheContents(cache, "Foo"); + + cache.put(fooConfig, key -> fooContext); + assertCacheContents(cache, "Foo"); + + cache.put(barConfig, key -> barContext); + assertCacheContents(cache, "Foo", "Bar"); + + cache.put(bazConfig, key -> bazContext); + assertCacheContents(cache, "Foo", "Bar", "Baz"); + + cache.put(abcConfig, key -> abcContext); + assertCacheContents(cache, "Bar", "Baz", "Abc"); + } + + @Test + void ensureLruOrderingIsUpdated() { + DefaultContextCache cache = new DefaultContextCache(3); + + // Note: when a new entry is added it is considered the MRU entry and inserted at the tail. + cache.put(fooConfig, key -> fooContext); + cache.put(barConfig, key -> barContext); + cache.put(bazConfig, key -> bazContext); + assertCacheContents(cache, "Foo", "Bar", "Baz"); + + // Note: the MRU entry is moved to the tail when accessed. + cache.get(fooConfig); + assertCacheContents(cache, "Bar", "Baz", "Foo"); + + cache.get(barConfig); + assertCacheContents(cache, "Baz", "Foo", "Bar"); + + cache.get(bazConfig); + assertCacheContents(cache, "Foo", "Bar", "Baz"); + + cache.get(barConfig); + assertCacheContents(cache, "Foo", "Baz", "Bar"); + } + + @Test + void ensureEvictedContextsAreClosed() { + DefaultContextCache cache = new DefaultContextCache(2); + + cache.put(fooConfig, key -> fooContext); + cache.put(barConfig, key -> barContext); + assertCacheContents(cache, "Foo", "Bar"); + + cache.put(bazConfig, key -> bazContext); + assertCacheContents(cache, "Bar", "Baz"); + verify(fooContext, times(1)).close(); + + cache.put(abcConfig, key -> abcContext); + assertCacheContents(cache, "Baz", "Abc"); + verify(barContext, times(1)).close(); + + verify(abcContext, never()).close(); + verify(bazContext, never()).close(); + } } - @Test - void ensureLruOrderingIsUpdated() { - DefaultContextCache cache = new DefaultContextCache(3); + /** + * @since 7.0 + */ + @Nested + class PutWithLoadFunctionIntegrationTests { - // Note: when a new entry is added it is considered the MRU entry and inserted at the tail. - cache.put(fooConfig, fooContext); - cache.put(barConfig, barContext); - cache.put(bazConfig, bazContext); - assertCacheContents(cache, "Foo", "Bar", "Baz"); + /** + * Mimics a database shared across application contexts. + */ + private static final Set database = new HashSet<>(); - // Note: the MRU entry is moved to the tail when accessed. - cache.get(fooConfig); - assertCacheContents(cache, "Bar", "Baz", "Foo"); + private static final List events = new ArrayList<>(); - cache.get(barConfig); - assertCacheContents(cache, "Baz", "Foo", "Bar"); - cache.get(bazConfig); - assertCacheContents(cache, "Foo", "Bar", "Baz"); + @BeforeEach + @AfterEach + void resetTracking() { + resetEvents(); + DatabaseInitializer.counter.set(0); + database.clear(); + } - cache.get(barConfig); - assertCacheContents(cache, "Foo", "Baz", "Bar"); - } + @Test + void maxCacheSizeOne() { + DefaultContextCache contextCache = new DefaultContextCache(1); - @Test - void ensureEvictedContextsAreClosed() { - DefaultContextCache cache = new DefaultContextCache(2); + // ----------------------------------------------------------------- - cache.put(fooConfig, fooContext); - cache.put(barConfig, barContext); - assertCacheContents(cache, "Foo", "Bar"); + // Get ApplicationContext for TestCase1. + Class testClass1 = TestCase1.class; + TestContext testContext1 = TestContextTestUtils.buildTestContext(testClass1, contextCache); + testContext1.getApplicationContext(); + assertContextCacheStatistics(contextCache, testClass1.getSimpleName(), 1, 1, 0, 1); + assertCacheContents(contextCache, "Config1"); + assertThat(database).containsExactly("enigma1"); + assertThat(events).containsExactly("START 1"); + resetEvents(); - cache.put(bazConfig, bazContext); - assertCacheContents(cache, "Bar", "Baz"); - verify(fooContext, times(1)).close(); + // ----------------------------------------------------------------- - cache.put(abcConfig, abcContext); - assertCacheContents(cache, "Baz", "Abc"); - verify(barContext, times(1)).close(); + // Get ApplicationContext for TestCase2. + Class testClass2 = TestCase2.class; + TestContext testContext2 = TestContextTestUtils.buildTestContext(testClass2, contextCache); + testContext2.getApplicationContext(); + assertContextCacheStatistics(contextCache, testClass2.getSimpleName(), 1, 1, 0, 2); + assertCacheContents(contextCache, "Config2"); + assertThat(database).containsExactly("enigma2"); + assertThat(events).containsExactly("CLOSE 1", "START 2"); + resetEvents(); - verify(abcContext, never()).close(); - verify(bazContext, never()).close(); + // ----------------------------------------------------------------- + + // Get ApplicationContext for TestCase3. + Class testClass3 = TestCase3.class; + TestContext testContext3 = TestContextTestUtils.buildTestContext(testClass3, contextCache); + testContext3.getApplicationContext(); + assertContextCacheStatistics(contextCache, testClass3.getSimpleName(), 1, 1, 0, 3); + assertCacheContents(contextCache, "Config3"); + assertThat(database).containsExactly("enigma3"); + assertThat(events).containsExactly("CLOSE 2", "START 3"); + resetEvents(); + + // ----------------------------------------------------------------- + + // Get ApplicationContext for TestCase1 again. + testContext1.getApplicationContext(); + assertContextCacheStatistics(contextCache, testClass1.getSimpleName(), 1, 1, 0, 4); + assertCacheContents(contextCache, "Config1"); + assertThat(database).containsExactly("enigma4"); + assertThat(events).containsExactly("CLOSE 3", "START 4"); + resetEvents(); + + // ----------------------------------------------------------------- + + testContext1.markApplicationContextDirty(HierarchyMode.EXHAUSTIVE); + assertThat(events).containsExactly("CLOSE 4"); + assertThat(database).isEmpty(); + assertThat(contextCache.size()).isZero(); + } + + @Test + void maxCacheSizeTwo() { + DefaultContextCache contextCache = new DefaultContextCache(2); + + // ----------------------------------------------------------------- + + // Get ApplicationContext for TestCase1. + Class testClass1 = TestCase1.class; + TestContext testContext1 = TestContextTestUtils.buildTestContext(testClass1, contextCache); + testContext1.getApplicationContext(); + assertContextCacheStatistics(contextCache, testClass1.getSimpleName(), 1, 1, 0, 1); + testContext1.markApplicationContextUnused(); + assertContextCacheStatistics(contextCache, testClass1.getSimpleName(), 1, 0, 0, 1); + assertCacheContents(contextCache, "Config1"); + assertThat(events).containsExactly("START 1"); + assertThat(database).containsExactly("enigma1"); + resetEvents(); + + // ----------------------------------------------------------------- + + // Get ApplicationContext for TestCase2. + Class testClass2 = TestCase2.class; + TestContext testContext2 = TestContextTestUtils.buildTestContext(testClass2, contextCache); + testContext2.getApplicationContext(); + assertContextCacheStatistics(contextCache, testClass2.getSimpleName(), 2, 1, 0, 2); + testContext2.markApplicationContextUnused(); + assertContextCacheStatistics(contextCache, testClass2.getSimpleName(), 2, 0, 0, 2); + assertCacheContents(contextCache, "Config1", "Config2"); + assertThat(events).containsExactly("START 2"); + assertThat(database).containsExactly("enigma1", "enigma2"); + resetEvents(); + + // ----------------------------------------------------------------- + + // Get ApplicationContext for TestCase3. + Class testClass3 = TestCase3.class; + TestContext testContext3 = TestContextTestUtils.buildTestContext(testClass3, contextCache); + testContext3.getApplicationContext(); + assertContextCacheStatistics(contextCache, testClass3.getSimpleName(), 2, 1, 0, 3); + testContext3.markApplicationContextUnused(); + assertContextCacheStatistics(contextCache, testClass3.getSimpleName(), 2, 0, 0, 3); + assertCacheContents(contextCache, "Config2", "Config3"); + assertThat(events).containsExactly("CLOSE 1", "START 3"); + // Closing App #1 removed "enigma1" and "enigma2" from the database. + assertThat(database).containsExactly("enigma3"); + resetEvents(); + + // ----------------------------------------------------------------- + + // Get ApplicationContext for TestCase1 again. + testContext1.getApplicationContext(); + assertContextCacheStatistics(contextCache, testClass1.getSimpleName(), 2, 1, 0, 4); + testContext1.markApplicationContextUnused(); + assertContextCacheStatistics(contextCache, testClass1.getSimpleName(), 2, 0, 0, 4); + assertCacheContents(contextCache, "Config3", "Config1"); + assertThat(events).containsExactly("CLOSE 2", "START 4"); + // Closing App #2 removed "enigma3" from the database. + assertThat(database).containsExactly("enigma4"); + resetEvents(); + + // ----------------------------------------------------------------- + + testContext3.markApplicationContextDirty(HierarchyMode.EXHAUSTIVE); + assertThat(events).containsExactly("CLOSE 3"); + resetEvents(); + + testContext1.markApplicationContextDirty(HierarchyMode.EXHAUSTIVE); + assertThat(events).containsExactly("CLOSE 4"); + assertThat(database).isEmpty(); + assertThat(contextCache.size()).isZero(); + } + + + private static void resetEvents() { + events.clear(); + } + + + /** + * Mimics a Spring component that inserts data into the database when the + * application context is started and drops data from a database when the + * application context is closed. + * + * @see org.springframework.jdbc.datasource.init.DataSourceInitializer + */ + static class DatabaseInitializer implements InitializingBean, DisposableBean { + + static final AtomicInteger counter = new AtomicInteger(); + + private final int count; + + + DatabaseInitializer() { + this.count = counter.incrementAndGet(); + } + + @Override + public void afterPropertiesSet() { + events.add("START " + this.count); + database.add("enigma" + this.count); + } + + @Override + public void destroy() { + events.add("CLOSE " + this.count); + database.clear(); + } + } + + @SpringJUnitConfig + static class TestCase1 { + + @Configuration + @Import(DatabaseInitializer.class) + static class Config1 { + } + } + + @SpringJUnitConfig + static class TestCase2 { + + @Configuration + @Import(DatabaseInitializer.class) + static class Config2 { + } + } + + @SpringJUnitConfig + static class TestCase3 { + + @Configuration + @Import(DatabaseInitializer.class) + static class Config3 { + } + } }