Files
spring-boot-demo/caching/src/test/java/com/ankurm/caching/InvalidDeclarationsTest.java
T
asmhatre 66208bcd97 Add caching: the Spring cache abstraction, keys, eviction timing and the self-invocation trap
A Spring Boot 4.1.1 module whose test suite is the evidence for the article: 19 tests
producing 22 transcripts under docs/output/, plus 12 documentation chapters.

Findings the build pins:
- @EnableCaching has no exposeProxy attribute; the widely-copied
  @EnableCaching(exposeProxy = true) does not compile.
- Two methods sharing a cache name and an argument type share a key space, and one
  silently serves the other's answers.
- beforeInvocation = true is NOT deferred by TransactionAwareCacheManagerProxy on
  7.0.9 - doEvict picks evictIfPresent, which the decorator does not intercept.
- Four of five invalid declarations start a clean context and throw at the first call.
- Caffeine on the classpath silently displaces the simple provider.
2026-09-12 05:19:22 +00:00

154 lines
5.9 KiB
Java

package com.ankurm.caching;
import org.junit.jupiter.api.Test;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.cache.autoconfigure.CacheAutoConfiguration;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Declarations the abstraction rejects, and when it tells you. Some of these fail while the
* context is still starting, which is the good case; others wait for the first call.
*
* <p>Every message below is the framework's own, captured from a real failed context.
*/
class InvalidDeclarationsTest {
private final ApplicationContextRunner runner = new ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(CacheAutoConfiguration.class))
.withPropertyValues("spring.cache.type=simple");
@Test
void whatTheAbstractionRefusesAndWhen() {
try (Transcript t = new Transcript("20-invalid-declarations.txt",
"Declarations that are rejected, and how late you find out")) {
t.line("1. key and keyGenerator together");
runner.withUserConfiguration(BothKeyAndGenerator.class).run(context -> {
report(t, context.getStartupFailure());
if (context.getStartupFailure() == null) {
probe(t, context::getBean, BothKeyAndGenerator.Svc.class);
}
});
t.section("2. sync = true with unless");
runner.withUserConfiguration(SyncWithUnless.class).run(context -> {
report(t, context.getStartupFailure());
probe(t, context::getBean, SyncWithUnless.Svc.class);
});
t.section("3. sync = true across two caches");
runner.withUserConfiguration(SyncTwoCaches.class).run(context -> {
report(t, context.getStartupFailure());
probe(t, context::getBean, SyncTwoCaches.Svc.class);
});
t.section("4. @Cacheable and @CacheEvict on one method");
runner.withUserConfiguration(CacheableAndEvict.class).run(context -> {
report(t, context.getStartupFailure());
probe(t, context::getBean, CacheableAndEvict.Svc.class);
});
t.section("5. a cache name that spring.cache.cache-names does not declare");
new ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(CacheAutoConfiguration.class))
.withPropertyValues("spring.cache.type=simple", "spring.cache.cache-names=known")
.withUserConfiguration(UndeclaredCache.class)
.run(context -> {
report(t, context.getStartupFailure());
probe(t, context::getBean, UndeclaredCache.Svc.class);
});
t.line("");
t.line("Only the first of these is a compile-time-shaped mistake. The rest start a");
t.line("perfectly healthy application and throw on a code path that may not be hit");
t.line("for hours.");
}
}
private void report(Transcript t, Throwable startupFailure) {
if (startupFailure == null) {
t.line(" startup : clean");
return;
}
Throwable cause = root(startupFailure);
t.line(" startup : FAILED - %s", cause.getClass().getName());
t.line(" %s", cause.getMessage());
}
private static Throwable root(Throwable t) {
Throwable cause = t;
while (cause.getCause() != null && cause.getCause() != cause) {
cause = cause.getCause();
}
return cause;
}
private <T> void probe(Transcript t, java.util.function.Function<Class<T>, T> lookup, Class<T> type) {
try {
T bean = lookup.apply(type);
Object result = type.getMethod("call", String.class).invoke(bean, "k");
t.line(" first call: returned %s", result);
} catch (Exception e) {
Throwable cause = root(e);
t.line(" first call: %s", cause.getClass().getName());
t.line(" %s", cause.getMessage());
}
}
@Configuration
@EnableCaching
static class BothKeyAndGenerator {
@Bean Svc svc() { return new Svc(); }
static class Svc {
@Cacheable(cacheNames = "c", key = "#a", keyGenerator = "simpleKeyGenerator")
public String call(String a) { return "v:" + a; }
}
}
@Configuration
@EnableCaching
static class SyncWithUnless {
@Bean Svc svc() { return new Svc(); }
static class Svc {
@Cacheable(cacheNames = "c", sync = true, unless = "#result != null")
public String call(String a) { return "v:" + a; }
}
}
@Configuration
@EnableCaching
static class SyncTwoCaches {
@Bean Svc svc() { return new Svc(); }
static class Svc {
@Cacheable(cacheNames = {"c1", "c2"}, sync = true)
public String call(String a) { return "v:" + a; }
}
}
@Configuration
@EnableCaching
static class CacheableAndEvict {
@Bean Svc svc() { return new Svc(); }
static class Svc {
@Cacheable(cacheNames = "c", sync = true)
@CacheEvict(cacheNames = "c")
public String call(String a) { return "v:" + a; }
}
}
@Configuration
@EnableCaching
static class UndeclaredCache {
@Bean Svc svc() { return new Svc(); }
static class Svc {
@Cacheable("unknown")
public String call(String a) { return "v:" + a; }
}
}
}