はじめに (対象読者・この記事でわかること)

この記事は、Spring BootアプリケーションでJUnitを使った単体テストを記述しており、特にMockitoを用いてJpaRepository.saveAndFlush()のようなデータベース操作で例外をシミュレートしようとした際に、なぜかMockito.doThrow()が機能しない、と悩んでいる開発者を対象にしています。

この記事を読むことで、以下の点が明確になります。 - JpaRepository.saveAndFlush()に対してdoThrow()が機能しない主な原因。 - Spring Bootのテスト環境における@Mock@MockBeanの適切な使い分け。 - JpaRepositoryのメソッド呼び出しで例外を正しくモックし、テストする方法。

データベースの制約違反や予期せぬエラー発生時のアプリケーションの挙動を、本物のデータベースにアクセスせずにテストしたい場合に、この記事が役立つでしょう。

前提知識

この記事を読み進める上で、以下の知識があるとスムーズです。 - JavaおよびSpring Bootの基本的な知識 - JUnitおよびMockitoを用いたテストコードの基本的な書き方 - JPA (Java Persistence API) およびJpaRepositoryの基本的な概念

JpaRepository.saveAndFlush()とMockito.doThrow()の落とし穴

Spring Bootアプリケーションにおいて、サービス層のビジネスロジックを単体テストする際、依存するコンポーネント(特にデータベースアクセスを行うJpaRepositoryなど)はモックすることが一般的です。Mockitoはそのための強力なツールであり、特定のメソッドが呼び出された際に意図的に例外をスローさせるdoThrow()は、エラーハンドリングのテストに不可欠な機能です。

しかし、「JpaRepository.saveAndFlush()メソッドに対してdoThrow()を設定しても、期待通りに例外が発生しない」という現象に遭遇することがあります。

Java
// 想定されるテストコードの断片(しかし、これが機能しないことがある) @ExtendWith(MockitoExtension.class) class MyServiceTest { @Mock private MyEntityRepository myEntityRepository; // ここがポイント! @InjectMocks private MyService myService; @Test void testCreateEntity_ShouldThrowExceptionOnSave() { MyEntity entity = new MyEntity("test"); // saveAndFlush()が呼び出されたらDataIntegrityViolationExceptionをスローする想定 Mockito.doThrow(new DataIntegrityViolationException("Duplicate entry")) .when(myEntityRepository) .saveAndFlush(any(MyEntity.class)); // サービスメソッドを呼び出すと例外がスローされるはず...? assertThrows(DataIntegrityViolationException.class, () -> myService.createEntity(entity)); } }

上記のコード例のように設定しても、assertThrowsが例外を捕捉できずテストが失敗したり、あるいは全く異なる振る舞いをしたりするケースが報告されます。なぜこのような現象が起こるのでしょうか?

この問題は、Spring BootのテストフレームワークとMockitoの連携、特にモックが正しくテスト対象に注入されていないことに起因することがほとんどです。JpaRepositoryはSpringのコンポーネントであり、通常は@Autowiredを通じてSpringコンテナによって管理・注入されます。@MockアノテーションはMockito単体でのモック生成には適していますが、Springのアプリケーションコンテキストに存在するBeanに対してモックを置き換える用途には不十分な場合があります。

次章では、この問題の具体的な原因を深掘りし、その上で適切な解決策をコード例とともに詳しく解説していきます。

原因究明と解決策:Springのテストとモックの正しい注入

JpaRepository.saveAndFlush()doThrow()を設定しても例外が発生しない」という問題の根本原因は、ほとんどの場合、テスト対象のサービスに注入されているJpaRepositoryインスタンスが、Mockitoで作成したモックインスタンスではないという点にあります。

ステップ1: 問題の再現と確認

まず、一般的なSpring Bootのテストコードの構造を確認し、問題が発生しうるシナリオを具体的に見てみましょう。

MyServiceクラス:

