PluginBench
Skill
Review
Audit score 70

unit-test-caching

giuseppe-trisciuoglio/developer-kit

Unit test patterns for Spring Cache annotations (@Cacheable, @CachePut, @CacheEvict) with mocked cache managers.

What is unit-test-caching?

Provides test patterns for Spring caching annotations without full Spring context. Use this when writing unit tests for @Cacheable, @CacheEvict, @CachePut behavior, cache hit/miss verification, key generation, and conditional caching scenarios.

  • Mock ConcurrentMapCacheManager for fast in-memory cache testing
  • Verify cache hits and misses by asserting repository call counts
  • Test @CacheEvict invalidation and cache clearing behavior
  • Validate @CachePut cache updates with mocked repositories
  • Test SpEL cache key generation and compound keys
  • Test conditional caching with unless and condition parameters

How to install unit-test-caching

npx skills add https://github.com/giuseppe-trisciuoglio/developer-kit --skill unit-test-caching
Prerequisites
  • spring-boot-starter-cache dependency
  • spring-boot-starter-test dependency
  • Mockito for mocking repositories
  • JUnit 5 test framework
Claude Code
Cursor
Windsurf
Cline

How to use unit-test-caching

  1. 1.Add spring-boot-starter-cache and spring-boot-starter-test dependencies to your project
  2. 2.Create a test class with @BeforeEach setup that mocks the repository and instantiates the service
  3. 3.Use ConcurrentMapCacheManager or mock CacheManager in tests
  4. 4.Call cached methods multiple times and verify repository call counts with verify(mock, times(n))
  5. 5.Test cache hits by calling method twice and asserting repository called once
  6. 6.Test cache misses by verifying repository called on each invocation
  7. 7.Test @CacheEvict by calling evict method and verifying repository called again on next read
  8. 8.Test conditional caching by checking unless and condition parameter behavior

Use cases

Good for
  • Unit testing @Cacheable methods to verify cache hit/miss behavior
  • Testing @CacheEvict to ensure cache invalidation works correctly
  • Validating @CachePut updates cache with new values
  • Testing cache key generation from SpEL expressions
  • Verifying conditional caching logic with unless (null results) and condition (parameter-based) parameters
Who it's for
  • Spring Boot developers writing unit tests
  • Backend engineers testing caching logic
  • QA engineers validating cache behavior
  • Developers migrating to or implementing Spring caching

unit-test-caching FAQ

How do I test @Cacheable without Spring context?

Mock the repository dependency and instantiate the service directly in @BeforeEach. Use ConcurrentMapCacheManager or a mock CacheManager. Verify cache behavior by checking how many times the repository method was called using Mockito's verify(mock, times(n)).

Why isn't my cache working in tests?

Ensure @EnableCaching annotation is present on your test class or service. Verify method calls go through the Spring proxy (avoid direct this calls). Check that the cache name in @Cacheable matches the cache manager configuration.

How do I test cache key generation with SpEL?

Mock the repository to return different values for different keys. Call the cached method with different parameters and verify the repository is called the expected number of times. Log actual cache keys if needed to debug mismatches.

Can I test @CacheEvict(allEntries=true)?

Yes. Cache multiple entries, call the evict method with allEntries=true, then verify that subsequent calls to retrieve cached entries hit the repository again.

How do I test conditional caching with unless and condition?

For unless, test with null and non-null return values and verify caching behavior differs. For condition, test with parameter values that satisfy and don't satisfy the condition expression.

Full instructions (SKILL.md)

Source of truth, from giuseppe-trisciuoglio/developer-kit.


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

<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
@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

// 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

@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

@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

@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

IssueSolution
Cache not workingVerify @EnableCaching on test config
Proxy bypassUse autowired/constructor injection, not direct this calls
Key mismatchLog cache key with cache.getNativeKey() to debug SpEL
Flaky testsClear cache in @BeforeEach before each test

References