少年易酔學難成

IT/技術的な話題について書きます

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

(追記) Property-Based Testing Advent Calendar 2018参加にあたって、新たに記事を書こうと思ったのですが、公私にわたる多忙によりあきらめました。 せめて内容を加筆しようと思ったのですが、精根尽き果てました。 大変申し訳ありまきねん。

背景

テストを書くときにテストデータを用意するのは精神的な苦痛を伴うし、また正しく作成するのはなかなか大変な作業です。
私が先月まで居た案件の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の生成が必要になることもあるでしょう。 QuickTheoriesのドキュメントをざっと読んだ限り、そのような巨大なEntityのGenを生成する便利な仕組みは提供されていないように思います。
そこで、今回は以下のような巨大なEntityを例に、どのようにGenを生成するか考えて見たいと思います。

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) -> {
      ... // なにかテスト処理
    });
  }

こんな感じで求めるコードのフォーマットが決まれば、あとはDSLを生成するスクリプトなりGradleプラグインを作れそうです。

まとめ

現場からは以上です。