5
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

3層テスト戦略の設計思想 ── 「どこまでモックするか」

5
Posted at

はじめに

前回の記事では、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回のクエリが発生するため、根本対策(@EntityGraphJOIN 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段階の検証レベルが存在します。

  1. Serviceテスト:setVersion()の呼び忘れがないか(コードの形)
  2. Repositoryテスト:Hibernateが実際に競合を検知するか(単一スレッドでの疑似競合)
  3. マルチスレッドテスト:本当に同時アクセスが来た場合の挙動(通常は別枠)
  4. 本番監視:実際の409発生率のモニタリング(テストではなく運用)

「テストを書いたから安心」ではなく、どのレベルまで検証済みで、どこから先は運用でカバーするのかを意識して線引きすることが、過剰なテストにも見逃しにもならないバランスになります。

まとめ

  • 3つのテスト層は、それぞれ「担保できないもの」を持っている。これを先に言語化することが設計判断の出発点になる
  • 「全部Testcontainersにすれば安心」は速度とのトレードオフを無視した過剰品質になりやすい。判断基準は「ロジックの分岐を見たいか、クエリそのものを見たいか」
  • N+1問題はServiceテストでは原理的に検知不可能。Repositoryテストでクエリ発行回数を明示的にアサーションする必要がある
  • 楽観ロックのようなテーマは、実は1つの層では検証しきれず、複数の検証レベルに分解して考える必要がある
  • テストの「本数」や「カバレッジ」ではなく、「どの性質を、どの層で、どこまで検証しているか」を言語化できているかが、テスト戦略の成熟度を分ける

「Serviceは全部モック、Controllerは@WebMvcTest、Repositoryは@DataJpaTest」という型を覚えるだけでなく、その型がなぜそのトレードオフを選んでいるのかを説明できるようになると、新しい機能を実装するたびに「このロジックはどの層でテストすべきか」を自分の頭で判断できるようになります。

5
9
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?