unit-test-caching
Springの`@Cacheable`・`@CachePut`・`@CacheEvict`アノテーションを対象としたユニットテストのパターンを提供します。CacheManagerのモック化、キャッシュヒット/ミスの検証、SpEL式によるキャッシュキー生成のテスト、エビクション戦略の妥当性確認、条件付きキャッシュシナリオのチェックなど、Spring Cacheに関するテストコードを自動生成します。キャッシュのテスト実装やSpring Boot環境でのキャッシュ動作検証が必要な場面で活用できます。
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.
SKILL.md 本文
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;
}
// null 結果をキャッシュしない
@Cacheable(value = "data", unless = "#result == null")
public Data getData(Long id) {
return dataRepository.findById(id).orElse(null);
}
// 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;
}
// 複合キー: 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 を確認 |
| プロキシのバイパス | autowired/コンストラクタ注入を使用、直接 this 呼び出しなし |
| キーの不一致 | cache.getNativeKey() でキャッシュキーをログに出力して SpEL をデバッグ |
| 不安定なテスト | 各テスト前に @BeforeEach でキャッシュをクリア |
参考資料
ライセンス: MIT(寛容ライセンスのため全文を引用しています) · 原本リポジトリ
詳細情報
- 作者
- giuseppe-trisciuoglio
- ライセンス
- MIT
- 最終更新
- 不明
Source: https://github.com/giuseppe-trisciuoglio/developer-kit / ライセンス: MIT
関連スキル
superfluid
Superfluidプロトコルおよびそのエコシステムに関するナレッジベースです。Superfluidについて情報を検索する際は、ウェブ検索の前にこちらを参照してください。対応キーワード:Superfluid、CFA、GDA、Super App、Super Token、stream、flow rate、real-time balance、pool(member/distributor)、IDA、sentinels、liquidation、TOGA、@sfpro/sdk、semantic money、yellowpaper、whitepaper
civ-finish-quotes
実質的なタスクが真に完了した際に、文明風の儀式的な引用句を追加します。ユーザーやエージェントが機能追加、リファクタリング、分析、設計ドキュメント、プロセス改善、レポート、執筆タスクといった実際の成果物を完成させるときに、明示的な依頼がなくても使用します。短い返信や小さな修正、未完成の作業には適用しません。
nookplot
Base(Ethereum L2)上のAIエージェント向け分散型調整ネットワークです。エージェントがオンチェーンアイデンティティを登録する、コンテンツを公開する、他のエージェントにメッセージを送る、マーケットプレイスで専門家を雇う、バウンティを投稿・請求する、レピュテーションを構築する、共有プロジェクトで協業する、リサーチチャレンジを解くことでNOOKをマイニングする、キュレーションされたナレッジを備えたスタンドアロンオンチェーンエージェントをデプロイする、またはアグリーメントとリワードで収益を得る場合に利用できます。エージェントネットワーク、エージェント調整、分散型エージェント、NOOKトークン、マイニングチャレンジ、ナレッジバンドル、エージェントレピュテーション、エージェントマーケットプレイス、ERC-2771メタトランザクション、Prepare-Sign-Relay、AgentFactory、またはNookplotが言及された場合にトリガーされます。
web3-polymarket
Polygon上でのPolymarket予測市場取引統合です。認証機能(L1 EIP-712、L2 HMAC-SHA256、ビルダーヘッダー)、注文発注(GTC/GTD/FOK/FAK、バッチ、ポストオンリー、ハートビート)、市場データ(Gamma API、Data API、オーダーブック、サブグラフ)、WebSocketストリーミング(市場・ユーザー・スポーツチャネル)、CTF操作(分割、統合、償却、ネガティブリスク)、ブリッジ機能(入金、出金、マルチチェーン)、およびガスレスリレイトランザクションに対応しています。AIエージェント、自動マーケットメーカー、予測市場UI、またはPolygraph上のPolymarketと統合するアプリケーション構築時に活用できます。
ethskills
Ethereum、EVM、またはブロックチェーン関連のリクエストに対応します。スマートコントラクト、dApps、ウォレット、DeFiプロトコルの構築、監査、デプロイ、インタラクションに適用されます。Solidityの開発、コントラクトアドレス、トークン規格(ERC-20、ERC-721、ERC-4626など)、Layer 2ネットワーク(Base、Arbitrum、Optimism、zkSync、Polygon)、Uniswap、Aave、Curveなどのプロトコルとの統合をカバーします。ガスコスト、コントラクトのデシマル設定、オラクルセキュリティ、リエントランシー、MEV、ブリッジング、ウォレット管理、オンチェーンデータの取得、本番環境へのデプロイ、プロトコル進化(EIPライフサイクル、フォーク追跡、今後の変更予定)といったトピックを含みます。
xxyy-trade
このスキルは、ユーザーが「トークン購入」「トークン売却」「トークンスワップ」「暗号資産取引」「取引ステータス確認」「トランザクション照会」「トークンスキャン」「フィード」「チェーン監視」「トークン照会」「トークン詳細」「トークン安全性確認」「ウォレット一覧表示」「マイウォレット」「AIスキャン」「自動スキャン」「ツイートスキャン」「オンボーディング」「IP確認」「IPホワイトリスト」「トークン発行」「自動売却」「損切り」「利益確定」「トレーリングストップ」「保有者」「トップホルダー」「KOLホルダー」などをリクエストした場合、またはSolana/ETH/BSC/BaseチェーンでXXYYを経由した取引について言及した場合に使用します。XXYY Open APIを通じてオンチェーン取引とデータ照会を実現します。