0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

JUnitはどうやってテストを実行しているのか ── `@Test`の裏側にある地味な仕組み

0
Posted at

はじめに

@ExtendWith(MockitoExtension.class)
class ProductServiceTest {

    @Mock private ProductRepository productRepository;
    @InjectMocks private ProductService productService;

    @Nested
    @DisplayName("findById")
    class FindByIdTest {

        @Test
        @DisplayName("指定したIDの商品が見つからない場合、ProductNotFoundException をスローすること")
        void shouldThrowExceptionWhenProductNotFound() {
            when(productRepository.findById(99L)).thenReturn(Optional.empty());

            assertThatThrownBy(() -> productService.findById(99L))
                    .isInstanceOf(ProductNotFoundException.class);
        }
    }
}

@Testを書けばテストが実行され、assertThatが失敗すれば赤くなる。これは当たり前すぎて、普段は仕組みを意識することがありません。しかし「なぜ@Nestedクラス内のテストは互いに影響し合わないのか」「@BeforeEachは本当に毎回リセットされているのか」を説明しようとすると、意外と言葉に詰まります。

本記事では、JUnit 5が@Testメソッドを見つけてから成功/失敗を判定するまでの内部動作を分解します。

全体像:JUnit 5は1つのライブラリではない

JUnit 5は実は3つのモジュールに分かれています。

JUnit Platform  ← テストを起動するための共通基盤(エンジンを呼び出す司令塔)
      │
JUnit Jupiter   ← 普段使う @Test, @BeforeEach などのAPIと、その実行エンジン
      │
JUnit Vintage   ← 古いJUnit 4のテストを動かすための互換エンジン

IDEやGradleが「テストを実行して」と指示する相手はJUnit Platformです。Platformは「エンジン」というプラグイン機構を通じて実際のテストクラスを解釈・実行します。私たちが書く@TestJupiterエンジンが解釈する対象です。この階層構造のおかげで、JUnit 4時代のテストコードも「Vintageエンジン」を通せば同じPlatform上で共存できます。


ステップ1:テストの発見(Discovery)

@Testが付いたメソッドは、どうやって見つかっているのでしょうか。JUnitはコンパイル時にコードの意味を理解しているわけではありません。実行時にリフレクションを使ってクラスファイルを解析しています。

1. クラスパス上の .class ファイルを走査する
2. 各クラスに対して「@Testが付いたメソッドがあるか」をリフレクションでチェックする
3. 見つかったメソッドを「テスト記述子(TestDescriptor)」というオブジェクトのツリーにまとめる

Javaのアノテーションは、それ自体には処理能力を持たないただの目印(メタデータ)です。JUnit側がMethod.getAnnotation(Test.class) != nullのような形でリフレクションAPIを使い、「このメソッドに目印が付いているか」を一つひとつ確認しているにすぎません。

冒頭のコードで言えば、@Nestedが付いたFindByIdTestも、この走査時にクラス階層としてツリーに組み込まれます。

ProductServiceTest
 └─ FindByIdTest(@Nested)
     ├─ shouldReturnProductById
     └─ shouldThrowExceptionWhenProductNotFound

ステップ2:インスタンス生成 ── テストメソッドごとに「作り直される」

ここが最も誤解されやすいポイントです。JUnitはテストメソッドを実行するたびに、テストクラスの新しいインスタンスを毎回生成します。

@Mock private ProductRepository productRepository;
@InjectMocks private ProductService productService;

private Product product;

@BeforeEach
void setUp() {
    product = new Product();
    product.setId(1L);
    product.setName("Test Product");
    // ...
}

findByIdに関するテストが2つあるとします。

@Test
void shouldReturnProductById() { ... }

@Test
void shouldThrowExceptionWhenProductNotFound() { ... }

この2つは、それぞれ独立したテストクラスのインスタンスの上で実行されます。

