mcpField
U
unit-test-caching

unit-test-caching

github.com/giuseppe-trisciuoglio测试与质量3653.7k 次安装7 周前更新
代码仓库

关于 unit-test-caching

unit-test-caching 是一项为 Spring 缓存注解编写单元测试的技能,适合希望不启动完整 Spring 上下文、快速验证缓存行为的开发者。它使用内存中的 ConcurrentMapCacheManager,涵盖命中、未命中、失效、键生成与条件缓存。

主要功能

  1. 01使用 ConcurrentMapCacheManager
  2. 02测试缓存命中与未命中
  3. 03验证缓存驱逐和更新
  4. 04检查 SpEL 缓存键生成
  5. 05测试条件缓存规则

适用场景

  1. 01验证 @Cacheable 方法行为
  2. 02确认 @CacheEvict 能使缓存失效
  3. 03测试 @CachePut 更新结果
  4. 04排查 SpEL 键不匹配

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/giuseppe-trisciuoglio/developer-kit --skill unit-test-caching

安装后在 Claude Code 中输入/unit-test-caching确认已加载。

详细介绍

单元测试 Spring 缓存

概述

本技能介绍了不启动完整 Spring 上下文,针对 Spring 缓存注解(@Cacheable、@CacheEvict、@CachePut)编写单元测试的模式。内容涵盖缓存命中/未命中、失效、键生成,以及使用内存中的 ConcurrentMapCacheManager 进行条件缓存。

适用场景

  • 为 @Cacheable 方法行为编写单元测试
  • 验证 @CacheEvict 是否能正确使缓存失效
  • 测试 @CachePut 的缓存更新
  • 验证从 SpEL 表达式生成的缓存键
  • 使用 unless/condition 参数测试条件缓存
  • 在不使用 Redis 的快速单元测试中模拟缓存管理器

说明

  1. 配置内存中的 CacheManager:在测试中使用 ConcurrentMapCacheManager
  2. 设置测试装置:模拟 repository,并在 @BeforeEach 中创建服务实例
  3. 验证 repository 调用次数:使用 times(n) 断言确认缓存行为
  4. 测试缓存命中:调用方法两次,验证 repository 只调用一次
  5. 测试缓存未命中:验证每次调用都会调用 repository
  6. 测试缓存清除:执行 @CacheEvict 后,验证下次读取时会再次调用 repository
  7. 测试键生成:验证 SpEL 表达式生成的复合键
  8. 验证条件缓存:测试 unless(空结果)和 condition(基于参数)

验证检查点:

  • 运行测试 → 如果缓存未生效:确认存在 @EnableCaching 注解
  • 如果遇到代理问题:确保方法调用经过 Spring 代理(不要直接调用 this)
  • 如果键不匹配:记录实际缓存键,并与 @Cacheable(key="...") 表达式对比

示例

Maven
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-test</artifactId>
  <scope>test</scope>
</dependency>
Gradle
dependencies {
  implementation("org.springframework.boot:spring-boot-starter-cache")
  testImplementation("org.springframework.boot:spring-boot-starter-test")
}
测试 @Cacheable(缓存命中/未命中)
// Service
@Service
public class UserService {
  private final UserRepository userRepository;

  public UserService(UserRepository userRepository) {
    this.userRepository = userRepository;
  }

  @Cacheable("users")
  public User getUserById(Long id) {
    return userRepository.findById(id).orElse(null);
  }
}

// Test
class UserServiceCachingTest {

  private UserRepository userRepository;
  private UserService userService;

  @BeforeEach
  void setUp() {
    userRepository = mock(UserRepository.class);
    userService = new UserService(userRepository);
  }

  @Test
  void shouldCacheUserAfterFirstCall() {
    User user = new User(1L, "Alice");
    when(userRepository.findById(1L)).thenReturn(Optional.of(user));

    // First call - hits database
    User firstCall = userService.getUserById(1L);
    // Second call - hits cache
    User secondCall = userService.getUserById(1L);

    assertThat(firstCall).isEqualTo(secondCall);
    verify(userRepository, times(1)).findById(1L); // Only once due to cache
  }

