はじめに
前回の記事では、Service・Controller・Repositoryという3つのテスト層が、それぞれ異なるツール(Mockito / Spring Test @WebMvcTest / @DataJpaTest)を使い、異なる対象を検証していることを整理しました。
しかし、その整理だけでは答えが出ない問いにすぐぶつかります。
- Serviceのテストで
ProductRepositoryを丸ごとモックにして本当に良いのか。実際のクエリが間違っていても気づけないのでは? - N+1問題のような「クエリの発行回数」に関わるバグは、そもそもどの層でテストすべきなのか?
- 「全部Testcontainersで本物のDBに対してテストすれば安心」ではダメなのか?
本記事では、この「どこまでモックにするか」という設計判断について掘り下げます。
3層それぞれが「担保できないもの」を先に言語化する
設計判断をするコツは、「各層は何を検証できるか」ではなく「各層は何を検証できないか」を先に洗い出すことです。
Serviceテスト(Mockito中心)が担保できないもの
@ExtendWith(MockitoExtension.class)
class ProductServiceTest {
@InjectMocks ProductService productService;
@Mock ProductRepository productRepository;
@Test
void findById_whenNotFound_shouldThrow() {
when(productRepository.findById(99L)).thenReturn(Optional.empty());
assertThatThrownBy(() -> productService.findById(99L))
.isInstanceOf(ProductNotFoundException.class);
}
}
このテストは「findByIdが空を返したら例外を投げる」というServiceの分岐ロジックは完璧に検証していますが、以下は一切検証していません。
-
ProductRepository.findByIdが実際に発行するSQLが正しいか -
@Queryで書いたJPQLの構文が正しいか(Mockなので実行すらされない) -
Specificationによる動的条件検索が意図通りの絞り込みになっているか
つまりServiceテストは「Repositoryが正しく動く前提」でのみ成立するテストです。この前提が崩れていたら、Serviceテストが全部グリーンでも本番は壊れます。
Controllerテスト(@WebMvcTest)が担保できないもの
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@MockitoBean private OrderService orderService;
@Test
@WithMockLoginUser
void shouldGetAllOrders() throws Exception {
when(orderService.findAllByCustomerId(anyLong(), any(Pageable.class)))
.thenReturn(new PageImpl<>(List.of(orderResult)));
mockMvc.perform(get("/api/orders")).andExpect(status().isOk());
}
}
ここではOrderServiceごとモックにしているため、以下は検証できません。
- Service内部の在庫チェックや状態遷移ロジック
- ServiceとRepositoryの結合部分(トランザクション境界、楽観ロックの実際の競合)
「HTTPの入り口から出口までが正しいか」だけを見ていて、中身のビジネスロジックの正しさは意図的に無視しています。
Repositoryテスト(@DataJpaTest + Testcontainers)が担保できないもの
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Import({TestcontainersConfiguration.class, JpaAuditConfig.class})
class ProductRepositoryTest {
@Autowired private ProductRepository productRepository;
}
これは最も「本物」に近い層ですが、それでも以下は検証していません。
- HTTPリクエストとしての妥当性(バリデーション、認可)
- Service層のビジネスルール(在庫が0未満にならないか、等)
- 複数リクエストが同時に来た場合の実際の競合(後述)
| Serviceテスト | Controllerテスト | Repositoryテスト | |
|---|---|---|---|
| ビジネスロジック | ✅ | ❌ | ❌ |
| HTTP層 | ❌ | ✅ | ❌ |
| 実際のSQL/JPQL | ❌ | ❌ | ✅ |
重要なのは、どの層も単独では「エンドツーエンドで正しく動くこと」を保証しないという点です。3層はお互いの「担保できない部分」を補い合う関係にあります。逆に言えば、どれか1層だけを手厚くしても、他の層が薄いままではバグを見逃すということです。
「全部Testcontainersにすればいい」は正しいか
「本物のDBに対してテストすれば一番安心では」という発想は自然ですが、これには明確なコストがあります。
| 観点 | Serviceテスト(Mockito) | Repositoryテスト(Testcontainers) |
|---|---|---|
| 実行速度 | ミリ秒単位 | Dockerコンテナ起動を含め秒〜数十秒 |
| CI全体への影響 | テスト数が増えても線形にしか伸びない | コンテナ起動のオーバーヘッドが常に乗る |
| 何を検証しているか | 分岐・条件・例外送出などロジックの形 | クエリの正しさそのもの |
もし全てのテストをTestcontainers経由にすると、「在庫が0の場合に例外を投げる」というただのif文の分岐確認のために、毎回Dockerコンテナを起動することになります。これは典型的な過剰品質で、CIの実行時間を不必要に押し上げます。
判断基準はシンプルです。
「ロジックの分岐」を確認したいのか、「クエリそのもの」を確認したいのか
- 前者はMockitoで十分(速度を優先すべき)
- 後者は本物のDBが必要(速度を犠牲にしてでも正確性を優先すべき)
単純なfindByIdのような自明なクエリまでTestcontainersで律儀に検証する必要はありません。
N+1問題はどの層でテストすべきか
N+1問題は「ロジックの分岐」でも「単一クエリの正しさ」でもなく、「クエリが何回発行されたか」という横断的な性質を持つバグです。
Serviceテスト(Mockito)では原理的に検知不可能
@Mock ProductRepository productRepository;
Repository自体が偽物なので、そもそもSQLが1回発行されようが100回発行されようがMockitoは関知しません。N+1はここでは検知できない、と言い切れます。
Repositoryテスト(Testcontainers)でなら検知できる
@Override
@EntityGraph(attributePaths = {"order", "details"})
Page<CustomerOrder> findAll(Pageable pageable);
この@EntityGraphが「本当に1回のSQLでJOIN取得できているか」を確認するには、Hibernateの統計情報を有効化して発行クエリ数をアサーションする、という手法が使えます。
spring:
jpa:
properties:
hibernate:
generate_statistics: true
@Test
void findAll_shouldFetchDetailsWithoutNPlusOne() {
// 事前に複数件のorderとdetailsを永続化しておく
Statistics stats = entityManager.getEntityManagerFactory()
.unwrap(SessionFactory.class).getStatistics();
stats.clear();
orderRepository.findAll(PageRequest.of(0, 10));
assertThat(stats.getPrepareStatementCount()).isEqualTo(1); // 1回で完結しているはず
}
これは@DataJpaTest層でしか実施できないテストです。「担保できない領域」の話に戻ると、N+1のようなクエリ発行回数に関わる性質は、Repositoryテストの担当領域として明確に切り分けるべきだと分かります。
default_batch_fetch_sizeはテストで安心してはいけない理由
hibernate:
default_batch_fetch_size: 100
これは「N+1が発生してもIN句クエリにまとめる」というセーフティネットです。しかし、このセーフティネットに頼ってN+1のテストを省略するのは危険です。理由は単純で、100件を超えるループでは結局N/100+1回のクエリが発生するため、根本対策(@EntityGraphやJOIN FETCH)がされていなければ、データ量が増えた瞬間に本番でパフォーマンス劣化が表面化します。「セーフティネットがあるから」ではなく「クエリ発行回数そのもの」をテストで固定化しておくことが、将来の劣化に対する保険になります。
楽観ロックの競合は、実はどの層でも「完全には」テストできない
Serviceテストでできること:
@Test
void update_shouldSetClientVersionForOptimisticLock() {
given(productRepository.findById(1L)).willReturn(Optional.of(product));
productService.update(new UpdateProduct(1L, 3L, "商品A改", ...));
ArgumentCaptor<Product> captor = ArgumentCaptor.forClass(Product.class);
verify(productRepository).save(captor.capture());
assertThat(captor.getValue().getVersion()).isEqualTo(3L);
}
「実際に2つのトランザクションが競合したときにOptimisticLockExceptionが飛ぶか」は検証していません。ProductRepositoryがモックである以上、HibernateのWHERE id = ? AND version = ?という実際のSQL発行と0件更新の検知は一切動いてないです。
Repositoryテスト(Testcontainers)でできること:
@Test
void update_shouldThrowOptimisticLockExceptionOnConflict() {
Product product = persistProduct("商品A", BigDecimal.valueOf(1000));
Product copy1 = entityManager.find(Product.class, product.getId());
Product copy2 = entityManager.find(Product.class, product.getId());
copy1.setName("A店による更新");
entityManager.persistAndFlush(copy1); // 先に更新 → version が上がる
copy2.setName("B店による更新");
assertThatThrownBy(() -> entityManager.persistAndFlush(copy2))
.isInstanceOf(OptimisticLockException.class); // 古いversionのままなので競合
}
ここで初めて「本当にHibernateが競合を検知して例外を投げるか」を確認できます。ただしこれも、実際の同時実行(複数スレッドが本当に同時にリクエストを投げてくる状況)までは再現していません。真の同時実行を再現するテストは、CountDownLatchなどを使ったマルチスレッドテストや、負荷試験の領域になり、通常の自動テストスイートの範囲を超えます。
つまり楽観ロックには実質4段階の検証レベルが存在します。
- Serviceテスト:
setVersion()の呼び忘れがないか(コードの形) - Repositoryテスト:Hibernateが実際に競合を検知するか(単一スレッドでの疑似競合)
- マルチスレッドテスト:本当に同時アクセスが来た場合の挙動(通常は別枠)
- 本番監視:実際の409発生率のモニタリング(テストではなく運用)
「テストを書いたから安心」ではなく、どのレベルまで検証済みで、どこから先は運用でカバーするのかを意識して線引きすることが、過剰なテストにも見逃しにもならないバランスになります。
まとめ
- 3つのテスト層は、それぞれ「担保できないもの」を持っている。これを先に言語化することが設計判断の出発点になる
- 「全部Testcontainersにすれば安心」は速度とのトレードオフを無視した過剰品質になりやすい。判断基準は「ロジックの分岐を見たいか、クエリそのものを見たいか」
- N+1問題はServiceテストでは原理的に検知不可能。Repositoryテストでクエリ発行回数を明示的にアサーションする必要がある
- 楽観ロックのようなテーマは、実は1つの層では検証しきれず、複数の検証レベルに分解して考える必要がある
- テストの「本数」や「カバレッジ」ではなく、「どの性質を、どの層で、どこまで検証しているか」を言語化できているかが、テスト戦略の成熟度を分ける
「Serviceは全部モック、Controllerは@WebMvcTest、Repositoryは@DataJpaTest」という型を覚えるだけでなく、その型がなぜそのトレードオフを選んでいるのかを説明できるようになると、新しい機能を実装するたびに「このロジックはどの層でテストすべきか」を自分の頭で判断できるようになります。