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 行为

⚠️ 注意两个关键细节:

  1. bookRepository 是 Spring AOP 生成的代理对象,要获取原始 mock 需用 AopTestUtils.getTargetObject
  2. 由于配置类只加载一次,必须在每个测试前 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(模拟地址)


原始标题:Testing @Cacheable on Spring Data Repositories