  @Test
  void shouldInvokeRepositoryOnCacheMiss() {
    when(userRepository.findById(1L)).thenReturn(Optional.of(new User(1L, "Bob")));

    userService.getUserById(1L);
    userService.getUserById(1L);

    verify(userRepository, times(2)).findById(1L); // No caching occurred
  }
}
测试 @CacheEvict
// Service
@Service
public class ProductService {
  private final ProductRepository productRepository;

  public ProductService(ProductRepository productRepository) {
    this.productRepository = productRepository;
  }

  @Cacheable("products")
  public Product getProductById(Long id) {
    return productRepository.findById(id).orElse(null);
  }

  @CacheEvict("products")
  public void deleteProduct(Long id) {
    productRepository.deleteById(id);
  }
}

// Test
class ProductCacheEvictTest {

  private ProductRepository productRepository;
  private ProductService productService;

  @BeforeEach
  void setUp() {
    productRepository = mock(ProductRepository.class);
    productService = new ProductService(productRepository);
  }

  @Test
  void shouldEvictProductFromCacheWhenDeleted() {
    Product product = new Product(1L, "Laptop", 999.99);
    when(productRepository.findById(1L)).thenReturn(Optional.of(product));

    productService.getProductById(1L); // Cache the product
    productService.deleteProduct(1L); // Evict from cache

    // Repository called again after eviction
    productService.getProductById(1L);
    verify(productRepository, times(2)).findById(1L);
  }

  @Test
  void shouldClearAllEntriesWithAllEntriesTrue() {
    Product product1 = new Product(1L, "Laptop", 999.99);
    Product product2 = new Product(2L, "Mouse", 29.99);
    when(productRepository.findById(anyLong())).thenAnswer(i ->
      Optional.of(new Product(i.getArgument(0), "Product", 10.0)));

    productService.getProductById(1L);
    productService.getProductById(2L);

    // Use reflection or clear() on ConcurrentMapCache
    productService.clearAllProducts();

    productService.getProductById(1L);
    productService.getProductById(2L);

    verify(productRepository, times(4)).findById(anyLong());
  }
}
测试 @CachePut
@Service
public class OrderService {
  private final OrderRepository orderRepository;

  public OrderService(OrderRepository orderRepository) {
    this.orderRepository = orderRepository;
  }

  @Cacheable("orders")
  public Order getOrder(Long id) {
    return orderRepository.findById(id).orElse(null);
  }

  @CachePut(value = "orders", key = "#order.id")
  public Order updateOrder(Order order) {
    return orderRepository.save(order);
  }
}

class OrderCachePutTest {

  private OrderRepository orderRepository;
  private OrderService orderService;

  @BeforeEach
  void setUp() {
    orderRepository = mock(OrderRepository.class);
    orderService = new OrderService(orderRepository);
  }

  @Test
  void shouldUpdateCacheWhenOrderIsUpdated() {
    Order original = new Order(1L, "Pending", 100.0);
    Order updated = new Order(1L, "Shipped", 100.0);

    when(orderRepository.findById(1L)).thenReturn(Optional.of(original));
    when(orderRepository.save(updated)).thenReturn(updated);

    orderService.getOrder(1L);
    orderService.updateOrder(updated);

    // Next call returns updated version from cache
    Order cachedOrder = orderService.getOrder(1L);
    assertThat(cachedOrder.getStatus()).isEqualTo("Shipped");
  }
}
测试条件缓存
@Service
public class DataService {
  private final DataRepository dataRepository;

  public DataService(DataRepository dataRepository) {
    this.dataRepository = dataRepository;
  }

