home / skills / giuseppe-trisciuoglio / developer-kit / unit-test-caching

This skill guides unit testing of Spring Cache annotations using in-memory caches to verify hits, misses, eviction, and key generation.

npx playbooks add skill giuseppe-trisciuoglio/developer-kit --skill unit-test-caching

Review the files below or copy the command above to add this skill to your agents.

Files (1)
SKILL.md
12.8 KB
---
name: unit-test-caching
description: Provides patterns for unit testing caching behavior using Spring Cache annotations (@Cacheable, @CachePut, @CacheEvict). Use when validating cache configuration and cache hit/miss scenarios.
category: testing
tags: [junit-5, caching, cacheable, cache-evict, cache-put]
version: 1.0.1
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 testing cache behavior, hits/misses, invalidation strategies, cache key generation, and conditional caching using in-memory cache managers.

## When to Use

Use this skill when:
- Testing @Cacheable method caching
- Testing @CacheEvict cache invalidation
- Testing @CachePut cache updates
- Verifying cache key generation
- Testing conditional caching
- Want fast caching tests without Redis or cache infrastructure

## Instructions

1. **Use in-memory CacheManager**: Use ConcurrentMapCacheManager for tests instead of Redis or other external caches
2. **Verify repository call counts**: Use `times(n)` to verify cache hits/misses by counting repository invocations
3. **Test both cache and eviction scenarios**: Verify data is cached on first call and evicted when appropriate
4. **Test cache key generation**: Ensure SpEL expressions in `@Cacheable(key = "...")` produce correct keys
5. **Test conditional caching**: Verify `unless` and `condition` parameters work correctly
6. **Clear cache between tests**: Reset cache state in @BeforeEach or use @DirtiesContext
7. **Mock service dependencies**: Use mocks for repositories and other services the caching layer uses
8. **Verify cache behavior explicitly**: Don't rely on timing; verify actual cache hit/miss behavior

## Examples

## Setup: Caching Testing

### Maven
```xml
<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>
<dependency>
  <groupId>org.mockito</groupId>
  <artifactId>mockito-core</artifactId>
  <scope>test</scope>
</dependency>
<dependency>
  <groupId>org.assertj</groupId>
  <artifactId>assertj-core</artifactId>
  <scope>test</scope>
</dependency>
```

### Gradle
```kotlin
dependencies {
  implementation("org.springframework.boot:spring-boot-starter-cache")
  testImplementation("org.springframework.boot:spring-boot-starter-test")
  testImplementation("org.mockito:mockito-core")
  testImplementation("org.assertj:assertj-core")
}
```

## Basic Pattern: Testing @Cacheable

### Cache Hit and Miss Behavior

```java
// Service with caching
@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 caching behavior
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.BeforeEach;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.concurrent.ConcurrentMapCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import static org.mockito.Mockito.*;
import static org.assertj.core.api.Assertions.*;

@Configuration
@EnableCaching
class CacheTestConfig {
  @Bean
  public CacheManager cacheManager() {
    return new ConcurrentMapCacheManager("users");
  }
}

class UserServiceCachingTest {

  private UserRepository userRepository;
  private UserService userService;
  private CacheManager cacheManager;

  @BeforeEach
  void setUp() {
    userRepository = mock(UserRepository.class);
    cacheManager = new ConcurrentMapCacheManager("users");
    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 secondCall = userService.getUserById(1L);

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

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

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

    assertThat(cachedResult).isEqualTo(user);
    verify(userRepository, times(1)).findById(1L);
  }
}
```

## Testing @CacheEvict

### Cache Invalidation

```java
@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);
  }

  @CacheEvict(value = "products", allEntries = true)
  public void clearAllProducts() {
    // Clear entire cache
  }
}

class ProductCacheEvictTest {

  private ProductRepository productRepository;
  private ProductService productService;
  private CacheManager cacheManager;

  @BeforeEach
  void setUp() {
    productRepository = mock(ProductRepository.class);
    cacheManager = new ConcurrentMapCacheManager("products");
    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

    User cachedAfterEvict = userService.getUserById(1L);
    
    // After eviction, repository should be called again
    verify(productRepository, times(2)).findById(1L);
  }

  @Test
  void shouldClearAllEntriesFromCache() {
    Product product1 = new Product(1L, "Laptop", 999.99);
    Product product2 = new Product(2L, "Mouse", 29.99);
    when(productRepository.findById(1L)).thenReturn(Optional.of(product1));
    when(productRepository.findById(2L)).thenReturn(Optional.of(product2));

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

    productService.clearAllProducts(); // Clear all cache entries

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

    // Repository called twice for each product
    verify(productRepository, times(2)).findById(1L);
    verify(productRepository, times(2)).findById(2L);
  }
}
```

## Testing @CachePut

### Cache Update

```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 originalOrder = new Order(1L, "Pending", 100.0);
    Order updatedOrder = new Order(1L, "Shipped", 100.0);

    when(orderRepository.findById(1L)).thenReturn(Optional.of(originalOrder));
    when(orderRepository.save(updatedOrder)).thenReturn(updatedOrder);

    orderService.getOrder(1L);
    Order result = orderService.updateOrder(updatedOrder);

    assertThat(result.getStatus()).isEqualTo("Shipped");
    
    // Next call should return updated version from cache
    Order cachedOrder = orderService.getOrder(1L);
    assertThat(cachedOrder.getStatus()).isEqualTo("Shipped");
  }
}
```

