1.21 jigowatts

Great Scott!

Java テストコードにおける複雑なオブジェクトのセットアップ

概要

テストコードを書くときに入力値や期待値として複雑なオブジェクトのセットアップをしなければならない、稀によくありますよね。現在参画中のプロジェクトではテストクラス内に大量にセットアップ用コードがあり、開発と共に増え続けていきます。可読性も悪く、メンテナンスも辛い。それじゃ、外部ファイルから読み込めばいいんじゃないというお話です。

環境

  • macOS Big Sur バージョン 11.5.2
  • Java 11

複雑なオブジェクトのセットアップが辛い

現状のテストコードは生成メソッドを大量に書くか、制御構文である程度まとめています。テストクラス内に書いているため、ほとんどがセットアップ用のコードで埋め尽くされているような状態です。これをなんとかしたい。

テストケースごとに生成メソッドを用意

シンプルですが大量にメソッドが作成されます。複数のケースで同じデータを扱っていても共通化はしていません。共通データを修正する際は複数箇所を書き直すのが辛いですがテストケースごとに独立はしています。

@Test
public void test() {
    // 期待値オブジェクトの生成
    var expected = expected_01();
    // 検証
    ...
}

private ComplexData expected_01() {
    // 複雑なオブジェクトの生成
    return ComplexData.builder()
            // 実際はここからさらにセットアップメソッドを呼ぶ
            ...
            .build();
}
テストケースごとにifやSwitch構文で共通のデータをまとめる

共通のデータをまとめます。修正時は直す箇所が少ないのがメリットですが、共通ではなくなった時にメンテナンスが辛いのと、可読性は落ちます。

@Test
public void test() {
    int caseNo = 2;
    // 期待値オブジェクトの生成
    var expected = expected(caseNo);
    // 検証
    ...
}

private ComplexData expected(int caseNo) {
    switch (caseNo) {
        case 1:
            // 複雑なオブジェクトの生成
            return ComplexData.builder()
                    // 実際はここからさらにセットアップメソッドを呼ぶ
                    ...
                    .build();
        case 2:
            // 複雑なオブジェクトの生成
            return ComplexData.builder()
                    // 実際はここからさらにセットアップメソッドを呼ぶ
                    ...
                    .build();
        default:
            return ComplexData.builder()
                    // 実際はここからさらにセットアップメソッドを呼ぶ
                    ...
                    .build();
    }
}

外部ファイルからオブジェクトの生成を行う

どうしたら良いのかとネットや本で調べていたら下記書籍に書いてありました。

外部ファイルに定義したリソースファイルにテストデータを記述し、生成メソッドなどで読み込む手法が有効です。

SnakeYamlというライブラリを使用してYAMLファイルからセットアップする方法が記載されていたので、この方法が良さそうです。

シンプルなオブジェクト

まずはシンプルなオブジェクトの生成から試してみました。

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Task {
    private String id;
    private int number;
    private String name;
    private LocalDateTime start;
    private LocalDateTime end;
    private LocalDate expire;
    private boolean completed;
}

YAMLファイルはこんな感じ。

id: "1"
number: 101
name: "Homework"
start: "2021-08-20 16:30:00"
end: null
expire: "2021-08-22"
completed: false

テストコードはYAMLファイルからオブジェクトを生成できるか確認したいので、ここでは期待値はインラインで作成しています。

@Test
public void test() throws IOException {

    Task expected = Task.builder()
                        .id("1")
                        .number(101)
                        .name("Homework")
                        .start(LocalDateTime.of(2021, 8, 20, 16, 30, 0))
                        .end(null)
                        .expire(LocalDate.of(2021, 8, 22))
                        .completed(false)
                        .build();

    var path = Paths.get("path/to/task.yml");
    try (InputStream io = Files.newInputStream(path)) {
        Constructor constructor = new Constructor(Task.class);
        Yaml yaml = new Yaml(constructor);
        Task actual = yaml.load(io);

        assertThat(actual).usingRecursiveComparison().isEqualTo(expected);
    }
}

残念ながらLocalDateLocalDateTime型は非対応っぽいので、そのままでは動きませんでした。

Constructorというクラスを拡張して、定義したタグが付いていたらLocalDateLocalDateTimeにパースしてあげるって感じにしてあげたらいいっぽい。

import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;

import org.yaml.snakeyaml.constructor.Constructor;
import org.yaml.snakeyaml.nodes.Node;
import org.yaml.snakeyaml.nodes.ScalarNode;
import org.yaml.snakeyaml.nodes.Tag;