  // Don't cache null results
  @Cacheable(value = "data", unless = "#result == null")
  public Data getData(Long id) {
    return dataRepository.findById(id).orElse(null);
  }

  // Only cache when id > 0
  @Cacheable(value = "users", condition = "#id > 0")
  public User getUser(Long id) {
    return dataRepository.findById(id).map(u -> new User(u.getId(), u.getName())).orElse(null);
  }
}

class ConditionalCachingTest {

  @Test
  void shouldNotCacheNullResults() {
    DataRepository dataRepository = mock(DataRepository.class);
    when(dataRepository.findById(999L)).thenReturn(Optional.empty());
    DataService service = new DataService(dataRepository);

    service.getData(999L);
    service.getData(999L);

    verify(dataRepository, times(2)).findById(999L); // Called twice - no caching
  }

  @Test
  void shouldNotCacheWhenConditionIsFalse() {
    DataRepository dataRepository = mock(DataRepository.class);
    when(dataRepository.findById(-1L)).thenReturn(Optional.of(new Data(-1L, "Test")));

    DataService service = new DataService(dataRepository);

    service.getUser(-1L);
    service.getUser(-1L);

    verify(dataRepository, times(2)).findById(-1L); // Condition "#id > 0" = false
  }
}
使用 SpEL 测试缓存键
@Service
public class InventoryService {
  private final InventoryRepository inventoryRepository;

  public InventoryService(InventoryRepository inventoryRepository) {
    this.inventoryRepository = inventoryRepository;
  }

  // Compound key: productId-warehouseId
  @Cacheable(value = "inventory", key = "#productId + '-' + #warehouseId")
  public InventoryItem getInventory(Long productId, Long warehouseId) {
    return inventoryRepository.findByProductAndWarehouse(productId, warehouseId);
  }
}

class CacheKeyTest {

  @Test
  void shouldUseCorrectCacheKeyForDifferentCombinations() {
    InventoryRepository repository = mock(InventoryRepository.class);
    InventoryItem item = new InventoryItem(1L, 1L, 100);
    when(repository.findByProductAndWarehouse(1L, 1L)).thenReturn(item);

    InventoryService service = new InventoryService(repository);

    // Same key: "1-1" - should cache
    service.getInventory(1L, 1L);
    service.getInventory(1L, 1L); // Cache hit
    verify(repository, times(1)).findByProductAndWarehouse(1L, 1L);

    // Different key: "2-1" - cache miss
    service.getInventory(2L, 1L); // Cache miss
    verify(repository, times(2)).findByProductAndWarehouse(any(), any());
  }
}

最佳实践

  • 模拟 repository 调用:使用 verify(mock, times(n)) 断言缓存行为
  • 同时测试命中和未命中场景:不要只测试正常路径
  • 清除缓存状态:在测试之间重置,避免结果不稳定
  • 使用 ConcurrentMapCacheManager:速度快,无外部依赖
  • 验证缓存清除:务必测试 @CacheEvict 是否确实使缓存数据失效

限制和警告

  • @Cacheable 需要代理:直接调用方法(this.method())会绕过缓存——请使用依赖注入
  • 缓存键冲突:SpEL 生成的复合键必须在每个数据集中唯一
  • 缓存空值:默认会缓存空结果——使用 unless = "#result == null" 将其排除
  • @CachePut 总会执行:与 @Cacheable 不同,它始终会运行方法
  • 内存使用量:内存缓存会无限增长——对于长时间运行的测试,请考虑设置 TTL
  • 线程安全:ConcurrentMapCacheManager 是线程安全的;分布式缓存可能需要额外配置

故障排除

问题解决方案
缓存未生效确认测试配置上有 @EnableCaching
绕过代理使用自动装配/构造函数注入,而非直接调用 this
键不匹配使用 cache.getNativeKey() 记录缓存键,以调试 SpEL
测试结果不稳定在每个测试前于 @BeforeEach 中清除缓存

参考资料

评论

支持 Markdown