关于 unit-test-caching
unit-test-caching 是一项为 Spring 缓存注解编写单元测试的技能,适合希望不启动完整 Spring 上下文、快速验证缓存行为的开发者。它使用内存中的 ConcurrentMapCacheManager,涵盖命中、未命中、失效、键生成与条件缓存。
主要功能
- 01使用 ConcurrentMapCacheManager
- 02测试缓存命中与未命中
- 03验证缓存驱逐和更新
- 04检查 SpEL 缓存键生成
- 05测试条件缓存规则
适用场景
- 01验证 @Cacheable 方法行为
- 02确认 @CacheEvict 能使缓存失效
- 03测试 @CachePut 更新结果
- 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 的快速单元测试中模拟缓存管理器
说明
- 配置内存中的 CacheManager:在测试中使用
ConcurrentMapCacheManager - 设置测试装置:模拟 repository,并在
@BeforeEach中创建服务实例 - 验证 repository 调用次数:使用
times(n)断言确认缓存行为 - 测试缓存命中:调用方法两次,验证 repository 只调用一次
- 测试缓存未命中:验证每次调用都会调用 repository
- 测试缓存清除:执行
@CacheEvict后,验证下次读取时会再次调用 repository - 测试键生成:验证 SpEL 表达式生成的复合键
- 验证条件缓存:测试
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