shouldReturnProductById() の実行:
  1. new ProductServiceTest() が新しく作られる
  2. new FindByIdTest() が新しく作られる
  3. @BeforeEach(setUp)が呼ばれ、product フィールドがまっさらな状態で構築される
  4. shouldReturnProductById() が実行される
  5. インスタンスは破棄される

shouldThrowExceptionWhenProductNotFound() の実行:
  1. また新しく new ProductServiceTest() が作られる(前のテストの状態は一切引き継がれない)
  2. また新しく new FindByIdTest() が作られる
  3. @BeforeEach が再び呼ばれる
  4. shouldThrowExceptionWhenProductNotFound() が実行される

もしこの「毎回作り直す」仕組みがなければ、あるテストでwhen(...)を使って設定したモックの振る舞いが、次のテストにまで残ってしまい、実行順序によって結果が変わる「テスト間の汚染」が起きます。「各@Testは完全に独立した世界で動く」という原則は、@BeforeEachが毎回呼ばれることによって初めて成立している、ということです。

なお@Nestedクラスがstaticにできないのはこの構造に理由があります。内部クラス(FindByIdTest)は外側のクラス(ProductServiceTest)のインスタンスを暗黙的に必要とするというJavaの言語仕様に従っているため、@Nestedクラスも外側のインスタンスと紐づいた状態で生成されます。


ステップ3:拡張機能(Extension)が割り込むタイミング

@ExtendWith(MockitoExtension.class)は具体的に何をしているのか、先ほどのインスタンス生成の流れに当てはめます。

JUnit 5は、テストの実行過程の要所要所に「割り込みポイント(フック)」を用意しています。

new ProductServiceTest() が作られる
    ↓
【ここで MockitoExtension の beforeEachCallback が割り込む】
    ├─ @Mock が付いた productRepository フィールドに、実際のモックオブジェクトを生成してセット
    └─ @InjectMocks が付いた productService フィールドに、
       上で作ったモックを注入した ProductService の本物のインスタンスをセット
    ↓
@BeforeEach(setUp)が呼ばれる  ← この時点で productRepository は既に使える状態
    ↓
テストメソッド本体が実行される

setUp()メソッド自体は「Mockitoが初期化を終えた後」に呼ばれることが保証されています。だからこそsetUp()の中で安全にテストデータを組み立てられ、各テストメソッド内でwhen(productRepository.findById(1L))...のようにモックを直接使えるわけです。

JUnit本体は、このMockitoExtensionが中で何をしているか一切知りません。ただ「登録された拡張機能を、決まったタイミングで順番に呼び出す」という汎用的な仕組みを提供しているだけです。

// イメージ(JUnit内部の簡略化)
for (Extension extension : registeredExtensions) {
    if (extension instanceof BeforeEachCallback callback) {
        callback.beforeEach(context); // ここでMockitoExtensionの初期化処理が呼ばれる
    }
}

JUnit 4時代に「Runnerは1クラスに1つしか付けられなかった」のに対し、JUnit 5のExtensionは複数積み重ねられます。それぞれのExtensionが独立して「自分の役割(モック初期化、Spring Context構築など)」を、決まったタイミングで果たせるように設計されているためです。


ステップ4:実行と判定 ── 例外が飛んだかどうかだけを見ている

実は、JUnitに「テストが成功した」ことを直接伝える特別なAPIはありません。判定原理は驚くほど素朴です。

テストメソッドが例外を投げずに正常終了すれば成功。何らかの例外(Throwable)が投げられたら失敗。

try {
    testMethod.invoke(testInstance); // リフレクションでテストメソッドを実行
    // ここまで来たら成功
} catch (Throwable t) {
    // 例外が飛んできたら失敗として記録
}

assertEqualsのようなアサーションメソッドの正体は、「値が一致しなければAssertionErrorthrowするだけの、ただのJavaメソッド」です。JUnitの成功/失敗判定機能とは、実はJavaの例外機構をそのまま流用しているだけなのです。