## Testing Conditional Caching

### Cache with Conditions

```java
@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 userRepository.findById(id).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);

    // Should call repository twice because null results are not cached
    verify(dataRepository, times(2)).findById(999L);
  }

  @Test
  void shouldNotCacheWhenConditionIsFalse() {
    UserRepository userRepository = mock(UserRepository.class);
    User user = new User(1L, "Alice");
    when(userRepository.findById(-1L)).thenReturn(Optional.of(user));

    DataService service = new DataService(null);

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

    // Should call repository twice because id <= 0 doesn't match condition
    verify(userRepository, times(2)).findById(-1L);
  }
}
```

## Testing Cache Keys

### Verify Cache Key Generation

```java
@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 shouldGenerateCorrectCacheKey() {
    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); // Cache: "1-1"
    service.getInventory(1L, 1L); // Hit cache: "1-1"
    service.getInventory(2L, 1L); // Miss cache: "2-1"

    verify(repository, times(2)).findByProductAndWarehouse(any(), any());
  }
}
```

## Best Practices

- **Use in-memory CacheManager** for unit tests
- **Verify repository calls** to confirm cache hits/misses
- **Test both positive and negative** cache scenarios
- **Test cache invalidation** thoroughly
- **Test conditional caching** with various conditions
- **Keep cache configuration simple** in tests
- **Mock dependencies** that services use

## Common Pitfalls

- Testing actual cache infrastructure instead of caching logic
- Not verifying repository call counts
- Forgetting to test cache eviction
- Not testing conditional caching
- Not resetting cache between tests

## Constraints and Warnings

- **@Cacheable requires a proxy**: Spring's caching works via proxies; direct method calls bypass caching
- **Cache key collisions**: Be aware that different parameters can produce the same cache key if key generation is not specific
- **Serialization requirements**: Cached objects must be serializable when using distributed caches
- **Memory usage**: In-memory caches can consume significant memory; consider TTL and max-size settings
- **@CachePut vs @Cacheable**: @CachePut always executes the method, while @Cacheable skips execution on cache hit
- **Null caching**: By default, null results are cached unless `unless = "#result == null"` is specified
- **Thread safety**: Cache operations should be thread-safe; verify behavior under concurrent access

## Troubleshooting

**Cache not working in tests**: Ensure `@EnableCaching` is in test configuration.

**Wrong cache key generated**: Use `SpEL` syntax correctly in `@Cacheable(key = "...")`.

**Cache not evicting**: Verify `@CacheEvict` key matches stored key exactly.

## References

- [Spring Caching Documentation](https://docs.spring.io/spring-framework/docs/current/reference/html/integration.html#cache)
- [Spring Cache Abstractions](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/cache/annotation/Cacheable.html)
- [SpEL in Caching](https://docs.spring.io/spring-framework/docs/current/reference/html/core.html#expressions)

Overview

This skill provides concise, reusable patterns for unit testing Spring Cache annotations (@Cacheable, @CachePut, @CacheEvict) without booting a full Spring context. It focuses on fast, reliable tests using an in-memory CacheManager, mocks for dependencies, and explicit verification of cache hits, misses, and invalidation. Use it to validate cache configuration, key generation, and conditional caching behavior.

How this skill works

The skill shows how to replace external caches (Redis, etc.) with ConcurrentMapCacheManager in tests and how to configure @EnableCaching in a light test config. It uses Mockito to mock repositories and verifies repository invocation counts to assert cache hits and misses. Patterns cover @Cacheable caching, @CacheEvict invalidation and allEntries behavior, @CachePut updates, SpEL-based cache keys, and conditional caching with condition/unless.

When to use it

  • Unit testing @Cacheable behavior and ensuring method execution is skipped on cache hit
  • Validating @CacheEvict removes single keys or clears all entries
  • Testing @CachePut updates cache entries after persistence operations
  • Verifying custom cache key generation using SpEL expressions
  • Testing conditional caching (condition and unless) and null-result handling

Best practices

  • Use ConcurrentMapCacheManager or an in-memory cache for deterministic, fast tests
  • Mock repositories and verify invocation counts (verify(times(n))) instead of relying on timing
  • Reset or clear caches between tests (@BeforeEach or @DirtiesContext) to avoid cross-test leaks
  • Test positive and negative paths: hits, misses, eviction, and conditional branches
  • Ensure caching proxying is active in test config (@EnableCaching) and avoid direct self-invocation

Example use cases

  • Assert getUserById is called once on repeated calls when @Cacheable is applied
  • Confirm deleteProduct triggers @CacheEvict and subsequent reads hit repository again
  • Validate updateOrder with @CachePut replaces cached value so subsequent reads return updated data
  • Check InventoryService cache key '#productId + "-" + #warehouseId' generates distinct keys for different params
  • Verify @Cacheable(unless = "#result == null") prevents caching of null repository results

FAQ

Do I need a running Spring context to test caching?

No. Use an in-memory CacheManager and a minimal @EnableCaching test configuration to exercise caching behavior without starting the full application.

How do I verify a cache hit in a unit test?

Mock the underlying repository and assert it was called only once while calling the cached method multiple times; use verify(times(1)).