KAKEHASHI Tech Blog

カケハシのEngineer Teamによるブログです。

fast-checkでProperty-based Testing導入してみた

Musubi AI在庫管理のフロントエンドエンジニアの木本です。

Unitテストを書いていると、「この正常ケース/異常ケースの羅列で本当に品質を担保できているのか?」と不安になることがあります。そのとき有用な技術としてProperty-based Testingがあります。

TypeScriptでの代表的なProperty-based Testingフレームワークであるfast-checkを導入してみたところ、その結果として実装に不具合を発見することができたので、まとめたいと思います。

Property-based Testingとは?

宣言された入力条件からランダムな入力を何パターンも生成して、どんな入力でもテストが失敗しないかをチェックするという手法です1

正常系での入力は思いついても異常系の入力は思いつきにくいというときに実施してみると、NaNや空配列などのエッジケースも生成してくれたり、開発者が思いも寄らなかった入力を生成してくれるため、品質がかなり向上します。

Property-based Testingの元祖とも言えるのがHaskellライブラリのQuickCheckで、関数の入力の型定義が強い(入力条件を定義しやすい)関数型プログラミングと相性が良い手法だと言えます。

ユニットテストでどれくらいバグを検知できるか?

Ramdaのaperture関数を次のように再実装してみます(説明のためバグ多めに作っています)。

/**
 * 配列をsizeごとの配列に分ける関数
 * aperture([1, 2, 3, 4, 5], 3) = [[1, 2, 3], [4, 5]]
 * @param arr 配列
 * @param size 各配列の長さ
 * @returns 分けられた配列
 */
export function aperture<A>(arr: A[], size: number): A[][] {
  // 何分割することになるのかを算出
  const n = Math.ceil(arr.length / size);
  // 結果として返す二次元配列を用意する。とりあえず、空配列を並べる
  const result = Array.from({ length: n }).map(() => []) as A[][];

  // resultの各行にデータをpushしていく
  result.forEach((_, i) => {
    for (let j = 0; j < size; j += 1) {
      // arrから該当要素を取得
      const elem = arr[i * size + j];

      // 該当要素があればresult[i]にpushする
      if (elem) {
        result[i]?.push(elem);
      }
    }
  });

  return result;
}

このaperture関数をTable-driven Testingでテストコードを書いてみると、こんな感じになります。

import { aperture } from './aperture';

test.each([
  // 長さ6の配列は3つずつに分割される
  {
    inputArray: [1, 2, 3, 4, 5, 6],
    inputSize: 3,
    expectedOutput: [
      [1, 2, 3],
      [4, 5, 6],
    ],
  },
  // 足らない場合は、最後の配列は余りとなる
  {
    inputArray: [1, 2, 3, 4, 5],
    inputSize: 3,
    expectedOutput: [
      [1, 2, 3],
      [4, 5],
    ],
  },
])('配列が正しくapertureされる', ({ inputArray, inputSize, expectedOutput }) => {
  const actualOutput = aperture(inputArray, inputSize);

  expect(actualOutput).toStrictEqual(expectedOutput);
});

正しく動いてそうですね!! jestの実行結果 - 成功例 ところが、まだコーナーケースとして次のようなものが考えられます

test.each([
  // 要素に0を含む
  {
    inputArray: [0, 1, 2, 3, 4, 5],
    inputSize: 3,
    expectedOutput: [
      [0, 1, 2],
      [3, 4, 5],
    ],
  },
  // sizeが0
  {
    inputArray: [0, 1, 2, 3, 4, 5],
    inputSize: 0,
    expectedOutput: [
      [0, 1, 2],
      [3, 4, 5],
    ],
  },
])('配列が正しくapertureされる', ({ inputArray, inputSize, expectedOutput }) => {
  const actualOutput = aperture(inputArray, inputSize);

  expect(actualOutput).toStrictEqual(expectedOutput);
});

jestの実行結果1

実は要素に0などのfalsyな値が含まれていると無視してしまうというバグがあり、その点をこのテストケースでは検証できていないのです。

こういったバグを掘り出すにはコーナーケースの勘が重要ですが、それには勘や経験で品質保証していく必要があり、結局属人性のあるスキルが必要になってきます。

fast-checkの使い方

TypeScript/JavaScriptでのProperty-based Testingの代表的なライブラリがfast-checkです2。fast-checkは単体で動作させるテストランナーではなく別のテストフレームワークと連携して使うことを想定しているため、今回はJestとの連携で実装してみます。

