JUnitで使うDTOの各フィールド値のバリエーションを作る
動作確認環境
- Java 11
- JUnit 5.7.2
関連記事
JUnitで使うMockitoの設定をテストごとにわかりやすく変える
フィールドが多めのDTOとユニットテスト
フィールドが多めのDTOがあり、そのDTOのフィールド値をこまめに変更してテスト対象のメソッドに渡す十分な量のバリエーションの作りたいとする。
本当に大量のフィールドを持ったDTOを用意すると大変なので、ここでは4つのフィールドを持ったものを例にあげる。
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PACKAGE)
public class Dto {
private Integer a;
private Integer b;
private Integer c;
private Integer d;
}
ユニットテストの正常系で利用する値がセットされたDTOを生成するために、通常はprivateメソッドを作成するだろう。
private Dto template() {
return new Dto(1, 2, 3, 4);
}
正常系、異常系をフィールド値を少しずつ変えながらテスト実行していく。
@Test
public void test() {
Dto dto;
// 正常系
dto = template();
// exec test and assert. 略
// 異常系その1(aだけを変えたもの)
dto = template(); // 初期化
dto.setA(100);
// exec test and assert. 略
// 異常系その2(bだけを変えたもの)
dto = template(); // 初期化
dto.setB(200);
// exec test and assert. 略
// 異常系その3(a, dだけを変えたもの)
dto = template(); // 初期化
dto.setA(100);
dto.setD(400);
// exec test and assert. 略
}
フィールドが多めのDTOとユニットテストの問題点
実用上は特段問題ないが、以下の点でテストコードが読みづらい、扱いづらいと感じる。
- dtoの初期化を忘れないようにしないといけない
- dtoの初期化をせずに前のケースの状態 + 特定のsetterを呼ぶような前のケースに依存したコードを書くと、コードが追いづらい
- dtoの生成の始まりから終わりが判別しづらい
1と2はとくに補足は不要だと思う。
3について空行を入れるか、あるいはブロックで囲むという対策が取れると思う。(メソッド自体を分けるというのは無しとする。理由はバリエーション自体が大量にあるという前提なので、その分大量にメソッドを作るのが嫌だから。)
// 空行
// 異常系その2(bだけを変えたもの)
dto = template(); // 初期化
dto.setB(200);
// exec test and assert. 略
// 異常系その3(a, dだけを変えたもの)
dto = template(); // 初期化
dto.setA(100);
dto.setD(400);
// exec test and assert. 略
// ブロック
// 異常系その2(bだけを変えたもの)
dto = template(); // 初期化
{
dto.setB(200);
}
// exec test and assert. 略
// 異常系その3(a, dだけを変えたもの)
dto = template(); // 初期化
{
dto.setA(100);
dto.setD(400);
}
// exec test and assert. 略
空行については、逆に「異常系その2」と「異常系その3」の区切れが見づらくなった。
ブロックについては、確かに見やすい。しかし、ブロックでくくる必然性がない。また、「異常系その3」のように複数行あれば意図もわかりやすいが、「異常系その2」のように1行しかブロック内にないと意図がわかりづらくなると思う。
lambdaを使った対応
ブロックでくくる手法を応用して、文法上ブロックが出てくるのが必然(もしくは自然)だし、1行しかブロック内になくても違和感のないコードを書いてみたい。
また、DTOの初期化も忘れることがありえないようにしてみたい。
コードを記載するが、ポイントはtemplate
メソッドの引数で関数を受け取り、DTOの初期化のあとで関数を実行するというもの。
private Dto template(Consumer<Dto> override) {
Dto dto = new Dto(1, 2, 3, 4);
if (override != null) {
override.accept(dto);
}
return dto;
}
@Test
public void test() {
Dto dto;
// 正常系
dto = template(null);
// exec test and assert. 略
// 異常系その1(aだけを変えたもの)
dto = template(x -> x.setA(100));
// exec test and assert. 略
// 異常系その2(bだけを変えたもの)
dto = template(x -> x.setB(200));
// exec test and assert. 略
// 異常系その3(a, dだけを変えたもの)
dto = template(x -> {
x.setA(100);
x.setD(400);
});
// exec test and assert. 略
}
template(x -> 略)
とlambdaを渡しているが、メソッド参照を渡すとわかりやすくなる場合もある。
例えば開始日時/終了日時(startTime
/endTime
)の値を変えてテストすることを考える。終了日時を現在日時よりも前に設定するテストでは、開始日時が終了日時よりも前になっていないとデータとしておかしいため、template(x -> x.setEndTime(略));
だけでなくsetStartTime
も併せて実行したい。ただテストの主眼が「終了日時」が現在日時よりも前のときの挙動なのに、setStartTime
も併せてlambdaに書くと主眼がわかりにくくなる。そこでわかりやすい名前のメソッドを作成し、メソッド参照として渡すとわかりやすくなる。
private void convNowIsAfterEndTime(Dto dto) {
dto.setStartTime(NOW.minusDays(2));
dto.setEndTime(NOW.minusDays(1));
}
dto = template(this::convNowIsAfterEndTime);
lambdaを使った対応その2
先程のコードで十分だと思うが、UnaryOperator
にはandThen
メソッドがあるので、遊びがてら少し書き方を変えてみたい。
@Test
public void test() {
UnaryOperator<Dto> template = x -> {
x.setA(1);
x.setB(2);
x.setC(3);
x.setD(4);
return x;
};
Dto dto;
// 正常系
dto = template.apply(new Dto());
// exec test and assert. 略
// 異常系その1(aだけを変えたもの)
dto = template.andThen(x -> {
x.setA(100);
return x;
}).apply(new Dto());
// exec test and assert. 略
// 以下略
}
ものすごく改善したポイントがあるわけではないが、andThen
が文脈上よく合う。
また、正常系でnull
を渡すという点がなくなったし、if分岐も消えた。
Pure Java以外の解法
Lombokの@Builder
Lombokの@Builder
をDTOにつけていれば、template
の戻り型をBuilder
クラスにすることでシンプルに実装できる。
private Dto.DtoBuilder template() {
return Dto.builder()
.a(1)
.b(2)
.c(3)
.d(4)
;
}
@Test
public void test() {
Dto dto;
// 正常系
dto = template().build();
// exec test and assert. 略
// 異常系その1(aだけを変えたもの)
dto = template().a(100).build();
// exec test and assert. 略
// 異常系その2(bだけを変えたもの)
dto = template().b(200).build();
// exec test and assert. 略
// 異常系その3(a, dだけを変えたもの)
dto = template().a(100).d(400).build();
// exec test and assert. 略
}
LombokのBuilderを使う場合、いくつかデメリットがある
- 完全コンストラクターではないので、値の指定漏れが発生しうる
- 外部ライブラリが提供しているDTOには、自分でBuilderを付与できない
これらのデメリットを無視できる場合は、大きな威力を発揮する。
Kotlinのデフォルト引数
Kotlinならtemplate
メソッドのデフォルト引数で全て値を指定しておけば、名前付き引数(named arguments)でうまく書ける。
// class Dto(val a: Int, val b: Int, val c: Int, val d: Int)
private fun template(a: Int = 1,
b: Int = 2,
c: Int = 3,
d: Int = 4) = Dto(a, b, c, d)
@Test
fun test() {
var dto: Dto
dto = template()
dto = template(a = 100)
dto = template(b = 200)
dto = template(a = 100, d = 400)
}
プロダクトコードはJavaで書かざるを得ない場合でも、JUnitならKotlinを導入できないか検討しても良いかもしれない。