少年易酔學難成

酒に交われば朱くなる

QuickTheoriesを使ってProperty Based Testingをやってみよう

背景

テストを書くときにテストデータを用意するのは精神的な苦痛を伴うし、また正しく作成するのはなかなか大変な作業です。
私が先月まで居た案件のSuper Scala-ManはこういうときにProperty Based Testingライブラリを使用してデータを用意するそうです。

私も面倒は嫌いな方なので、これを試してみることにしました。

Property Based Testingやその活用方法についてはこちらがわかりやすいです。

さて、最近もメンテされているJavaのProperty Based Testingライブラリは、いくつかの選択肢があるようです。

  • JUnit-QuickCheck
  • FunctionalJava-Quickcheck
  • QuickTheories

私はこのなかでドキュメントもある程度揃っていて、JUnitで独自のTestRunnerを使用しなくても実行できるQuickTheoriesを選択しました。

基本的な書き方

基本的な使い方はREADMEを見てもらうのが早いと思いますが、すこしだけ解説します。

import static org.quicktheories.QuickTheory.qt;
import static org.quicktheories.generators.SourceDSL.*;

public class SomeTests {
  @Test
  public void addingTwoPositiveIntegersAlwaysGivesAPositiveInteger(){
    qt()
    .forAll(
      integers().allPositive(), // 1. 整数のジェネレータを用意する。この場合は正の整数が2つ生成される。
      integers().allPositive())
    .check((i,j) -> i + j > 0); // 2. Genが生成した値に対して行うテストを記述する
  }
}

このテストは、すべての(forAll)2つの正の整数の組み合わせに対して、その合計が0以上であることをチェック(check)すると読めます。
実際にはすべての組み合わせをテストすることはできないので、ランダムにいくつかの組み合わせをチェックします。

試行回数が増えればそれだけ時間もかかるので、QuickTheoriesでは以下のように試行回数も指定することができます。

import static org.quicktheories.QuickTheory.qt;
import static org.quicktheories.generators.SourceDSL.*;

public class SomeTests {
  @Test
  public void addingTwoPositiveIntegersAlwaysGivesAPositiveInteger(){
    qt()
    .withExamples(5) // 5つの組み合わせだけテストします。
    .forAll(
      integers().allPositive(),
      integers().allPositive())
    .check((i,j) -> i + j > 0);
  }
}

forAllに渡されているintegers().allPositive()の型はGenで、RandomnessSourceからIntegerを生成する責務を持っています。

Genインタフェースは抽象メソッドを一つだけ(generateメソッド)もつ関数型インターフェースなので、ラムダ式を代入できます。

// たとえば1だけをひたすら生成するGenはこんな感じ。
Gen<Integer> integerGen = rs -> 1;

実際のプロジェクトへの適用を考える

実際のプロジェクトで使うのであれば、Entityの生成が必要になるでしょう。
今回は以下のようなEntityを例に使ってみます。

import lombok.*;

@Getter
@Setter
@AllArgsConstructor
public class Employee {
  private Integer id;
  private String name;
  // EmployeeでcompanyCd持ってることに対するツッコミは無しでお願いします。
  private Integer companyCd;
}

Genを作る

単純にGen<Employee>を作るのならば、以下のように各フィールドに対応するGenを用意すれば良いでしょう。

import static org.quicktheories.generators.SourceDSL.*;
...
Gen<Employee> empGen = new Gen {
  private Gen<Integer> idGen = integers().all();
  private Gen<Integer> nameGen = strings().ascii().ofLengthBetween(1, 15);
  private Gen<Integer> companyCdGen = integers().all();
  
  @Override
  public Employee generate(RamdomnessSource rs) {
    return new Employee(
      idGen.generate(rs),
      nameGen.generate(rs),
      companyCdGen.generate(rs)
    );
  }
};

DSLを作る

QuickTheoriesはSourceDSLのように各データ型に対するDSLを用意しています。 同じようにEntityにもDSLを用意してあげるほうが良いでしょう。

public class EmployeeDSL {
  private Gen<Integer> idGen;
  private Gen<String> nameGen;
  private Gen<Integer> companyCdGen;

  public EmployeeDSL() {
    this(
      integers().all(),
      strings().ascii().ofLengthBetween(1, 15),
      integers().all()
    );
  }

  private EmployeeDSL(Gen<Integer> idGen, Gen<String> nameGen, Gen<Integer> ageGen, Gen<Integer> companyCdGen) {
    this.idGen = idGen;
    this.nameGen = nameGen;
    this.companyCdGen = companyCdGen;
  }

  public Gen<Employee> one() {
    return in -> new Employee(
      this.idGen.generate(in),
      this.nameGen.generate(in);
      this.companyCdGen.generate(in)
    );
  }

  // ここらへんはoneにしか依存しないのでinterfaceのdefault実装で提供してもよさそう
  public Gen<List<Employee>> listOfSize(int size) {
    return lists().of(one()).ofSize(size);
  }

  public Gen<List<Employee>> listOfSizeBetween(int minSize, int maxSize) {
    return lists().of(one()).ofSizeBetween(minSize, maxSize);
  }

  public Gen<List<Employee>> listOfSizes(Gen<Integer> sizeGen) {
    return lists().of(one()).ofSizes(sizeGen);
  }
}

このDSLにより以下のようにテストを書けるようになります。

  @Test
  public void addingTwoPositiveIntegersAlwaysGivesAPositiveInteger(){
    qt()
    .forAll(
      employees.listOfSize(10)
    .checkAssert(employees -> {
      ... // なにかテスト処理
    });
  }

ところで現在のidGenだと上記のように生成したemployeesの中でidが重複する可能性があります。
(実際にQuickTheoriesは重複が発生しやすいように思います。原因教えてください。)

重複した値を生成したくない場合は、こんなGen<Integer>を用意するといいかもしれません。 これをidGenとして使えば0, 1, 2 ...と順番にIDが生成されるので重複を避けることが出来ます。

  public static Gen<Integer> integerSequenceGen() {
    var seed = IntStream.iterate(0, i -> ++i).iterator();
    return in -> seed.next();
  }

またデータ生成に使うには各フィールドを固定値にしたいと思うかもしれません。
例えば、companyCdは外部キーで、DBに存在する値で固定したいとします。 その場合は以下のメソッドをEmployeeDSLに追加します。

  public EmployeeDSL companyCd(Integer companyCd) {
    this.companyCdGen = Generate.constant(companyCd);
    return this;
  }

idはDBのシーケンスで生成する場合、エンティティに値を設定しない方がいいときもあります。 例えばDomaではデータ挿入時にIDに値が設定してあればその値を使うようなので、EntityにIDを設定しているとキー重複エラーを引き起こす可能性があります。 この場合もcompanyCdと同様に固定値を設定するメソッドを追加すれば以下のように書けます。

  @Test
  public void addingTwoPositiveIntegersAlwaysGivesAPositiveInteger(){
    qt()
    .forAll(
      // Professional Null Cleanerに消されるやつ。
      employees.id(null).companyCd(1).listOfSize(10),
      employees.id(null).companyCd(2).listOfSize(10)
    .checkAssert((company1Employees, company2Employees) -> {
      ... // なにかテスト処理
    });
  }

もし余力があるのなら、Entityから全てのフィールドで固定値を設定するメソッドを持ったDSLを生成するスクリプトを作ってもいいかもしれません。

まとめ

こんな感じでDSLを拡張していけば、とりあえず実際のプロジェクトでも快適に使えるのではないかと思います。

現場からは以上です。