mcpField
U
unit-test-caching

unit-test-caching

github.com/giuseppe-trisciuoglioテスト・品質3653.7k 回インストール7 週間前に更新
リポジトリ

unit-test-caching について

unit-test-caching は、Spring のキャッシュアノテーションをフルコンテキストなしでテストする開発者向けスキルです。メモリ内の ConcurrentMapCacheManager を使い、ヒット、ミス、削除、キー生成、条件付きキャッシュを扱います。

主な機能

  1. 01ConcurrentMapCacheManager の利用
  2. 02キャッシュヒットとミスのテスト
  3. 03キャッシュ削除と更新の検証
  4. 04SpEL キャッシュキー生成の確認
  5. 05条件付きキャッシュルールのテスト

活用シーン

  1. 01@Cacheable メソッドの動作確認
  2. 02@CacheEvict によるエントリー無効化の確認
  3. 03@CachePut による更新のテスト
  4. 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 を使わず、高速なユニットテストでキャッシュマネージャーをモックするとき

手順

  1. メモリー内の CacheManager を設定する:テストには ConcurrentMapCacheManager を使用します
  2. テスト用フィクスチャを準備する:@BeforeEach でリポジトリをモックし、サービスインスタンスを作成します
  3. リポジトリの呼び出し回数を検証する:times(n) アサーションでキャッシュの動作を確認します
  4. キャッシュヒットをテストする:メソッドを 2 回呼び出し、リポジトリが 1 回だけ呼び出されることを検証します
  5. キャッシュミスをテストする:呼び出すたびにリポジトリが呼び出されることを検証します
  6. 削除をテストする:@CacheEvict の後、次の読み取りで再びリポジトリが呼び出されることを検証します
  7. キー生成をテストする:SpEL 式から複合キーが生成されることを検証します
  8. 条件付きキャッシュを検証する: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 でキャッシュをクリアします

参考資料

コメント