JUnitで例外処理系のテストを実施する

## はじめに
お疲れ様です。 @Keichan_15 です。

**DevToxicClub Advent Calendar 2024 11日目**の記事を担当します。

https://qiita.com/advent-calendar/2024/dev-toxic-club

今回はお仕事で**Java**を触っていることにちなんで、ユニットテストのフレームワークである`JUnit`を使用したので、今回はそちらについて書いていこうと思います。

基本的な内容は検索すると山ほど引っ掛かると思うので割愛し、今回は**例外処理**に着目して`JUnit`のテストコードをどう書くかといった内容をご紹介できればと思います。

## 雑談
この記事執筆に至ったきっかけは単純で、業務で`JUnit`を書く機会をいただいたのが理由です。

テストコードと聞くと自分は`RoR`育ちなので`RSpec`が思い浮かびましたね。最近はフロントエンドなどもやってるので`Jest`とかその辺りが出てきます。

https://rspec.info/

https://jestjs.io/ja/

ほぼテストコードを1から書くという経験が初めてだったこともあり、テストコードを書く前はこれらの重要性をあまり理解できませんでした。

実際に様々なテストケースを考慮してそれらをテストコードに卸して動作検証する、という一連の過程を進めるにつれて、プログラミング段階でのミスを大幅に減らすと共にサービスの品質向上に寄与するといった側面を学ぶことができたと思います。

プロジェクトによっては工数の観点からあまり重要視されにくい分野ではありますが、テストコードを用いたユニットテストはシステムの価値・品質の向上に少なくとも役立っていると思いますので、時間があるのであれば積極的に取り入れるべきなのではと感じました。

話が少し脱線しましたが、早速パターン別のテストコードを見ていきましょう。

## 環境
- `JDK 21`
- `JUnit5`

## パターン別テストコード
### ①設定した例外がthrowされているか
`JUnit5`からは、例外を検出するアサーションとして`AssertThrows`が追加されました。

https://blogs.oracle.com/otnjp/post/migrating-from-junit-4-to-junit-5-important-differences-and-benefits-ja

```java:Sample01.java
    @Test
    void testCase_001() {
        String str = null;
        Exception exception = assertThrows(NullPointerException.class, () -> {
            str.length();
        });
    }
```
上記のサンプルコードでは`str.length();`で文字列長を取得しようとしていますが、前段で`str`に対して`null`値を代入しているため`NullPointerException`が発生します。

`AssertThrows`では第一引数に指定した例外クラスが第二引数以降の処理内で発生するかを確認します。
指定した例外が発生しなかった場合や別の例外が発生した場合は`assertThrows`が失敗します。

`JUnit4`では上記のような「例外が発生しなかった場合の処理」だった場合に`fail()`をかまして意図的にテストが失敗したことを検知させる必要がありました。
※本来は例外が発生することを期待していたが、例外が発生しなかった場合に`catch`文節へ処理が移らない

```java:Sample02.java
    // JUnit4の場合...
    @Test
    public void testCase_002() {
        String str = null;
        try {
            if (str == null) {
                throw new NullPointerException();
            }
            str.length();
            fail("失敗だよーん");
        } catch (NullPointerException e) {
            // メッセージの検証は行わない
        }
    }
```

例外が発生するのは`str.length()`に通った時点のため、例外が発生した場合はそのまま`catch`文節へと遷移します。
また、例外が何も発生しなかった場合は`fail()`が発火することでテスト失敗とさせています。
※例外が発生しない場合って、それはそれでテスト的にダメだよね、ってことです

それが`JUnit5`では`assertThrows(hogehogeException.class, () -> {});`で完結するわけですから使わない手はないよねって話ですよね。

ちなみにサンプルコードで挙げた実装方法は、第二引数に指定しているメソッドが **`public`メソッドに限り有効**です。

各システムの設計思想にもよりますが、基本的に何でもかんでも`public`にするというのはあまり見かけません。
そのため`private`メソッドへも同様にユニットテストを実行する場面が出てくるでしょう。

そこでこれらを実行するには**リフレクション**を使用する必要があります。

まあモックライブラリ(`Mockito` etc..)を使えよという話ですが、いろいろなしがらみで使えない方もいるかもしれませんのでね…。
~~制約という名のしがらみ~~

#### ■リフレクションを用いたprivateメソッドへのテスト
まずはサンプルコードから。

```java:Sample03.java
    @Test
    void testCase_003() throws Exception {
        String str = null;
        Exception exception = assertThrows(Exception.class, () -> {
            try {
                Method method = NullPointerExceptionTest.class.getDeclaredMethod("checkNull", String.class);
                method.invoke(this, str);
            } catch (Exception e) {
                throw e.getTargetException();
            }
        });
        Throwable cause = exception.getCause();
        assertTrue(cause instanceof NullPointerException);
    }

    private void checkNull(String str) {
        if (str == null) {
            throw new NullPointerException();
        }
        str.length();
    }
```

`getDeclaredMethod()`では第一引数に`privateメソッド名`、第二引数以降に`privateメソッドの引数の型`を指定します。
今回は引数が1つで`String`型のため、`String.class`としました。メソッドの実行は`invoke()`で行います。

https://docs.oracle.com/javase/jp/8/docs/api/java/lang/Class.html#getDeclaredMethod-java.lang.String-java.lang.Class...-

