始めに
ユニットテストを品質の向上の為ではなく、開発する際に便利なツールである、と言う観点で紹介をします。
利用例
まずは、どんな時に便利か、と言う話です。
1) コードを修正したら、サーバーを再起動して、動作確認している。
コードを変更するたびにTomcatの起動を待っているような場合、ユニットテストを利用する事で、動作確認の為の起動が早くなり、待ち時間を短縮する事ができます。
2) パターンテストを設定変更で、動作確認している。
設定により変わる動作の確認をする際に、パターンを繰り返し実行している場合、ユニットテストを利用する事で、複数のパターンを同時に確認する事ができます。
3) 実装を変更する度に、動作確認している。
動作確認をした後に不具合やリファクタリングにより実装変更した際に、もう一度動作確認をする場合、ユニットテストを利用する事で、楽に動作確認を繰り返す事ができます。
4) 例外ケースの確認の為の再現に苦労している、もしくは、確認を諦めている。
通常発生しないケースの確認としてログを出力したり、例外をスローするケースの再現に苦労している場合、ユニットテストを利用する事で、例外ケースを簡単に再現する事ができます。
5) ドキュメントを書いても、修正によりに実装とズレていく。
JavaDocやJSDocを書いた後に実装の変更した際に、修正を忘れてしまうような場合、ユニットテストを利用する事で、動作確認すると同時に仕様を残す事ができます。
実装例
例として休日を判定するロジックを作成する場合の流れを紹介します。
前提
コード記述は以下の利用を前提としています。
※途中で出てくる// ~~~
は行の省略を意味します。
対象の関数を決める
ここでは対象日付が休日かどうかを判定する関数を対象にします。
package com.example;
import java.util.Calendar;
public class CalendarUtils {
/**
* 休日であるか判定する。
*
* 対象日(targetDate)が休日の場合、trueを返す。
* 休日は土曜日、日曜日を対象とする。
*
* @param targetDate 対象日
* @return booleanを返す
* @throws IllegalArgumentException 対象日(targetDate)がnullの場合
*/
public static boolean isHoliday(Calendar targetDate) throws IllegalArgumentException {
if (targetDate == null) {
throw new IllegalArgumentException("TargetDate is null.");
}
int dayOfWeek = targetDate.get(Calendar.DAY_OF_WEEK);
if (dayOfWeek == Calendar.SUNDAY) return true;
if (dayOfWeek == Calendar.SATURDAY) return true;
return false;
}
}
ユニットテストを使わずにこの関数の動作確認をする場合、サーバーを起動し、該当部分が実行される操作をします。
ユニットテストで実行する。
テストクラスを作成し、以下のように記述します。※通常、テストクラスはIDEで簡単に作成できます。
package com.example;
import java.util.Calendar;
import static org.hamcrest.MatcherAssert.*;
import static org.hamcrest.Matchers.*;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.DisplayName;
public class CalendarUtilsTest {
@Test
@DisplayName("isHolidayは、対象日が休日の場合、trueを返す。")
void testIsHoliday() throws IllegalArgumentException {
// Given
Calendar targetDate = Calendar.getInstance();
targetDate.set(2022, Calendar.AUGUST, 14);
// When
boolean actual = isHoliday(targetDate);
// Then
assertThat(actual, is(Boolean.TRUE));
}
}
これだけで実行が可能です。長い起動時間を待つ事無く、動作を確認する事ができます。
パターンテストを実行する
先ほどの関数を複数パターンに対応した記述に書き換えます。直接記述した値を引数で受けるようにします。
package com.example;
import java.util.Calendar;
import java.util.stream.Stream; // <-- 追加
import static org.hamcrest.MatcherAssert.*;
import static org.hamcrest.Matchers.*;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest; // <-- importを追加
import org.junit.jupiter.params.provider.Arguments; // <-- importを追加
import org.junit.jupiter.params.provider.MethodSource; // <-- importを追加
import org.junit.jupiter.api.DisplayName;
public class CalendarUtilsTest {
// @Test <-- ParameterizedTestに変更
@ParameterizedTest
@MethodSource("sourceIsHoliday") // <-- データを作成する関数を指定
@DisplayName("isHolidayは休日であるかを返す。")
void testIsHoliday(String description, Calendar targetDate, boolean expected) throws Exception {
// Given by parameter
// Calendar targetDate = Calendar.getInstance(); <-- 引数に変更
// targetDate.set(2022, Calendar.AUGUST, 14);
// When
boolean actual = isHoliday(targetDate);
// Then
assertThat(actual, is(expected));
}
static Stream<Arguments> sourceIsHoliday() { // <-- データを作成する関数
// targetDate のパターンを指定
Calendar weekday = Calendar.getInstance(); // 平日
weekday.set(2022, Calendar.AUGUST, 1);
Calendar saturday = Calendar.getInstance(); // 土曜
saturday.set(2022, Calendar.AUGUST, 6);
Calendar sunday = Calendar.getInstance(); // 日曜
sunday.set(2022, Calendar.AUGUST, 7);
return Stream.of(// <-- testIsHolidayに渡すデータ
Arguments.of("対象日が平日の場合、falseを返す。", weekday, false),
Arguments.of("対象日が土曜の場合、trueを返す。", saturday, true),
Arguments.of("対象日が日曜の場合、trueを返す。", sunday, true)
);
}
}
設定を変えてテストしていたら時間がかかる内容も、組み合わせを簡単に作成して、同時に動作確認をする事ができます。
仕様変更に対応する
土曜日を含むかどうか指定できるように仕様変更したケースに対応してみます。containSaturdayを引数に追加して動作を合わせて変えます。
package com.example;
import java.util.Calendar;
public class CalendarUtils {
/**
* 休日であるか判定する
*
* 対象日(targetDate)が休日の場合、trueを返す。
* 休日は土曜日、日曜日を対象とする。
*
* @param targetDate 対象日
* @param containSaturday 休日に土曜を含むか // <-- 引数を追加
* @return booleanを返す
* @throws IllegalArgumentException 対象日(targetDate)がnullの場合
*/
public static boolean isHoliday(Calendar targetDate, boolean containSaturday) throws IllegalArgumentException { // <-- 引数を追加
if (targetDate == null) {
throw new IllegalArgumentException("TargetDate is null.");
}
int dayOfWeek = targetDate.get(Calendar.DAY_OF_WEEK);
if (dayOfWeek == Calendar.SUNDAY) return true;
if (containSaturday && dayOfWeek == Calendar.SATURDAY) return true; // <-- 条件を追加
return false;
}
}
組み合わせにも追加します。
static Stream<Arguments> sourceIsHoliday() {
// targetDate
Calendar weekday = Calendar.getInstance(); // 平日
weekday.set(2022, Calendar.AUGUST, 1);
Calendar saturday = Calendar.getInstance(); // 土曜
saturday.set(2022, Calendar.AUGUST, 6);
Calendar sunday = Calendar.getInstance(); // 日曜
sunday.set(2022, Calendar.AUGUST, 7);
// containSaturday <-- 追加した引数のパターンを追加
boolean contain = true; // 含む
boolean notContain = false; // 含まない
return Stream.of(// <-- testIsHolidayに渡すデータを変更
Arguments.of("対象日が平日で、土曜を含む場合、falseを返す。", weekday, contain, false),
Arguments.of("対象日が平日で、土曜を含まない場合、falseを返す。", weekday, notContain, false),
Arguments.of("対象日が土曜で、土曜を含む場合、trueを返す。", saturday, contain, true),
Arguments.of("対象日が土曜で、土曜を含まない場合、falseを返す。", saturday, notContain, false),
Arguments.of("対象日が日曜で、土曜を含む場合、trueを返す。", sunday, contain, true),
Arguments.of("対象日が日曜で、土曜を含まない場合、trueを返す。", sunday, notContain, true)
);
}
ケースが2倍になり、複雑になりましたが、変更前の部分も合わせ同時に動作確認をする事ができます。
例外系を書いてみる
今度は例外を取るケースもテストをしてみます。先ほどとは検証内容が異なるので、別の関数にします。
// ~~~
import static org.junit.jupiter.api.Assertions.assertThrows; // <-- importを追加
// ~~~
public class CalendarUtilsTest {
// ~~~
@Test
@DisplayName("isHolidayは対象日がnullである場合、例外が発生する。")
void testIsHolidayWhenTargetDateIsNull() { // <-- 関数を追加
// Given
Calendar targetDate = null;
// When
IllegalArgumentException e = assertThrows(IllegalArgumentException.class, () -> isHoliday(targetDate, true)); // <-- 発生したExceptionを取得
// Then
assertThat(e.getMessage(), is("TargetDate is null.")); // <-- メッセージを検証
}
設定では再現しづらく、諦めがちなテストも動作確認をする事ができます。
テストケースをグループに纏める
関数に対するテストケースが分かれて見づらいので纏めます。ケースに合わせ関数名も変更します。
// ~~~
import org.junit.jupiter.api.TestInstance; // <-- importを追加
import org.junit.jupiter.api.TestInstance.Lifecycle; // <-- importを追加
// ~~~
import org.junit.jupiter.api.Nested; // <-- importを追加
// ~~~
public class CalendarUtilsTest {
@Nested
@DisplayName("isHolidayは休日であるかを判定する。") // <-- 関数の説明を記載
@TestInstance(TestInstance.Lifecycle.PER_CLASS) // <-- class内にsourceを配置する為に指定
class IsHolidayTest { // <-- グルーピング用を追加
@ParameterizedTest
@MethodSource("sourceWhenTargetDateIsNotNull") // <-- 合わせて変更
@DisplayName("対象日付がnullではない場合") // <-- 場合分けに変更
void testWhenTargetDateIsNotNull(String description, Calendar targetDate, boolean containSaturday, boolean expected) throws Exception { // <-- 場合分けに変更
// ~~~
}
Stream<Arguments> sourceWhenTargetDateIsNotNull() { // <-- 名称変更、staticを除外
// ~~~
}
@Test
@DisplayName("対象日がnullである場合、IllegalArgumentExceptionが発生する。") // <-- 関数名を除外
void testWhenTargetDateIsNull() { // <-- 関数名を除外
// ~~~
}
}
このように整理する事で纏まりが分かりやすくなり、パターンの漏れがないか確認しやすくなります。
詳細な仕様はテストケースに任せる
上記での仕様変更のドキュメントへの反映が漏れています。組み合わせも多く、該当ケースが分かりづらいので、テストケースを参照するように促す。
* 休日であるかを返す。
*
* 対象日(targetDate)が休日の場合、trueを返す。
* 休日は土曜日、日曜日を対象とする。// <-- 仕様変更が反映されづらいので削除
*
* @param targetDate 対象日
* @param containSaturday 休日に土曜を含むか
* @return booleanを返す
* @throws IllegalArgumentException 対象日(targetDate)がnullの場合
* @see "Test: com.example.CalendarUtils.IsHolidayTest" // <-- テストケースをみるように追加
*/
public static boolean isHoliday(Calendar targetDate, boolean containSaturday) throws IllegalArgumentException {
// ~~~
}
テストケースに詳細仕様を任せる事で、更新漏れによる仕様の不一致を防ぐことができます。
こんな感じで表示されるようになります。社内で利用するだけであれば十分です。
実行結果
VSCodeで実行した結果を参照して、出力のされ方を確認します。
実行した結果を分かりやすいようにしておく事で、他の人が仕様を把握するのが楽になります。
サンプルコード
実行できるコードは以下から取得可能です。動かしてみたい方はご利用下さい。
おまけ
もっと色々とやってみたい方はmoromi25さんの記事が初心者向けに詳しいので、こちらも見てみて下さい。
参考資料
MockやHelper関数など他の方法も活用すると多くのケースに対応できます。