public class ExtensionConstructorBase extends Constructor {
    private static final String DATETIME_FORMAT = "yyyy-MM-dd HH:mm:ss";

    public ExtensionConstructorBase() {
        this(Object.class);
    }

    public ExtensionConstructorBase(Class<? extends Object> theRoot) {
        super(theRoot);
        setup();
    }

    private void setup() {
        yamlConstructors.put(new Tag("!localdate"), new LocalDateConstructor());
        yamlConstructors.put(new Tag("!localdatetime"), new LocalDateTimeConstructor());
    }

    protected class LocalDateConstructor extends Constructor.ConstructScalar {
        @Override
        public Object construct(Node node) {
            return LocalDate.parse(((ScalarNode) node).getValue());
        }
    }

    protected class LocalDateTimeConstructor extends Constructor.ConstructScalar {
        @Override
        public Object construct(Node node) {
            DateTimeFormatter dtf = DateTimeFormatter.ofPattern(DATETIME_FORMAT);
            return LocalDateTime.parse(((ScalarNode) node).getValue(), dtf);
        }
    }
}

YAMLファイルにはそれぞれ定義したタグをつける。

id: "1"
number: 101
name: "Homework"
start: !localdatetime "2021-08-20 16:30:00"
end: null
expire: !localdate "2021-08-22"
completed: false

テストコードはConstructorを新しく作成したExtensionConstructorBaseに差し替え。

Constructor constructor = new ExtensionConstructorBase(Task.class);
複雑なオブジェクト

次に現実でありそうな少し複雑なオブジェクトです。customersプロパティにはCustomerクラスのコレクションが格納されます。対してitemsプロパティにはItemクラスをスーパークラスとしたサブクラス(GlassesBagなど)のコレクションが格納されます。

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ComplexData {
    private String id;
    private Address address;
    private List<Customer> customers;
    private List<Item> items;
}

サブクラスのコレクションについては、YAMLファイルの定義からサブクラスを紐付ける必要があるため、ExtensionConstructorBaseをさらに拡張してタグを用意し、各クラスに対応するよう定義しました。

import org.yaml.snakeyaml.TypeDescription;
import org.yaml.snakeyaml.nodes.Tag;

public class ComplexDataConstructor extends ExtensionConstructorBase {
    public ComplexDataConstructor() {
        super(ComplexData.class);
        this.addTypeDescription(new TypeDescription(Glasses.class, new Tag("!glasses")));
        this.addTypeDescription(new TypeDescription(Bag.class, new Tag("!bag")));
    }
}

YAMLファイルはこんな感じ。

id: 1
address:
  id: 1
  zip: 1234567
  street: Fuji St.
  city: Tokyo
  country: JPN
customers:
  - {
    customerId: 1,
    customerName: customer1
    }
  - {
    customerId: 2,
    customerName: customer2
    }
items:
  - !glasses {
    id: 1,
    lens: blue
    }
  - !bag {
    id: 2,
    capacity: L
    }

ついでにYAMLを読み込む部分はクラスに抽出しました。引数なしコンストラクタでExtensionConstructorBaseを指定することでLocalDateなど共通の設定が反映されるようにしています。

public class YamlLoader<T> {
    private Constructor constructor;
    private static final String BASE_PATH = "src/test/resources/";

    public YamlLoader() {
        this.constructor = new ExtensionConstructorBase();
    }

    public YamlLoader(Constructor constructor) {
        this.constructor = constructor;
    }

    public T load(String yml) throws IOException {
        var path = Paths.get(BASE_PATH + yml);
        try (InputStream io = Files.newInputStream(path)) {
            return new Yaml(this.constructor).load(io);
        }
    }
}

テストコードはYAMLファイルの読み込みや設定を別クラスに切り出したことで少しシンプルになったかなと思います。

private YamlLoader<ComplexData> yamlLoader;

@BeforeEach
void setup() {
    yamlLoader = new YamlLoader<>(new ComplexDataConstructor());
}

@Test
public void test() throws IOException {

    var yml = "path/to/complexdata.yml";
    var expected = yamlLoader.load(yml);

    var actual = target.findById(id);

    assertThat(actual).usingRecursiveComparison().isEqualTo(expected);
}

外部ファイルから複雑なオブジェクトのセットアップをすることでテストコードの肥大化を防ぐことができそうです。今回のサンプルでは設定周りのコードが必要なので辛い部分は残ります。また、テストケースと同じだけの大量の外部ファイルが作られるという別の課題も出て来そうです。いずれにせよ改善の余地はまだあるということでしょう。

完全なコードはGitHubにあげています。