ここで重要なのは以下の部分です。`catch`した例外を再`throw`している箇所です。
```java
    } catch (Exception e) {
        throw e.getTargetException();
    }
```

実はリフレクションを使用して`private`メソッドを呼び出した場合、例外(ここでは`NullPointerException`)は`InvocationTargetException`と呼ばれる例外にラップされます。

https://docs.oracle.com/javase/jp/8/docs/api/java/lang/reflect/InvocationTargetException.html

今回テストをしたい例外はあくまで`NullPointerException`です。これらを取得するにはどうすればいいか…、ここで`getTargetException()`が出番になります。

https://docs.oracle.com/javase/jp/8/docs/api/java/lang/reflect/InvocationTargetException.html#getTargetException--

`getTargetException()`は`InvocationTargetException()`にラップされている元の例外を取得するためのメソッドです。
つまり今回で言えば、`e.getTargetException()`とすることで本来取得したかった`NullPointerException`がゲットできるということです。

これで取得した`NullPointerException`を`assertThrows`で比較することで、`private`メソッドでも特定の例外が発生したかどうかを検知することができます。

私は最初この部分に非常に悩まされたのですが、`e.printStackTrace();`でトレースログを出して解析したところ、この`InvocationTargetException()`に辿り着きました。
案外トレースログを出すの、おすすめです。

### ②例外に付与しているメッセージ内容を検証したい場合
こっちが今回の本題です。まずは以下のサンプルコードをご覧ください。

```java:Sample04.java
    @Test
    void testCase_004() {
        String str = null;
        Exception exception = assertThrows(NullPointerException.class, () -> {
            if (str == null) {
                throw new NullPointerException("Nullだよーん");
            }
            str.length();
        });
        assertEquals("Nullだよーん", exception.getMessage());
    }
```

検証内容としては`NullPointerException()`の引数に指定しているメッセージが、想定しているメッセージ内容と一致するかといった検証です。

上記のサンプルコードでは`"Nullだよーん"`と文字列を指定していますが、大きなシステムになるとこのメッセージの部分は動的な値を指定することがあるかもしれません。というかむしろ直接文字列を指定することなんてほぼ無いでしょう。~~ですよね？~~

そういった場合に上記のサンプルコードは非常に役立ちます。例えば以下のコードです。

```java:Sample05.java
    @Test
    void testCase_005() {
        String str = null;
        String dynamicValue = "hogehoge";
        Exception exception = assertThrows(NullPointerException.class, () -> {
            if (str == null) {
                throw new NullPointerException("お変数の値: " + dynamicValue);
            }
            str.length();
        });
        assertEquals("お変数の値: " + dynamicValue, exception.getMessage());
    }
```

`dynamicValue`という変数にあらかじめ何らかの形でデータを格納し、このデータが`NullPointerException()`の引数に指定しているメッセージ内容と一致しているかを確認しています。

あくまで一例ですが、このように例外にメッセージを付与している場合にそのメッセージ内容を検証したい場合は参考になるかもしれません。

#### ■リフレクションを用いたprivateメソッドへのテスト
まあリフレクションもそら見たいよねと。非使用時とさほど実装内容は変わりません。
サンプルコードは以下です。

```java:Sample06.java
    @Test
    void testCase_006() throws Exception {
        String str = null;
        String dynamicValue = "dynamic value";
        Exception exception = assertThrows(Exception.class, () -> {
            try {
                Method method = NullPointerExceptionTest.class.getDeclaredMethod("checkNull", String.class);
                method.invoke(this, str);
            } catch (InvocationTargetException e) {
                throw e.getTargetException();
            }
        });
        Throwable cause = exception.getCause();
        assertTrue(cause instanceof NullPointerException);
        assertEquals("お変数の値: " + dynamicValue, cause.getMessage());
    }

    private void checkNull(String str) {
        if (str == null) {
            throw new NullPointerException("お変数の値: dynamic value");
        }
        str.length();
    }
```

リフレクションは事前に以下のような記載が必要な点で面倒ですが、わざわざ`JUnit`のテストを通すためだけにアクセス修飾子を外すぐらいなら断然こっちの方がベストかなと思います。

```java
    Method method = NullPointerExceptionTest.class.getDeclaredMethod("checkNull", String.class);
    method.invoke(this, str);
```

逆に`JUnit`で`private`メソッドにテストを通せないのでアクセス修飾子を変えてるなんてこと、あるんですか…、て思ったらあるみたいです。凄いな…。

https://qiita.com/11295/items/773e7395889e05f605ae

> @11295 さん執筆記事より引用
> 
> モックライブラリ使えばリフレクションなんて使わずにもっと楽に書ける(MockitoのWhiteboxとか)んですけど、モックライブラリの導入が許されないプロジェクトにぶち当たると軽率にアクセス修飾子を外す人が存在するので、この記事を書きました。
やめろ！やめてくれ！テストのために誤ったクソコードを書くのはやめるんだ！まじで！！


## おわりに
今回は`JUnit5`における例外処理のテストについて執筆しました。

冒頭にも述べた通り、プロジェクトの工数やお客様とのビジネス的側面からは評価しづらい傾向にあるテストコードによる品質向上問題ですが、しっかりとテストケースを挙げて実行することで相応のリターンがあると私は思います。

扱うシステムの品質向上や自身の技術力向上の一環として、皆さんもぜひ積極的にテストコードを書いてみられてはいかがでしょうか。:santa: