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とユニットテストの問題点

実用上は特段問題ないが、以下の点でテストコードが読みづらい、扱いづらいと感じる。

  1. dtoの初期化を忘れないようにしないといけない
  2. dtoの初期化をせずに前のケースの状態 + 特定のsetterを呼ぶような前のケースに依存したコードを書くと、コードが追いづらい
  3. 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を導入できないか検討しても良いかもしれない。