Haskell で実装された QuickCheck をはじまりとする、Property Based Testing を紹介します。
意外と知られていないようなので、記事を書いてみることにしました。
Property Based Testing とは
プロダクトの品質をあげるためにはテストは必須です。 しかし、考えられるテストケースが多すぎる場合などは、漏れも出やすくなります。 そういったときに有用なのが Property Based Testing です。
Property Based Testing では、関数の引数に対する返り値の特性 (Property) に着目します。 テスト時には、引数にランダムな値を与え、返り値がそれらの特性を満たすかをチェックします。
このランダムなチェックを大量に実施することで、考慮されていなかったケースを暴きだそうというわけです。
TypeScript でやってみる
Java の jqwik、Go の gopter、Python の 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-fns の
addDays
で次の日を求め、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(); }