はじめに
お疲れ様です。 @Keichan_15 です。
DevToxicClub Advent Calendar 2024 11日目の記事を担当します。
今回はお仕事でJavaを触っていることにちなんで、ユニットテストのフレームワークであるJUnitを使用したので、今回はそちらについて書いていこうと思います。
基本的な内容は検索すると山ほど引っ掛かると思うので割愛し、今回は例外処理に着目してJUnitのテストコードをどう書くかといった内容をご紹介できればと思います。
雑談
この記事執筆に至ったきっかけは単純で、業務でJUnitを書く機会をいただいたのが理由です。
テストコードと聞くと自分はRoR育ちなのでRSpecが思い浮かびましたね。最近はフロントエンドなどもやってるのでJestとかその辺りが出てきます。
ほぼテストコードを1から書くという経験が初めてだったこともあり、テストコードを書く前はこれらの重要性をあまり理解できませんでした。
実際に様々なテストケースを考慮してそれらをテストコードに卸して動作検証する、という一連の過程を進めるにつれて、プログラミング段階でのミスを大幅に減らすと共にサービスの品質向上に寄与するといった側面を学ぶことができたと思います。
プロジェクトによっては工数の観点からあまり重要視されにくい分野ではありますが、テストコードを用いたユニットテストはシステムの価値・品質の向上に少なくとも役立っていると思いますので、時間があるのであれば積極的に取り入れるべきなのではと感じました。
話が少し脱線しましたが、早速パターン別のテストコードを見ていきましょう。
環境
JDK 21JUnit5
パターン別テストコード
①設定した例外がthrowされているか
JUnit5からは、例外を検出するアサーションとしてAssertThrowsが追加されました。
@Test
void testCase_001() {
String str = null;
Exception exception = assertThrows(NullPointerException.class, () -> {
str.length();
});
}
上記のサンプルコードではstr.length();で文字列長を取得しようとしていますが、前段でstrに対してnull値を代入しているためNullPointerExceptionが発生します。
AssertThrowsでは第一引数に指定した例外クラスが第二引数以降の処理内で発生するかを確認します。
指定した例外が発生しなかった場合や別の例外が発生した場合はassertThrowsが失敗します。
JUnit4では上記のような「例外が発生しなかった場合の処理」だった場合にfail()をかまして意図的にテストが失敗したことを検知させる必要がありました。
※本来は例外が発生することを期待していたが、例外が発生しなかった場合にcatch文節へ処理が移らない
// 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メソッドへのテスト
まずはサンプルコードから。
@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()で行います。
ここで重要なのは以下の部分です。catchした例外を再throwしている箇所です。
} catch (Exception e) {
throw e.getTargetException();
}
実はリフレクションを使用してprivateメソッドを呼び出した場合、例外(ここではNullPointerException)はInvocationTargetExceptionと呼ばれる例外にラップされます。
今回テストをしたい例外はあくまでNullPointerExceptionです。これらを取得するにはどうすればいいか…、ここでgetTargetException()が出番になります。
getTargetException()はInvocationTargetException()にラップされている元の例外を取得するためのメソッドです。
つまり今回で言えば、e.getTargetException()とすることで本来取得したかったNullPointerExceptionがゲットできるということです。
これで取得したNullPointerExceptionをassertThrowsで比較することで、privateメソッドでも特定の例外が発生したかどうかを検知することができます。
私は最初この部分に非常に悩まされたのですが、e.printStackTrace();でトレースログを出して解析したところ、このInvocationTargetException()に辿り着きました。
案外トレースログを出すの、おすすめです。
②例外に付与しているメッセージ内容を検証したい場合
こっちが今回の本題です。まずは以下のサンプルコードをご覧ください。
@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だよーん"と文字列を指定していますが、大きなシステムになるとこのメッセージの部分は動的な値を指定することがあるかもしれません。というかむしろ直接文字列を指定することなんてほぼ無いでしょう。ですよね?
そういった場合に上記のサンプルコードは非常に役立ちます。例えば以下のコードです。
@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メソッドへのテスト
まあリフレクションもそら見たいよねと。非使用時とさほど実装内容は変わりません。
サンプルコードは以下です。
@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のテストを通すためだけにアクセス修飾子を外すぐらいなら断然こっちの方がベストかなと思います。
Method method = NullPointerExceptionTest.class.getDeclaredMethod("checkNull", String.class);
method.invoke(this, str);
逆にJUnitでprivateメソッドにテストを通せないのでアクセス修飾子を変えてるなんてこと、あるんですか…、て思ったらあるみたいです。凄いな…。
@11295 さん執筆記事より引用
モックライブラリ使えばリフレクションなんて使わずにもっと楽に書ける(MockitoのWhiteboxとか)んですけど、モックライブラリの導入が許されないプロジェクトにぶち当たると軽率にアクセス修飾子を外す人が存在するので、この記事を書きました。
やめろ!やめてくれ!テストのために誤ったクソコードを書くのはやめるんだ!まじで!!
おわりに
今回はJUnit5における例外処理のテストについて執筆しました。
冒頭にも述べた通り、プロジェクトの工数やお客様とのビジネス的側面からは評価しづらい傾向にあるテストコードによる品質向上問題ですが、しっかりとテストケースを挙げて実行することで相応のリターンがあると私は思います。
扱うシステムの品質向上や自身の技術力向上の一環として、皆さんもぜひ積極的にテストコードを書いてみられてはいかがでしょうか。![]()