unit-test-caching について
unit-test-caching は、Spring のキャッシュアノテーションをフルコンテキストなしでテストする開発者向けスキルです。メモリ内の ConcurrentMapCacheManager を使い、ヒット、ミス、削除、キー生成、条件付きキャッシュを扱います。
主な機能
- 01ConcurrentMapCacheManager の利用
- 02キャッシュヒットとミスのテスト
- 03キャッシュ削除と更新の検証
- 04SpEL キャッシュキー生成の確認
- 05条件付きキャッシュルールのテスト
活用シーン
- 01@Cacheable メソッドの動作確認
- 02@CacheEvict によるエントリー無効化の確認
- 03@CachePut による更新のテスト
- 04SpEL キー不一致の調査
インストール
コマンドをコピーしてターミナルで実行します。
$
npx skills add https:/ / github.com/ giuseppe-trisciuoglio/ developer-kit --skill unit-test-cachingインストール後、Claude Code で/unit-test-cachingを入力して読み込まれたか確認します。
詳細
Spring キャッシュのユニットテスト
概要
このスキルでは、Spring のキャッシュアノテーション(@Cacheable、@CacheEvict、@CachePut)を完全な Spring コンテキストなしでユニットテストするためのパターンを紹介します。メモリー内の ConcurrentMapCacheManager を使用したキャッシュのヒット/ミス、無効化、キー生成、条件付きキャッシュについて説明します。
使用する場面
@Cacheableメソッドの動作をユニットテストするとき@CacheEvictによるキャッシュの無効化が正しく機能することを検証するとき@CachePutによるキャッシュの更新をテストするとき- SpEL 式からのキャッシュキー生成を検証するとき
unless/conditionパラメーターを使用した条件付きキャッシュをテストするとき- Redis を使わず、高速なユニットテストでキャッシュマネージャーをモックするとき
手順
- メモリー内の CacheManager を設定する:テストには
ConcurrentMapCacheManagerを使用します - テスト用フィクスチャを準備する:
@BeforeEachでリポジトリをモックし、サービスインスタンスを作成します - リポジトリの呼び出し回数を検証する:
times(n)アサーションでキャッシュの動作を確認します - キャッシュヒットをテストする:メソッドを 2 回呼び出し、リポジトリが 1 回だけ呼び出されることを検証します
- キャッシュミスをテストする:呼び出すたびにリポジトリが呼び出されることを検証します
- 削除をテストする:
@CacheEvictの後、次の読み取りで再びリポジトリが呼び出されることを検証します - キー生成をテストする:SpEL 式から複合キーが生成されることを検証します
- 条件付きキャッシュを検証する:
unless(null の結果)とcondition(パラメーターに基づく条件)をテストします
検証チェックポイント:
- テストを実行してキャッシュが機能しない場合:
@EnableCachingアノテーションがあることを確認します - プロキシの問題がある場合:メソッド呼び出しが Spring プロキシを経由することを確認します(
thisによる直接呼び出しは避けます) - キーが一致しない場合:実際のキャッシュキーをログに出力し、
@Cacheable(key="...")式と比較します
例
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")
}
@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
}
}
@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());
}
}
@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");
}
}
条件付きキャッシュのテスト
@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
}
}
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());
}
}
ベストプラクティス
- リポジトリの呼び出しをモックする:
verify(mock, times(n))を使ってキャッシュの動作を検証します - ヒットとミスの両方のケースをテストする:正常系だけをテストしないようにします
- キャッシュの状態をクリアする:不安定な結果を避けるため、テスト間で状態をリセットします
ConcurrentMapCacheManagerを使用する:高速で、外部依存関係がありません- 削除を検証する:
@CacheEvictがキャッシュデータを実際に無効化することを必ずテストします
制約と警告
@Cacheableにはプロキシが必要です:メソッドを直接呼び出す(this.method())とキャッシュが回避されるため、依存性注入を使用します- キャッシュキーの衝突:SpEL で生成する複合キーは、データセットごとに一意である必要があります
- null のキャッシュ:デフォルトでは null の結果もキャッシュされます。除外するには
unless = "#result == null"を使用します @CachePutは常に実行されます:@Cacheableとは異なり、常にメソッドを実行します- メモリー使用量:メモリー内キャッシュは上限なく増加します。長時間実行するテストでは TTL を検討してください
- スレッドセーフ性:
ConcurrentMapCacheManagerはスレッドセーフです。分散キャッシュでは追加の設定が必要になる場合があります
トラブルシューティング
| 問題 | 解決策 |
|---|---|
| キャッシュが機能しない | テスト設定に @EnableCaching があることを確認します |
| プロキシの回避 | this による直接呼び出しではなく、Autowired またはコンストラクターインジェクションを使用します |
| キーが一致しない | SpEL のデバッグには cache.getNativeKey() でキャッシュキーをログに出力します |
| テストが不安定 | 各テストの前に @BeforeEach でキャッシュをクリアします |
参考資料
コメント
Markdown 対応