Java テストコードにおける複雑なオブジェクトのセットアップ
概要
テストコードを書くときに入力値や期待値として複雑なオブジェクトのセットアップをしなければならない、稀によくありますよね。現在参画中のプロジェクトではテストクラス内に大量にセットアップ用コードがあり、開発と共に増え続けていきます。可読性も悪く、メンテナンスも辛い。それじゃ、外部ファイルから読み込めばいいんじゃないというお話です。
環境
複雑なオブジェクトのセットアップが辛い
現状のテストコードは生成メソッドを大量に書くか、制御構文である程度まとめています。テストクラス内に書いているため、ほとんどがセットアップ用のコードで埋め尽くされているような状態です。これをなんとかしたい。
テストケースごとに生成メソッドを用意
シンプルですが大量にメソッドが作成されます。複数のケースで同じデータを扱っていても共通化はしていません。共通データを修正する際は複数箇所を書き直すのが辛いですがテストケースごとに独立はしています。
@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); } }
残念ながらLocalDate
とLocalDateTime
型は非対応っぽいので、そのままでは動きませんでした。
Constructor
というクラスを拡張して、定義したタグが付いていたらLocalDate
やLocalDateTime
にパースしてあげるって感じにしてあげたらいいっぽい。
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
クラスをスーパークラスとしたサブクラス(Glasses
やBag
など)のコレクションが格納されます。
@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にあげています。