Java
package com.example.demo.service; import com.example.demo.entity.MyEntity; import com.example.demo.repository.MyEntityRepository; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Service public class MyService { private final MyEntityRepository myEntityRepository; public MyService(MyEntityRepository myEntityRepository) { this.myEntityRepository = myEntityRepository; } @Transactional public MyEntity createEntity(MyEntity entity) { // 何らかのビジネスロジック return myEntityRepository.saveAndFlush(entity); // ここで例外を発生させたい } // 他のメソッド... }

MyEntityRepositoryクラス:

Java
package com.example.demo.repository; import com.example.demo.entity.MyEntity; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; @Repository public interface MyEntityRepository extends JpaRepository<MyEntity, Long> { }

MyEntityクラス:

Java
package com.example.demo.entity; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.Column; @Entity public class MyEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(unique = true) // UNIQUE制約を想定 private String name; // Constructors, Getters, Setters public MyEntity() {} public MyEntity(String name) { this.name = name; } public Long getId() { return id; } public void setId(Long id) { this.id = id; } public String getName() { return name; } public void setName(String name) { this.name = name; } }

そして、問題のあるテストコードです。

Java
package com.example.demo.service; import com.example.demo.entity.MyEntity; import com.example.demo.repository.MyEntityRepository; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.Mockito; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.dao.DataIntegrityViolationException; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; @ExtendWith(MockitoExtension.class) class MyServiceBrokenTest { @Mock // ← ここが問題の根源となることが多い! private MyEntityRepository myEntityRepository; @InjectMocks private MyService myService; @Test void testCreateEntity_ShouldThrowExceptionOnSave_Broken() { MyEntity entity = new MyEntity("unique_name"); // saveAndFlush()が呼び出されたらDataIntegrityViolationExceptionをスローする想定 // しかし、このdoThrowが機能しない可能性が高い Mockito.doThrow(new DataIntegrityViolationException("Duplicate entry mock")) .when(myEntityRepository) .saveAndFlush(any(MyEntity.class)); // assertThrowsがDataIntegrityViolationExceptionを捕捉できないことがある // テストは成功するが、それは例外がスローされなかったためか、 // あるいは別の例外がスローされてしまったためかもしれない assertThrows(DataIntegrityViolationException.class, () -> myService.createEntity(entity)); // Verifyが失敗する場合もある (myEntityRepository.saveAndFlush()が全く呼ばれていないなど) // Mockito.verify(myEntityRepository).saveAndFlush(any(MyEntity.class)); } }

このテストを実行すると、assertThrowsが期待するDataIntegrityViolationExceptionを捕捉できず、テストが失敗することがよくあります。これは、myServiceが実際に使用しているmyEntityRepositoryが、@Mockで作成したモックインスタンスではないために起こります。

ステップ2: Spring Bootテストにおけるモックの正しい注入

Spring Bootアプリケーションのコンポーネントをテストする際、@Mock@InjectMocksはMockito単体でのテストには非常に便利ですが、Springのアプリケーションコンテキストが関与するテストでは、@MockBeanアノテーションを使用するのが適切です。

@MockBeanは、Springのアプリケーションコンテキストに存在するBeanをモックインスタンスで置き換える役割を持ちます。これにより、@Autowiredやコンストラクタインジェクションを通じてサービス層に注入されるリポジトリが、正しくモックされたものになります。

解決策:@MockBeanの利用

先ほどのテストコードを@MockBeanを使って修正しましょう。

Java
package com.example.demo.service; import com.example.demo.entity.MyEntity; import com.example.demo.repository.MyEntityRepository; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.dao.DataIntegrityViolationException; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.verify; // Spring Bootのテストコンテキストをロード @SpringBootTest class MyServiceCorrectTest { // 実際のMyServiceインスタンスをSpringコンテキストから注入 @Autowired private MyService myService; // JpaRepositoryのBeanをモックで置き換える @MockBean private MyEntityRepository myEntityRepository; @Test void testCreateEntity_ShouldThrowExceptionOnSave_Correct() { MyEntity entity = new MyEntity("unique_name"); // saveAndFlush()が呼び出されたらDataIntegrityViolationExceptionをスローするよう設定 doThrow(new DataIntegrityViolationException("Duplicate entry mock")) .when(myEntityRepository) .saveAndFlush(any(MyEntity.class)); // サービスメソッドを呼び出すと期待通りに例外がスローされる assertThrows(DataIntegrityViolationException.class, () -> myService.createEntity(entity)); // saveAndFlush()が一度呼び出されたことを確認 verify(myEntityRepository).saveAndFlush(any(MyEntity.class)); } }

この修正により、MyServiceにはSpringのアプリケーションコンテキストによって@MockBeanで作成されたmyEntityRepositoryのモックインスタンスが正しく注入されます。その結果、doThrow()の設定が有効になり、myService.createEntity()myEntityRepository.saveAndFlush()を呼び出した際に、設定したDataIntegrityViolationExceptionが正しくスローされるようになります。

@Mock@MockBeanの使い分けの整理

  • @Mock: Mockito単体でモックオブジェクトを作成し、@InjectMocksと組み合わせて使用する場合に最適です。Springのコンテキストをロードしない軽量な単体テストに適しています。しかし、Springによって管理されるBeanの依存関係を置き換えることはできません。

  • @MockBean: Spring Bootのテストアノテーションの一つで、Springのアプリケーションコンテキスト内に存在する既存のBeanをモックオブジェクトで置き換えます。@Autowiredされたテスト対象クラスが、モックされた依存関係を持つようにしたい場合に利用します。@SpringBootTest@DataJpaTestなどのSpringテストアノテーションと組み合わせて使用します。

ハマった点やエラー解決

多くの開発者がこの問題に遭遇するのは、以下のような思考経路を辿るためです。

  1. 「Mockitoを使うから@Mock@InjectMocksを使えばいいだろう。」と考える。
  2. テストコードを書いて実行すると、doThrow()が効かず、assertThrowsが失敗する。
  3. 「なぜ例外がスローされないのだろう?doThrow()の引数が違う?メソッドが呼ばれていない?」とデバッグを開始する。
  4. verify()を使ってメソッドが呼ばれていることを確認すると、実は呼ばれていない(またはモックではない実体が呼ばれている)ことが判明するが、原因が特定しにくい。
  5. Springのコンテキストが関わるテストであることを忘れ、純粋なMockitoの問題として捉えてしまう。

この誤解は、@Mockがモックオブジェクトを作成するだけであるのに対し、@MockBeanがSpringコンテキスト内の特定のBeanをモックで置き換えるという、両者の決定的な違いを把握していない場合に生じがちです。

解決策

JpaRepository.saveAndFlush()doThrow()を指定してもExceptionが発生しない」問題の解決策は、以下の点を確実に実施することです。

  1. @SpringBootTestまたは適切なSpringテストアノテーションを使用する: テスト対象のサービスがSpringコンポーネントであり、@Autowiredで依存関係が注入されている場合、Springコンテキストをロードする必要があります。
  2. JpaRepositoryのモックには@MockBeanを使用する: @Autowiredでサービスに注入されるJpaRepositoryのインスタンスをモックに置き換えるためには、@MockBeanアノテーションを使用します。これにより、Springコンテキストがそのリポジトリをモックインスタンスとして登録し、サービスに注入します。
  3. @Autowiredでサービスを注入する: テスト対象のサービスもSpringコンテキストから取得するため、@Autowiredを使用します。@InjectMocks@Mockと併用することが推奨され、@MockBeanと組み合わせて使う場合は、通常はテスト対象のサービスを@Autowiredで注入します。
  4. doThrow().when(...).method(any())の引数を正しく指定する: any(MyEntity.class)のように、モックしたいメソッドが受け取る引数の型を正確に指定します。

この正しい設定を行うことで、JpaRepository.saveAndFlush()で期待通りの例外をモックで発生させ、サービス層のエラーハンドリングロジックを正確にテストすることが可能になります。

まとめ

本記事では、JUnit + SpringBoot + JpaRepository.saveAndFlush()の組み合わせでMockito.doThrow()が機能しないという問題の原因と、その具体的な解決策を解説しました。

  • 要点1: JpaRepository.saveAndFlush()に対するdoThrow()が機能しない主な原因は、Spring Bootのテスト環境において、テスト対象のサービスにモックが正しく注入されていないことでした。
  • 要点2: Springコンテキストを伴うテストでは、依存関係のモック化に@Mockではなく@MockBeanを使用することが不可欠です。@MockBeanは、Springのアプリケーションコンテキスト内の既存のBeanをモックで置き換える役割を果たします。
  • 要点3: テスト対象のサービスは@AutowiredでSpringコンテキストから注入し、doThrow()の引数(例:any(MyEntity.class))が、実際に呼び出されるメソッドシグネチャと一致していることを確認しましょう。

この記事を通して、Spring Boot環境でのJpaRepositoryのモック化、特に例外テストにおける@MockBeanの重要性を理解し、効果的な単体テストを記述できるようになることでしょう。

今後は、より複雑なトランザクション境界や非同期処理におけるテストのベストプラクティスについても記事にする予定です。

参考資料