(余談ですが、Jestは内部実装のテストでfast-checkを利用しているそうです)

筆者はユニットテストが書かれたプロダクトコードに対してfast-checkでテストを書き直してみましたが、書き換えたコードのうち関数ベース30%くらいで何かしらのバグが埋まっていました。これらのバグの重要度については別途議論が必要ですが、それでもユニットテストが書かれているだけでは品質が完璧に担保されているわけではないことがわかりました。

ということで、fast-checkを利用してaperture関数のテストを書いてみます。

aperture関数の入力(引数)は2つあります。任意の型Tの配列arr: T[]と分割サイズsize: numberです。この2つの引数がどんな値であっても、aperture関数が正常に動けば、aperture関数は正常であると言って良いでしょう。

この入力の条件定義を、fast-checkではarbitrary3と言います。実際に、この2つの入力を引数に取るテストを書いてみましょう。

import { test, fc } from '@fast-check/jest';
import { aperture } from './aperture';

// 配列のarbitrary
const arrayArb = fc.array(fc.anything());
// 配列の分割サイズのarbitrary
const sizeArb = fc.integer();

test.prop([arrayArb, sizeArb])('配列が正しくapertureされる', (inputArray, inputSize) => {
  const output = aperture(inputArray, inputSize);

  // TODO: ここでoutputの性質についてチェックする
});

fast-checkがデフォルトで用意しているarbitraryを使い、2つの入力を定義しました。fast-checkが用意しているarbitraryについては公式のドキュメントをご覧ください。fc.anythingfc.arrayを使い、配列のarbitrary arrayArbを定義しました。また配列の分割サイズはfc.integerをそのまま使うことにしました。

これらのarbitraryを使うためには、test.prop関数を使ってarbitraryの使用を宣言し、その後にテストを記述します。すると、テストの関数に引数が渡るので、それを利用してテストを書くことが可能となっています。fast-checkでは、arbitaryの宣言をもとに、デフォルトで100通りのテストを繰り返します。

この引数を使い、aperture関数のテストを書いてみます。

...

