本記事ではJUnit5におけるパラメータ化テストの使いどころと実際の実装方法について紹介します。
使いどころ
テストケースを作成する時は複数の振る舞いをテストすることがほとんどかと思います。
例えば、以下のように受け取った年齢の値から学年を返すメソッドがあるとします。
public String getGrade(int age) { if (age < 0) { return "存在しない年齢"; } if (age <= 5) { return "園児" } else if (age <= 12) { return "小学生" } else if (age <= 15) { return "中学生" } else if (age <= 18) { return "高校生" } return "大人" }
この場合テストしたい振る舞いは6ケースです。
- 存在しない年齢
- 園児
- 小学生
- 中学生
- 高校生
- 大人
境界値でテストをするならそれぞれ以下の値を入力値としてテストしたいです
- 存在しない年齢: -1
- 園児: 0, 5
- 小学生: 6, 12
- 中学生: 13, 15
- 高校生: 16, 18
- 大人: 19
このテストを素直に書くと以下のようになります。
public class 年齢から学年を判定する処理のテスト { @Test public void 年齢が0未満の場合_存在しない年齢とする() { assertEquals("存在しない年齢", getGrade(-1)); } @Test public void 年齢が0の場合_園児とする() { assertEquals("園児", getGrade(0)); } @Test public void 年齢が5の場合_園児とする() { assertEquals("園児", getGrade(5)); } @Test public void 年齢が6の場合_小学生とする() { assertEquals("小学生", getGrade(6)); } @Test public void 年齢が12の場合_小学生とする() { assertEquals("小学生", getGrade(12)); } @Test public void 年齢が13の場合_中学生とする() { assertEquals("中学生", getGrade(13)); } @Test public void 年齢が15の場合_中学生とする() { assertEquals("中学生", getGrade(15)); } @Test public void 年齢が16の場合_高校生とする() { assertEquals("高校生", getGrade(16)); } @Test public void 年齢が18の場合_高校生とする() { assertEquals("高校生", getGrade(18)); } @Test public void 年齢が19以上の場合_大人とする() { assertEquals("大人", getGrade(19)); } }
このテストでも網羅性の観点で言えば問題は無さそうです。
ただ、入力値が異なるが期待値が同じといういわゆる同値クラスのテストもメソッドが分割されていて冗長に感じます。
また、それらのメソッド名はシステムの振る舞いを適切に表せていません。例えば、上記のメソッドを見ただけでは年齢が7の時は何が返ってくるのが分からないので結局実装を見に行くことになります。
テストはシステムの仕様を表現するという大事な役割も担っていますが、上記の書き方だと仕様をメソッド名で表現しづらくなってしまいます。
こんな場面で使えるのがパラメータ化テストです。
同値クラスの箇所をパラメータ化テストに置き換えたのが以下になります。
public class 年齢から学年を判定する処理のテスト { @Test public void 年齢が0未満の場合_存在しない年齢とする() { assertEquals("存在しない年齢", getGrade(-1)); } @ParameterizedTest @ValueSource(ints = {0, 5}) public void 年齢が0以上5以下の場合_園児とする(int age) { assertEquals("園児", getGrade(age)); } @ParameterizedTest @ValueSource(ints = {6, 12}) public void 年齢が6以上12以下の場合_小学生とする(int age) { assertEquals("小学生", getGrade(age)); } @ParameterizedTest @ValueSource(ints = {13, 15}) public void 年齢が13以上15以下の場合_中学生とする(int age) { assertEquals("中学生", getGrade(age)); } @ParameterizedTest @ValueSource(ints = {16, 18}) public void 年齢が16以上18以下の場合_高校生とする(int age) { assertEquals("高校生", getGrade(age)); } @Test public void 年齢が19以上の場合_大人とする() { assertEquals("大人", getGrade(19)); } }
同値クラスがすっきりして見やすくなりました。
テストメソッド名も振る舞いを表現しやすくなりました。テストを見るだけでgetGradeメソッド
がどのような振る舞いをするかが分かるようになったかと思います。
パラメータ化テストが便利なことが分かりました。
しかし、使い方を誤ると逆にテストが分かりにくくなることもあります。
パラメータ化テストは複数データを一度にパラメータとして渡すこともできます。そうすると、上記のテストコードをもっと改良しようと全てのケースをパラメータに集約したくなってきます。
実際にやってみたのが以下です。
public class 年齢から学年を判定する処理のテスト { @ParameterizedTest @CsvSource({"-1, 存在しない年齢", "0, 園児" "5, 園児" "6, 小学生" "12, 小学生" "13, 中学生" "15, 中学生" "16, 高校生" "18, 高校生" "19, 大人" }) public void 与えられた年齢から学年を判定する(int age, String grade) { assertEquals(grade, getGrade(age)); } }
テストコードは短くなりました。しかし、何をテストしているのかよく分からなくなりました。
単純にパラメータが多すぎるのが一つの原因です。パラメータは値でしかないのでそれだけを書かれてもテストの意図は分かりません。
またテストメソッドが汎用的なメソッド名になっているのが分かるかと思います。色んな振る舞いを一度にテストしすぎて具体的な命名ができなくなってしまいました。
このようにパラメータ化テストはやりすぎるとテストコードを読みづらくし、本来のテストの目的である仕様の表現ができなくなってしまいます。
なので、ケースバイケースですがパラメータ化の際は振る舞いが同じで入力が異なる同値クラスごとに分割して行うのが良い粒度だと思います。パラメータ化をしすぎて本来の目的を忘れないように注意しましょう。
実装方法
ここまでも軽く触れましたが、パラメータ化テストの具体的な実装方法を紹介します。
色々と便利なアノテーションがありますが、本記事では実際の開発で特に使用するものに限定して紹介します。
パラメータ化テストの宣言
@ParameterizedTest
このテストではパラメータ化テストをしますよ~という宣言を行うアノテーションです。
パラメータ化テストを行う対象のメソッドに必ず付与します
パラメータ指定
単一データの入力
@ValueSource
基本型のアノテーションが行えます。以下の型が入力可能です。
アノテーションの中にパラメータに渡す型と値を記述し、テストメソッドの引数で値を受け取って使用します。
値は記述した順番にテストメソッドの引数に渡されます。
コード例
@ParameterizedTest @ValueSource(ints = {0, 5}) public void 年齢が0以上5以下の場合_園児とする(int age) { assertEquals("園児", getGrade(age)); }
ここで、仮に age=0
のテストでこけた場合どうなるの?と思った方がいるかもしれません。
パラメータ化テストでは途中でテストがこけても全てのパラメータについて実行を行い、どのパラメータでこけたかが明確に分かります。
なのでJUnitのアンチパターンであるアサーションルーレットが発生することもありません。
アサーションルーレット: 一つのテストメソッドに複数のアサートを記述した場合、途中でテストがこけるとそれ以降のテストが実施されなくなるというアンチパターン
列挙型
@EnumSource
Enumの値もパラメータ化できます。
例えば Grade
というEnumがあった場合、@EnumSourceの引数にGrade.class
を指定し、パラメータに Grade
クラスのオブジェクト名を記述します。
コード例
enum Grade { KINDERGARTEN("園児"), ELEMENTARY_SCHOOL_STUDENT("小学生"), JUNIOR_HIGH_SCHOOL_STUDENT("中学生"), SENIOR_HIGH_SCHOOL_STUDENT("高校生"), GROWN_UP("大人"); } int getFee(Grade grade) { if (grade == Grade.KINDERGARTEN || grade == Grade.ELEMENTARY_SCHOOL_STUDENT) { return 0; } return 1000; } @ParameterizedTest @EnumSource(value = Grade.class, names = {"KINDERGARTEN, ELEMENTARY_SCHOOL_STUDENT"}) public void 園児から小学生の場合_料金を無料とする(Grade grade) { assertEquals(0, getFee(grade)); }
複数データの入力
@CsvSource
前述したように使いどころには注意が必要ですが、
テスト対象のメソッドが複数の引数を必要とする場合や期待値が引数と連動するような場合に役立ちます。
コード例
@ParameterizedTest @CsvSource({"1901, 1, 1", "2000, 12, 31"}) public void 1901年から2000年の100年間は_20世紀とする(int year, int month, int day) { assertEquals(20, getCentury(year, month, day)); }
上記のコードを見るとstringで与えたパラメータがintに変換されています。
パラメータ化テストではこのような暗黙的な変換を行ってくれます。
暗黙的な変換の種類について
まとめ
JUnitのパラメータ化テストについて紹介しました。
使いどころさえ見極めればテストコードを書くのに非常に有効なテクニックになります。
ぜひ使いこなして、良いJUnitライフを送りましょう!
参考
https://oohira.github.io/junit5-doc-jp/user-guide/#writing-tests-parameterized-tests
エンジニア中途採用サイト
ラクスでは、エンジニア・デザイナーの中途採用を積極的に行っております!
ご興味ありましたら是非ご確認をお願いします。
https://career-recruit.rakus.co.jp/career_engineer/
カジュアル面談お申込みフォーム
どの職種に応募すれば良いかわからないという方は、カジュアル面談も随時行っております。
以下フォームよりお申込みください。
ラクスDevelopers登録フォーム
https://career-recruit.rakus.co.jp/career_engineer/form_rakusdev/
イベント情報
会社の雰囲気を知りたい方は、毎週開催しているイベントにご参加ください!
◆TECH PLAY
◆connpass