冒頭のコードで確かめてみます。

when(productRepository.findById(99L)).thenReturn(Optional.empty());

assertThatThrownBy(() -> productService.findById(99L))
        .isInstanceOf(ProductNotFoundException.class);

処理の流れを追うと以下のようになります。

1. productService.findById(99L) を実行する
2. 内部でリポジトリが空のOptionalを返す
3. Serviceの実装が ProductNotFoundException を投げる
4. assertThatThrownBy は、渡されたラムダを内部で try-catch している
   → 例外が投げられたことを確認できたので「期待通り」と判断
   → もし例外が投げられなかったら、assertThatThrownBy自身が
     「例外が飛ぶはずが飛ばなかった」という AssertionError を新たに投げる
5. テストメソッド全体としては、AssertionErrorが投げられなければ正常終了
   → JUnitはこれを「成功」と記録する

面白いのは、assertThatThrownBy自体が「期待した例外を検証するために、一段階余分に例外を握りつぶしている」という構造になっている点です。「Serviceが例外を投げるかどうか」をテストメソッド自身がキャッチし、条件を満たしていなければ改めてAssertionError`を投げ直すことで、最終的にJUnitに「失敗した」と伝えています。JUnit自体は「アサーションライブラリが何をしているか」を一切知らず、ただ「テストメソッドの実行結果として、例外が飛んできたかどうか」だけを監視しています。

verify(...)(Mockitoの呼び出し検証)も原理は同じです。「期待通り呼ばれていなければ、Mockitoが独自の例外を投げる」というだけの仕組みで、JUnitからすればassertThat由来の例外もverify由来の例外も区別されません。「テストメソッド実行中に何らかのThrowableが飛んできたか」だけを見ています。


全体のタイムラインとしてまとめる

冒頭のコード全体で起きている処理を、時系列で並べ直すとこうなります。

① リフレクションで @Nested / @Test を走査し、実行計画(ツリー)を構築する
② テストメソッドごとに ProductServiceTest / FindByIdTest 等のインスタンスを新規生成する
③ MockitoExtension の beforeEachCallback が @Mock / @InjectMocks を初期化する
④ @BeforeEach(setUp)が呼ばれ、テストデータを構築する
⑤ テストメソッド本体をリフレクションで実行する
⑥ アサーション(assertThat / verify)が失敗すれば例外を投げる
⑦ JUnitが例外の有無だけを見て成功/失敗を判定し、次のテストへ進む

1つのテストクラスに30個近いテストメソッドが書かれていても、お互いに影響し合わずに独立して実行される。この当たり前に思える信頼は、②の「テストメソッドごとに毎回新しいインスタンスを作り直す」という一見地味な仕組みによって支えられています。


まとめ

  • JUnit 5はPlatform / Jupiter / Vintageという3層構造で、私たちが書く@TestはJupiterエンジンが解釈している
  • @Testの発見は、コンパイル時ではなく実行時のリフレクションによって行われる
  • テストメソッドごとにテストクラスの新しいインスタンスが作られることで、テスト間の独立性が保たれている
  • @ExtendWithは「決まったタイミングで処理を差し込めるフック」を提供する仕組みで、Mockitoのモック初期化やSpringのコンテナ構築のような複雑な機能を、JUnit本体を変更せずに後付けできるようにしている
  • テストの成功/失敗判定に特別なAPIは存在せず、「例外が飛んだかどうか」という素朴な仕組みがすべての土台になっている

普段何気なく書いている@Testメソッドの裏側には、「リフレクションでメソッドを見つけて呼び出し、例外の有無で成否を判定する」という驚くほど原始的な仕組みしかありません。その原始的な土台の上に、Extensionという拡張ポイントを介してMockitoやSpring Testが積み重なっている、という構造を理解しておくと、初めて見るテストコードでも「これはJUnit本体の機能か、それとも誰かが後付けしたExtensionの機能か」を切り分けて読めるようになります。

0
0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?