test.prop([arrayArb, sizeArb])('配列が正しくapertureされる', (inputArray, inputSize) => {
  const output = aperture(inputArray, inputSize);

  // 性質1: outputを連結させるとinputArrayと等しくなる
  expect(output.reduce((prev, curr) => [...prev, ...curr], [])).toStrictEqual(inputArray);

  // 性質2: output内の最後以外の行の長さはinputSizeと等しい
  output.slice(0, -1).forEach((row) => {
    expect(row).toHaveLength(inputSize);
  });

  // 性質3: output内の最後の行の長さはinputSize以下
  expect(output[output.length - 1]?.length).toBeLessThanOrEqual(inputSize);
}

このテストを組み合わせて、最終的にこのようなテストができあがります。

import { test, fc } from '@fast-check/jest';
import { aperture } from './aperture';

// 配列のarbitrary
const arrayArb = fc.array(fc.anything());
// 配列の分割サイズのarbitrary
const sizeArb = fc.integer();

test.prop([arrayArb, sizeArb])('配列が正しくapertureされる', (inputArray, inputSize) => {
  const output = aperture(inputArray, inputSize);

  // 性質1: outputを連結させるとinputArrayと等しくなる
  expect(output.reduce((prev, curr) => [...prev, ...curr], [])).toStrictEqual(inputArray);

  // 性質2: output内の最後以外の行の長さはinputSizeと等しい
  output.slice(0, -1).forEach((row) => {
    expect(row).toHaveLength(inputSize);
  });

  // 性質3: output内の最後の行の長さはinputSize以下
  expect(output[output.length - 1]?.length).toBeLessThanOrEqual(inputSize);
});

というわけでテストができたので実際に実行してみます。

jestの実行結果2

エラーが出ました。空配列が入力されたときは性質3の部分でエラーが出てしまうようです。そこでarrayArbを修正します。arbitraryは.filterというメンバー関数があり、arbitraryに追加で条件を指定できます。

// 配列のarbitrary
const arrayArb = fc.array(fc.anything()).filter((a) => a.length > 0);

また、別に空配列専用のテストを書いておきます。

// 配列の分割サイズのarbitrary
const sizeArb = fc.integer().filter((n) => n > 0);

test.prop([arrayArb, sizeArb])('配列が正しくapertureされる', (inputArray, inputSize) => {
  ...
});

test.prop([sizeArb])('空配列が正しくapertureされる', (inputSize) => {
  const output = aperture([], inputSize);
  expect(output).toStrictEqual([]);
});

これでもう一度実行してみます。

jestの実行結果3

再びエラーが出ました。今度はsizeが0のときに例外がthrowされるということのようです。aperture関数にバリデーションを書いておきます。

export function aperture<A>(arr: A[], size: number): A[][] {
  if (size <= 0) {
    throw new RangeError(`sizeは1以上にしてください: size: ${size}`);
  }
  ...

また、テスト側も修正し、sizeが1以上の場合と0以下の場合でテストを分けましょう。

// 配列の分割サイズのarbitrary
const sizeArb = fc.integer().filter((n) => n > 0);

test.prop([arrayArb, sizeArb])('配列が正しくapertureされる', (inputArray, inputSize) => {
  ...
});

test.prop([sizeArb])('空配列が正しくapertureされる', (inputSize) => {
  ...
});

// 0以下の配列の分割サイズのarbitrary
const invalidSizeArb = fc.integer().filter((n) => n <= 0);
test.prop([arrayArb, invalidSizeArb])(
  '0以下のサイズが指定されたときにエラーがthrowされる',
  (inputArray, inputSize) => {
    expect(() => {
      aperture(inputArray, inputSize);
    }).toThrow();
  },
);

テストを実行してみます。

jestの実行結果4

配列にfalsyな値が入っているときに発生する不具合が検知されたようです。こちらは修正しましょう。「取得した該当要素がfalsyだったら要素がないと認識する」というロジックになっているので、こちらを修正します。

export function aperture<A>(arr: A[], size: number): A[][] {
  if (size <= 0) {
    throw new RangeError(`sizeは1以上にしてください: size: ${size}`);
  }

  // 何分割することになるのかを算出
  const n = Math.ceil(arr.length / size);
  // 結果として返す二次元配列を用意する。とりあえず、空配列を並べる
  const result = Array.from({ length: n }).map(() => []) as A[][];

  // 対象の行のindex
  let rowIndex = 0;
  // 元の配列の各要素を対象にforEachする
  arr.forEach((elem) => {
    // 対象行にelemをpushする
    result[rowIndex]?.push(elem);

    // 対象行がsizeよりも大きくなったらrowIndexをインクリメントする
    if ((result[rowIndex]?.length || 0) >= size) rowIndex += 1;
  });

  return result;
}

こちらでテストを実行すると……成功しました!

jestの実行結果5

このような形で、関数に対し不具合が起きるケースをひとつひとつ潰していくことができます。

ちなみに、関数自体の実行速度にもよりますが、100回の試行を行った上でも2.1s程度の実行速度でテストを行うことができるので、テストにかかる時間はそれほど増えていないこともわかります。

テストコード作成上のTips

実際にプロダクト上のユニットテストをProperty-based Testingで実装してみてわかったことがいくつかあるのでメモしておきます。

実装のロジックと距離を取る

既存のTable-driven Testingをfast-checkで書き直そうとすると、どうしても「引数」と「正とする戻り値」のペアで構築しがちで、引数をarbitraryで表現しても、戻り値をその引数から計算する形にしてしまいがちです。

そうすると、結局プロダクトコードの本関数を呼び出して正とする戻り値を生成してしまう問題が起きることがあります。そうせず戻り値生成をテストコードに書くとしても、同じ意味合いのコードを書いてしまう上、プロダクトコードを眺めたせいで実装の詳細に幾分か引っ張られたロジックを書いてしまいバグも移植してしまう、ということになります。

// aperture関数に参照透過性がある限りエラーが起きないテスト
test.prop([arrayArb, sizeArb])('配列が正しくapertureされる', (inputArray, inputSize) => {
  const output = aperture(inputArray, inputSize);
  const expectedOutput = aperture(inputArray, inputSize);
  expect(output).toStrictEqual(expectedOutput)
});

そのため、発想を転換する必要があります。戻り値が「正とする戻り値」と完全一致するかをチェックするのではなく、戻り値の性質を検証するようにします。

test.prop([arrayArb, sizeArb])('配列が正しくapertureされる', (inputArray, inputSize) => {
  const output = aperture(inputArray, inputSize);

  // 性質: outputを連結させるとinputArrayと等しくなる
  expect(output.reduce((prev, curr) => [...prev, ...curr], [])).toStrictEqual(inputArray);
});

例をそのまま挙げてしまうTable-driven Testingとは違い、Property-based Testingでは「プロダクトコードと距離を取ってテストコードを書く」ということを意識する必要があります。

想定していない引数について

とくにnumber型で顕著ですが、TypeScriptでは明示できない引数制限があったりします。このような引数制限は暗黙知であることがほとんどで、たとえばaperture関数のsizeに負の値や小数やNaNが入らないことは明白です。

上記で作ったテストではfc.integerの範囲のみで引数を指定しています。負の整数や0はエラーが出るようになっていますが、1.5などの小数やNaNとかInfinityなどに対しては振る舞いがどうなるか保証できていません。

かといって、小数や特殊値の引数に対して例外を投げるロジックをaperture関数内に記述し小数や特殊値のテストが書けるようにするというのも、テストに振り回されている感があります。不特定多数が使うオープンソースライブラリの関数であれば特殊値での例外が必要ですが、プロダクトコードでは「対応しない」でも良いのではないかと思います。

テストは仕様を明示するものでもあります。fast-checkではarbitraryの定義によってかなり細かく引数範囲を明示することができるので、「引数の範囲の仕様はテストのハッピーパステストを見てください」という誘導をすることもできます。

テストケース内で条件分岐を書かない

エラーになるケースを記述する際は、条件分岐せずに別のテストで書くことをオススメします。

// 悪い例

// 配列のarbitrary
const arrayArb = fc.array(fc.anything());
// 配列の分割サイズのarbitrary
const sizeArb = fc.integer();

test.prop([arrayArb, sizeArb])('配列が正しくapertureされる', (inputArray, inputSize) => {
  // 性質0: inputSizeが0以下であればエラーが起きる
  if (inputSize <= 0) {
    expect(() => {
      aperture(inputArray, inputSize);
    }).toThrow();
  }

  const output = aperture(inputArray, inputSize);
    
  // 性質1: outputを連結させるとinputArrayと等しくなる
  expect(output.reduce((prev, curr) => [...prev, ...curr], [])).toStrictEqual(inputArray);

  output.forEach((row, i) => {
    if (i < output.length - 1) {
      // 性質2: output内の最後以外の行の長さはinputSizeと等しい
      expect(row).toHaveLength(inputSize);
    } else {
      // 性質3: output内の最後の行の長さはinputSize以下
      expect(row.length).toBeLessThanOrEqual(inputSize);
    }
  });
});
// 良い例

// 配列の分割サイズのarbitrary
const sizeArb = fc.integer().filter((n) => n > 0);

test.prop([arrayArb, sizeArb])('配列が正しくapertureされる', (inputArray, inputSize) => {
  ...
}

// 0以下の配列の分割サイズのarbitrary
const invalidSizeArb = fc.integer().filter((n) => n <= 0);
test.prop([arrayArb, invalidSizeArb])(
  '0以下のサイズが指定されたときにエラーがthrowされる',
  (inputArray, inputSize) => {
    expect(() => {
      aperture(inputArray, inputSize);
    }).toThrow();
  },
);

そもそもテスト内に条件分岐が入ってしまっているので読みにくくなるという話でもあります。

しかし、それ以上に試行回数が半減してしまうという問題があります。fast-checkはデフォルトで100回試行しますが、負の値は別のテストを行うとするとうち半分の約50回が例外系の試行に費やされ、結果としてハッピーパスは約50回しかテストされないことになります。

これに小数(fast-checkではfc.doubleで小数を生成できます)が加わると、fc.doubleは約50%の確率で小数を生成するため、正の整数でテストできるハッピーパスの機会は約25回に落ちてしまいます。

ハッピーパスと例外系でarbitraryを分け、別々にテストケースを書けば、それぞれ意味のあるテストが100回試行されるようになります。

まとめ

fast-checkでProperty-based Testingをやってみました。ランダムで入力を生成してテストを行うことで、テスト対象のコードの信頼性を上げることができます。また、テストすると同時により厳密に仕様を記述することにも使えます。


  1. 名前の雰囲気を考えると、このようなランダム生成だけではなく網羅検査することもProperty-based Testingに含めても良さそうですが、現状QuickCheckの影響を受けたテストをProperty-based Testingと呼ぶことが多いようです。
  2. 類似ライブラリにjsverifytestcheck-jsがありますが、年単位で開発が止まっている様子なのでfast-checkを採用しました
  3. 「任意の〜」という意味の形容詞で、任意定数のことをarbitrary constantなどと表現します。残念ながらfast-checkでの用法には固定した日本語訳がないようなので、本記事ではarbitraryもしくはarbと表現しています。