| 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
- Configure in-memory CacheManager: Use
ConcurrentMapCacheManager for tests
- Set up test fixtures: Mock repository and create service instance in
@BeforeEach
- Verify repository call counts: Use
times(n) assertions to confirm cache behavior
- Test cache hit: Call method twice, verify repository called once
- Test cache miss: Verify repository called on each invocation
- Test eviction: After
@CacheEvict, verify repository called again on next read
- Test key generation: Verify compound keys from SpEL expressions
- 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
<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")
}
Testing @Cacheable (Cache Hit/Miss)
@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);
}
}
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));
User firstCall = userService.getUserById(1L);
User userService.getUserById();
assertThat(firstCall).isEqualTo(secondCall);
verify(userRepository, times()).findById();
}
{
(userRepository.findById()).thenReturn(Optional.of( (, )));
userService.getUserById();
userService.getUserById();
verify(userRepository, times()).findById();
}
}
Testing @CacheEvict
@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);
}
}
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();
productService.deleteProduct();
productService.getProductById();
verify(productRepository, times()).findById();
}
{
(, , );
(, , );
(productRepository.findById(anyLong())).thenAnswer(i ->
Optional.of( (i.getArgument(), , )));
productService.getProductById();
productService.getProductById();
productService.clearAllProducts();
productService.getProductById();
productService.getProductById();
verify(productRepository, times()).findById(anyLong());
}
}
Testing @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(, , );
(orderRepository.findById()).thenReturn(Optional.of(original));
(orderRepository.save(updated)).thenReturn(updated);
orderService.getOrder();
orderService.updateOrder(updated);
orderService.getOrder();
assertThat(cachedOrder.getStatus()).isEqualTo();
}
}
Testing Conditional Caching
@Service
public class DataService {
private final DataRepository dataRepository;
public DataService(DataRepository dataRepository) {
this.dataRepository = dataRepository;
}
@Cacheable(value = "data", unless = "#result == null")
public Data getData(Long id) {
return dataRepository.findById(id).orElse(null);
}
@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);
}
{
mock(DataRepository.class);
(dataRepository.findById(-)).thenReturn(Optional.of( (-, )));
(dataRepository);
service.getUser(-);
service.getUser(-);
verify(dataRepository, times()).findById(-);
}
}
Testing Cache Keys with SpEL
@Service
public class InventoryService {
private final InventoryRepository inventoryRepository;
public InventoryService(InventoryRepository inventoryRepository) {
this.inventoryRepository = inventoryRepository;
}
@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);
service.getInventory(1L, 1L);
service.getInventory(1L, 1L);
verify(repository, times()).findByProductAndWarehouse(, );
service.getInventory(, );
verify(repository, times()).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