Install any skill in seconds. Free to start, no credit card required.
Get Started Free →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.
.claude/skills/giuseppe-trisciuoglio-unit-test-caching/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-02 | ✗→✓ | ▲ Improved | 118% | 0% |
| case-11 | ✗→✓ | ▲ Improved | 102% | 0% |
| case-03 | ✓→✓ | = Same ✓ | 103% | 0% |
| case-04 | ✓→✓ | = Same ✓ | 105% | 0% |
| case-05 | ✓→✓ | = Same ✓ | 100% | 0% |
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.
@Cacheable method behavior@CacheEvict cache invalidation works correctly@CachePut cache updatesunless/condition parametersConcurrentMapCacheManager for tests@BeforeEachtimes(n) assertions to confirm cache behavior@CacheEvict, verify repository called again on next readunless (null results) and condition (parameter-based)Validation checkpoints:
@EnableCaching annotation presentthis calls)@Cacheable(key="...") expressionxml<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>
kotlindependencies { implementation("org.springframework.boot:spring-boot-starter-cache") testImplementation("org.springframework.boot:spring-boot-starter-test") }
@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 } }
@CacheEvictjava// 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()); } }
@CachePutjava@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"); } }
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 } }
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()); } }
verify(mock, times(n)) to assert cache behaviorConcurrentMapCacheManager: Fast, no external dependencies@CacheEvict actually invalidates cached data@Cacheable requires proxy: Direct method calls (this.method()) bypass caching - use dependency injectionunless = "#result == null" to exclude@CachePut always executes: Unlike @Cacheable, it always runs the methodConcurrentMapCacheManager is thread-safe; distributed caches may require additional config| 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 |
| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-01 | fail→fail | 12,814 | 11,019 | -14% | 1 | 1 | 0% | 2,449 | 4,958 | +102% | 0 | 0 | — |
case-02 | fail→pass | 13,465 | 14,275 | +6% | 1 | 1 | 0% | 2,691 | 5,866 | +118% | 0 | 0 | — |
case-03 | pass→pass | 10,509 | 3,184 | -70% | 1 | 1 | 0% | 1,705 | 3,467 | +103% | 0 | 0 | — |
case-04 | pass→pass | 9,133 | 3,474 | -62% | 1 | 1 | 0% | 1,698 | 3,482 | +105% | 0 | 0 | — |
case-05 | pass→pass | 10,611 | 4,928 | -54% | 1 | 1 | 0% | 1,867 | 3,728 | +100% | 0 | 0 | — |
case-06 | pass→pass | 14,414 | 10,295 | -29% | 1 | 1 | 0% | 2,692 | 4,819 | +79% | 0 | 0 | — |
case-07 | pass→pass | 13,377 | 10,571 | -21% | 1 | 1 | 0% | 2,486 | 4,843 | +95% | 0 | 0 | — |
case-08 | pass→pass | 12,430 | 12,689 | +2% | 1 | 1 | 0% | 2,344 | 5,409 | +131% | 0 | 0 | — |
case-09 | pass→pass | 14,661 | 12,005 | -18% | 1 | 1 | 0% | 2,794 | 5,099 | +82% | 0 | 0 | — |
case-10 | pass→pass | 14,382 | 12,590 | -12% | 1 | 1 | 0% | 2,366 | 5,115 | +116% | 0 | 0 | — |
case-11 | fail→pass | 13,380 | 12,029 | -10% | 1 | 1 | 0% | 2,540 | 5,132 | +102% | 0 | 0 | — |
case-12 | pass→pass | 8,238 | 5,302 | -36% | 1 | 1 | 0% | 1,332 | 3,733 | +180% | 0 | 0 | — |
case-13 | pass→pass | 13,393 | 6,235 | -53% | 1 | 1 | 0% | 2,129 | 3,987 | +87% | 0 | 0 | — |
case-14 | pass→pass | 5,019 | 3,842 | -23% | 1 | 1 | 0% | 818 | 3,480 | +325% | 0 | 0 | — |
case-15 | pass→pass | 10,742 | 5,323 | -50% | 1 | 1 | 0% | 1,847 | 3,850 | +108% | 0 | 0 | — |
case-16 | pass→pass | 8,308 | 2,966 | -64% | 1 | 1 | 0% | 1,439 | 3,366 | +134% | 0 | 0 | — |
case-17 | pass→pass | 9,270 | 3,712 | -60% | 1 | 1 | 0% | 1,599 | 3,544 | +122% | 0 | 0 | — |
case-18 | pass→pass | 10,704 | 5,727 | -46% | 1 | 1 | 0% | 1,761 | 3,806 | +116% | 0 | 0 | — |
case-19 | pass→pass | 9,528 | 7,992 | -16% | 1 | 1 | 0% | 1,566 | 4,166 | +166% | 0 | 0 | — |
case-20 | pass→pass | 13,339 | 10,468 | -22% | 1 | 1 | 0% | 2,684 | 5,015 | +87% | 0 | 0 | — |
case-21 | fail→fail | 12,445 | 9,652 | -22% | 1 | 1 | 0% | 2,280 | 4,774 | +109% | 0 | 0 | — |
case-22 | pass→pass | 23,380 | 9,890 | -58% | 1 | 1 | 0% | 4,102 | 4,885 | +19% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 22 cases were attempted. The headline lift of +9 percentage points is the difference between those two pass rates over the 22 comparable cases.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.