TECHSCORE BLOG

クラウドCRMを提供するシナジーマーケティングのエンジニアブログです。

Property Based Testing

Haskell で実装された QuickCheck をはじまりとする、Property Based Testing を紹介します。

意外と知られていないようなので、記事を書いてみることにしました。

鰺坂 誠也(アジサカ セイヤ)
猫に脳を食われた人間


Property Based Testing とは

プロダクトの品質をあげるためにはテストは必須です。 しかし、考えられるテストケースが多すぎる場合などは、漏れも出やすくなります。 そういったときに有用なのが Property Based Testing です。

Property Based Testing では、関数の引数に対する返り値の特性 (Property) に着目します。 テスト時には、引数にランダムな値を与え、返り値がそれらの特性を満たすかをチェックします。

このランダムなチェックを大量に実施することで、考慮されていなかったケースを暴きだそうというわけです。

TypeScript でやってみる

Java の jqwikGo の gopterPython の hypothesisなど、各言語でライブラリがあります。 今回は TypeScript の fast-check を使います。

$ yarn init -y .
$ yarn add --dev @types/jest fast-check jest ts-jest typescript
$ yarn ts-jest config:init

テスト対象としては、次のような月末判定の関数を考えてみます。

export function isEndOfMonth(date: Date): boolean {
  switch (date.getMonth() + 1)  {
    case 2:
      return date.getDate() === 28;
    case 4:
    case 6:
    case 9:
    case 11:
      return date.getDate() === 30;
    default:
      return date.getDate() === 31;
  }
}

通常のテストであれば、テストケース用の具体的な日付を引数として考えるところです。

しかし、今回は月末の翌日は 1 日(月初)という特性 (Property) を記述することにします。

import fc from 'fast-check';
import { addDays } from 'date-fns';

const testDate = fc.date({
  min: new Date('1900-01-01T00:00:00'),
  max: new Date('3000-01-01T00:00:00'),
});

describe('月末の判定 NG', () => {
  it('月末の次の日は、月初(1日)', () => {
    fc.assert(
      fc.property(testDate, (date: Date) => {
        // 実際の関数の返り値
        const actual = isEndOfMonth(date);
        const expect = addDays(date, 1).getDate() === 1;
        return  actual === expect;
      }),
      { numRuns: 10000 }
    );
  });
});
  • date-fnsaddDays で次の日を求め、1 日(月初)であるか判定しています。
  • testDate で、入力とする日付の範囲を定義しています。
  • numRuns オプションで、試行回数を多めにしています。

このテストを実際に動かしてみます。

$ export TZ=utc
$ yarn jest

すると…

FAIL  ./index.test.ts
  月末の判定
    ✕ 月末の次の日は、月初(1日) (15 ms)

  ● 月末の判定 › 月末の次の日は、月初(1日)

Property failed after 21 tests
{ seed: 1725421630, path: "1054:17:2:0:1:1:0:5:0:1:0:0:2:0", endOnFailure: true }
Counterexample: [new Date("2036-02-29T00:00:00.000Z")]
Shrunk 13 time(s)
Got error: Property failed by returning false

エラーになってしまいました。

Counterexample: という部分が、失敗したときの具体的な引数値です。 これを見ると 2468-02-28T00:00:00.000Z となっています。 02-29 という日付が怪しいですね。閏年です。

つまり、閏年が考慮されていない実装になっていたということが分ります。 ランダムな入力の一つが、エッジケース(?)をチェックしてくれたということです。

では、閏年を考慮した関数にしてみましょう。

export function isEndOfMonth(date: Date): boolean {
  switch (date.getMonth() + 1)  {
    case 2:
      const year = date.getFullYear();
      const leapYear = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
      return date.getDate() === (leapYear ? 29 : 28);
    case 4:
    case 6:
    case 9:
    case 11:
      return date.getDate() === 30;
    default:
      return date.getDate() === 31;
  }
}

これをテストすると…

PASS  ./index.test.ts
  月末の判定 OK
    ✓ 月末の次の日は、月初(1日) (34 ms)

成功しました!

あとは、失敗した引数を使った通常の単体テストを追加しておくと、ベストでしょう。

まとめ

Pros

  • 全てのパターンの検証が難しい場合に便利
  • 失敗した時の値をログに出してくれる
  • ライブラリを使うと、値の生成器が色々用意されているので便利

Cons

  • 運次第なところはある
  • 通常、このテストだけで済むことはない

ちなみに、Property Based Testing の初出は、ここ のようです。 また、従来(?)のテストは、Example Based Testing と呼ぶようですが、由来はよくわかりません。

オチ

実際のところ、月末判定はライブラリなしで↓のようにできます。

    function isEndOfMonth(date: Date): boolean {
      return date.getDate() === new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate();
    }

シナジーマーケティング株式会社では一緒に働く仲間を募集しています。