1. 概述
Spring 的声明式缓存机制不仅适用于类或方法实现,还能直接用于接口层面。一个典型的场景就是:在 Spring Data 的 Repository 接口上使用 @Cacheable
注解来缓存查询结果。
本文将重点演示如何正确测试这种带缓存的 Repository 接口行为,确保缓存逻辑按预期工作。我们不关心底层数据库操作是否正确(那是另一个测试范畴),而是聚焦于缓存命中、未命中以及方法调用次数等关键点。
✅ 核心目标:验证缓存机制是否生效
❌ 不关注:Repository 的持久化逻辑、SQL 执行细节
2. 准备工作
先定义一个简单的实体类 Book
:
@Entity
public class Book {
@Id
private UUID id;
private String title;
// 构造函数、getter、setter 省略
}
接着创建一个继承 CrudRepository
的接口,并在查询方法上添加 @Cacheable
:
public interface BookRepository extends CrudRepository<Book, UUID> {
@Cacheable(value = "books", unless = "#a0 == 'Foundation'")
Optional<Book> findFirstByTitle(String title);
}
关键点说明:
value = "books"
:指定缓存名称,对应一个 Cache 实例。unless = "#a0 == 'Foundation'"
:表示当书名是 "Foundation" 时不缓存结果。这个条件纯粹为了方便测试缓存未命中的场景。- 使用
#a0
而不是#title
是因为接口方法的参数名在编译后通常会被擦除,SpEL 无法通过名字访问。✅ 正确做法是使用#root.args[0]
、p0
或简写#a0
。
⚠️ 踩坑提醒:如果你用了 -parameters
编译参数并开启了调试信息,理论上可以用 #title
,但在接口+代理场景下仍建议用 #a0
更稳妥。
3. 测试方案
我们的测试目标很明确:验证缓存是否按规则正确存取数据。下面提供两种主流测试方式,各有适用场景。
3.1 基于 Spring Boot 的集成测试(推荐日常使用)
这种方式启动完整的上下文,适合验证缓存与真实组件协同工作的场景。
@ExtendWith(SpringExtension.class)
@SpringBootTest(classes = CacheApplication.class)
public class BookRepositoryIntegrationTest {
@Autowired
CacheManager cacheManager;
@Autowired
BookRepository repository;
@BeforeEach
void setUp() {
repository.save(new Book(UUID.randomUUID(), "Dune"));
repository.save(new Book(UUID.randomUUID(), "Foundation"));
}
private Optional<Book> getCachedBook(String title) {
return ofNullable(cacheManager.getCache("books"))
.map(c -> c.get(title, Book.class));
}
}
测试缓存命中
验证名为 "Dune" 的书被成功缓存:
@Test
void givenBookThatShouldBeCached_whenFindByTitle_thenResultShouldBePutInCache() {
Optional<Book> dune = repository.findFirstByTitle("Dune");
assertEquals(dune, getCachedBook("Dune"));
}
测试缓存未命中(跳过缓存)
验证 "Foundation" 不会被放入缓存:
@Test
void givenBookThatShouldNotBeCached_whenFindByTitle_thenResultShouldNotBePutInCache() {
repository.findFirstByTitle("Foundation");
assertEquals(Optional.empty(), getCachedBook("Foundation"));
}
📌 核心思路:通过 CacheManager
直接检查缓存状态,绕过 Repository 调用,判断数据是否真的写入了缓存。
✅ 优点:贴近真实运行环境,能发现配置类问题(如缓存未启用)
❌ 缺点:启动慢,不适合高频执行
3.2 基于纯 Spring + Mockito 的集成测试(适合边界逻辑验证)
这种方案更适合验证方法调用次数和代理行为,比如确认缓存命中后底层方法不再被调用。
首先定义一个测试配置类,注入 mock 实例和内存缓存管理器:
@ContextConfiguration
@ExtendWith(SpringExtension.class)
public class BookRepositoryCachingIntegrationTest {
private static final Book DUNE = new Book(UUID.randomUUID(), "Dune");
private static final Book FOUNDATION = new Book(UUID.randomUUID(), "Foundation");
private BookRepository mock;
@Autowired
private BookRepository bookRepository;
@EnableCaching
@Configuration
public static class CachingTestConfig {
@Bean
public BookRepository bookRepositoryMockImplementation() {
return mock(BookRepository.class);
}
@Bean
public CacheManager cacheManager() {
return new ConcurrentMapCacheManager("books");
}
}
}
初始化 mock 行为
⚠️ 注意两个关键细节:
bookRepository
是 Spring AOP 生成的代理对象,要获取原始 mock 需用AopTestUtils.getTargetObject
- 由于配置类只加载一次,必须在每个测试前
reset(mock)
防止状态污染
@BeforeEach
void setUp() {
mock = AopTestUtils.getTargetObject(bookRepository);
reset(mock);
when(mock.findFirstByTitle(eq("Foundation")))
.thenReturn(Optional.of(FOUNDATION));
when(mock.findFirstByTitle(eq("Dune")))
.thenReturn(Optional.of(DUNE))
.thenThrow(new RuntimeException("Book should be cached!"));
}
thenThrow
是个技巧:第一次调用返回值,第二次开始抛异常,这样如果缓存没生效就会触发异常,帮你快速发现问题。
测试缓存生效后不再访问底层接口
@Test
void givenCachedBook_whenFindByTitle_thenRepositoryShouldNotBeHit() {
assertEquals(Optional.of(DUNE), bookRepository.findFirstByTitle("Dune"));
verify(mock).findFirstByTitle("Dune"); // 第一次调用
assertEquals(Optional.of(DUNE), bookRepository.findFirstByTitle("Dune"));
assertEquals(Optional.of(DUNE), bookRepository.findFirstByTitle("Dune"));
verifyNoMoreInteractions(mock); // 后续调用不应再进入 mock
}
测试非缓存项每次都访问接口
@Test
void givenNotCachedBook_whenFindByTitle_thenRepositoryShouldBeHit() {
assertEquals(Optional.of(FOUNDATION), bookRepository.findFirstByTitle("Foundation"));
assertEquals(Optional.of(FOUNDATION), bookRepository.findFirstByTitle("Foundation"));
assertEquals(Optional.of(FOUNDATION), bookRepository.findFirstByTitle("Foundation"));
verify(mock, times(3)).findFirstByTitle("Foundation");
}
✅ 优点:轻量、快速、精准控制行为,适合 CI 环境
❌ 缺点:依赖对代理机制的理解,稍复杂
4. 总结
本文展示了两种测试 Spring Data Repository 上 @Cacheable
注解的有效方法:
方式 | 适用场景 | 推荐程度 |
---|---|---|
Spring Boot + CacheManager | 验证缓存实际存取 | ✅ 强烈推荐 |
Spring + Mockito mock | 验证调用次数、代理行为 | ✅ 辅助使用 |
📌 最佳实践建议:
- 日常开发优先使用 Spring Boot 集成测试 + CacheManager 检查
- 对复杂缓存逻辑(如 condition/unless)可辅以 mock 测试验证调用频次
- 两种方式可以结合使用,互补不足
示例代码已托管至 GitHub:https://github.com/tech-tutorial/spring-boot-caching-demo(模拟地址)