---
name: unit-test-caching
description: "Provides patterns for unit testing Spring Cache annotations (@Cacheable, @CachePut, @CacheEvict). Generates test code that mocks cache managers, verifies cache hit/miss behavior, tests cache key generation with SpEL expressions, validates eviction strategies, and checks conditional caching scenarios. Triggers: caching tests, test Spring cache, mock cache, Spring Boot caching, cache hit/miss verification, @Cacheable testing."
allowed-tools: Read, Write, Bash, Glob, Grep
---
# Unit Testing Spring Caching
## Overview
This skill provides patterns for unit testing Spring caching annotations (`@Cacheable`, `@CacheEvict`, `@CachePut`) without full Spring context. It covers cache hits/misses, invalidation, key generation, and conditional caching using in-memory `ConcurrentMapCacheManager`.
## When to Use
- Writing unit tests for `@Cacheable` method behavior
- Verifying `@CacheEvict` cache invalidation works correctly
- Testing `@CachePut` cache updates
- Validating cache key generation from SpEL expressions
- Testing conditional caching with `unless`/`condition` parameters
- Mocking cache managers in fast unit tests without Redis
## Instructions
1. **Configure in-memory CacheManager**: Use `ConcurrentMapCacheManager` for tests
2. **Set up test fixtures**: Mock repository and create service instance in `@BeforeEach`
3. **Verify repository call counts**: Use `times(n)` assertions to confirm cache behavior
4. **Test cache hit**: Call method twice, verify repository called once
5. **Test cache miss**: Verify repository called on each invocation
6. **Test eviction**: After `@CacheEvict`, verify repository called again on next read
7. **Test key generation**: Verify compound keys from SpEL expressions
8. **Validate conditional caching**: Test `unless` (null results) and `condition` (parameter-based)
**Validation checkpoints:**
- Run test → If cache not working: verify `@EnableCaching` annotation present
- If proxy issues: ensure method calls go through Spring proxy (no direct `this` calls)
- If key mismatches: log actual cache key and compare with `@Cacheable(key="...")` expression
## Examples
### Maven
```xml
org.springframework.boot
spring-boot-starter-cache
org.springframework.boot
spring-boot-starter-test
test
```
### Gradle
```kotlin
dependencies {
implementation("org.springframework.boot:spring-boot-starter-cache")
testImplementation("org.springframework.boot:spring-boot-starter-test")
}
```
### Testing `@Cacheable` (Cache Hit/Miss)
```java
// 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
}
}
```
### Testing `@CacheEvict`
```java
// 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());
}
}
```
### Testing `@CachePut`
```java
@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");
}
}
```
### Testing Conditional Caching
```java
@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
}
}
```
### Testing Cache Keys with SpEL
```java
@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());
}
}
```
## Best Practices
- **Mock repository calls**: Use `verify(mock, times(n))` to assert cache behavior
- **Test both hit and miss scenarios**: Don't just test the happy path
- **Clear cache state**: Reset between tests to avoid flaky results
- **Use `ConcurrentMapCacheManager`**: Fast, no external dependencies
- **Verify eviction**: Always test that `@CacheEvict` actually invalidates cached data
## Constraints and Warnings
- **`@Cacheable` requires proxy**: Direct method calls (`this.method()`) bypass caching - use dependency injection
- **Cache key collisions**: Compound keys from SpEL must be unique per dataset
- **Null caching**: Null results are cached by default - use `unless = "#result == null"` to exclude
- **`@CachePut` always executes**: Unlike `@Cacheable`, it always runs the method
- **Memory usage**: In-memory caches grow unbounded - consider TTL for long-running tests
- **Thread safety**: `ConcurrentMapCacheManager` is thread-safe; distributed caches may require additional config
## Troubleshooting
| Issue | Solution |
|-------|----------|
| Cache not working | Verify `@EnableCaching` on test config |
| Proxy bypass | Use autowired/constructor injection, not direct `this` calls |
| Key mismatch | Log cache key with `cache.getNativeKey()` to debug SpEL |
| Flaky tests | Clear cache in `@BeforeEach` before each test |
## References
- [Spring Caching Documentation](https://docs.spring.io/spring-framework/docs/current/reference/html/integration.html#cache)
- [Cacheable Annotation](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/cache/annotation/Cacheable.html)
- [SpEL Expressions](https://docs.spring.io/spring-framework/docs/current/reference/html/core.html#expressions)