Developer-kit unit-test-caching
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.
install
source · Clone the upstream repo
git clone https://github.com/giuseppe-trisciuoglio/developer-kit
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/giuseppe-trisciuoglio/developer-kit "$T" && mkdir -p ~/.claude/skills && cp -r "$T/plugins/developer-kit-java/skills/unit-test-caching" ~/.claude/skills/giuseppe-trisciuoglio-developer-kit-unit-test-caching && rm -rf "$T"
manifest:
plugins/developer-kit-java/skills/unit-test-caching/SKILL.mdsource content
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
method behavior@Cacheable - Verifying
cache invalidation works correctly@CacheEvict - Testing
cache updates@CachePut - Validating cache key generation from SpEL expressions
- Testing conditional caching with
/unless
parameterscondition - Mocking cache managers in fast unit tests without Redis
Instructions
- Configure in-memory CacheManager: Use
for testsConcurrentMapCacheManager - Set up test fixtures: Mock repository and create service instance in
@BeforeEach - Verify repository call counts: Use
assertions to confirm cache behaviortimes(n) - Test cache hit: Call method twice, verify repository called once
- Test cache miss: Verify repository called on each invocation
- Test eviction: After
, verify repository called again on next read@CacheEvict - Test key generation: Verify compound keys from SpEL expressions
- Validate conditional caching: Test
(null results) andunless
(parameter-based)condition
Validation checkpoints:
- Run test → If cache not working: verify
annotation present@EnableCaching - If proxy issues: ensure method calls go through Spring proxy (no direct
calls)this - If key mismatches: log actual cache key and compare with
expression@Cacheable(key="...")
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)
@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 } }
Testing @CacheEvict
@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
@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
to assert cache behaviorverify(mock, times(n)) - Test both hit and miss scenarios: Don't just test the happy path
- Clear cache state: Reset between tests to avoid flaky results
- Use
: Fast, no external dependenciesConcurrentMapCacheManager - Verify eviction: Always test that
actually invalidates cached data@CacheEvict
Constraints and Warnings
requires proxy: Direct method calls (@Cacheable
) bypass caching - use dependency injectionthis.method()- Cache key collisions: Compound keys from SpEL must be unique per dataset
- Null caching: Null results are cached by default - use
to excludeunless = "#result == null"
always executes: Unlike@CachePut
, it always runs the method@Cacheable- Memory usage: In-memory caches grow unbounded - consider TTL for long-running tests
- Thread safety:
is thread-safe; distributed caches may require additional configConcurrentMapCacheManager
Troubleshooting
| Issue | Solution |
|---|---|
| Cache not working | Verify on test config |
| Proxy bypass | Use autowired/constructor injection, not direct calls |
| Key mismatch | Log cache key with to debug SpEL |
| Flaky tests | Clear cache in before each test |