🐒

fast-check で Property Based Testing を試してみる

2021/10/17に公開

Property Based Testing ライブラリの fast-check を試してみたのでまとめます。

Property Based Testingとは?

Property Based Testing は、Haskell のライブラリ QuickCheck からはじまったテストスタイルで、定義された条件に合わせて自動生成された膨大な値に対してテストを行う手法です。Property Based Testing のライブラリに入力値の条件を渡すと、その条件から複数のテストケースを自動生成し実行してくれます。

短いコードで膨大なテストケースを検証できる点、テストケースが開発者に依存せず意図しないエッジケースをテストできる点がメリットとしてあります。

(参考)

https://kotest.io/docs/proptest/property-based-testing.html

https://medium.com/criteo-engineering/introduction-to-property-based-testing-f5236229d237

fast-checkとは?

fast-check は JavaScript で Property Based Testing を実現するためのライブラリです。

https://github.com/dubzzz/fast-check

Jest や mocha などのテストティングフレームと併用して利用します。基本的な書き方はこちらです。

it('sample test', () => {
  fc.assert( // Property Based Testing のランナー
    fc.property(
      fc.nat(), // 入力値の条件。 nat()は正の整数
      num => { // 実際にexpectを実行するコールバック関数。引数には入力値の条件に沿って生成されたランダムな値が入る。標準で100回実行される。
        expect(testMethod(num)).toBe('FizzBuzz')
      }
    )
  )
})

入力値の条件は Number, Boolean、String などの基本データ型から、JSON、Date、uuid 等のカスタムなデータ型まで柔軟に指定できます。

以下はidnameagebirthdayのキーを持つ構造体を生成する場合の例です。

fc.record({
  id: fc.uuidV(4),
  name: fc.constantFrom('Paul', 'Luis', 'Jane', 'Karen'),
  age: fc.nat(99),
  birthday: fc.date({min: new Date("1970-01-01T00:00:00.000Z"), max: new Date("2100-12-31T23:59:59.999Z")})
}

// 出力例
// • {"id":"00000010-e2be-4b98-8d3a-944affffffe2","age":4,"birthday":new Date("2100-12-31T23:59:59.959Z")}
// • {"id":"00000001-0005-4000-bfff-fff03ec646bf","age":48,"birthday":new Date("2069-12-20T11:27:18.998Z")}
// • {"id":"00000003-ffed-4fff-bfff-fff400000012","name":"Jane","birthday":new Date("2028-02-06T17:18:26.370Z")}
// • {"id":"fa5630bc-000f-4000-8000-001600000018","age":0,"birthday":new Date("1970-01-01T00:00:00.039Z")}
// • {"id":"00000018-ffee-4fff-8a22-b8770000001b","age":93}
// • …

指定出来る条件の詳細はドキュメントを確認ください。

https://github.com/dubzzz/fast-check/blob/main/documentation/Arbitraries.md#combinators

他にも、テスト失敗時のテストケースの再現機能など、Property Based Testing に必要な機能が網羅されています。

FizzBuzz で Property Based Testing を試してみる

定義を読んでもいまいち便利さが分からなかったので、FizzBuzz で試してみました。

FizzBuzz の条件は色々ありますが、今回は正の整数を与えた場合に以下の条件で値を返す関数とします。

  • 3 の倍数でかつ 5 の倍数の場合はFizzBuzz
  • 3 の倍数の場合はFizz
  • 5 の倍数の場合はBuzz
  • それ以外は与えられた数値を返す

実装はこちらです。

type FizzBuzzReturn = "FizzBuzz" | "Fizz" | "Buzz" | number

export const fizzBuzz = (num: number): FizzBuzzReturn => {
  if(num % 3 === 0 && num % 5 === 0) {
    return "FizzBuzz"
  }
  if(num % 3 === 0) {
    return "Fizz"
  }
  if(num % 5 === 0) {
    return "Buzz"
  }
  return num
}

この関数を愚直にテストした場合のコードがこちらです。

describe('fizzBuzz', () => {
  test('3の倍数でかつ5の倍数の場合はFizzBuzzと返す', () => {
    expect(fizzBuzz(15)).toBe('FizzBuzz')
    expect(fizzBuzz(90)).toBe('FizzBuzz')
    expect(fizzBuzz(180)).toBe('FizzBuzz')
  })

  test('3の倍数でかつ5の倍数ではない場合はFizzと返す', () => {
    expect(fizzBuzz(3)).toBe('Fizz')
    expect(fizzBuzz(9)).toBe('Fizz')
    expect(fizzBuzz(63)).toBe('Fizz')
  })

  test('5の倍数でかつ3の倍数ではない場合はBuzzと返す', () => {
    expect(fizzBuzz(5)).toBe('Buzz')
    expect(fizzBuzz(35)).toBe('Buzz')
    expect(fizzBuzz(80)).toBe('Buzz')
  })

  test('3の倍数でも5の倍数でもない場合は引数の数値をそのまま返す', () => {
    expect(fizzBuzz(1)).toBe(1)
    expect(fizzBuzz(26)).toBe(26)
    expect(fizzBuzz(98)).toBe(98)
  })
})

Jest のtest.eachを使って Parameterized Test 的に書けばもっと効率的に書けそうですが、それでも検証パターンには限界があります。たとえば、10,000,000 等大きな数を与えた場合に実はバグがあるかも知れませんが、検証しようとするには無理があります。

これを fast-check を使って Property Based Testing で書き直すとこちらです。

describe('fizzBuzz', () => {
  test('3の倍数でかつ5の倍数の場合はFizzBuzzと返す', () => {
    fc.assert(
      fc.property(fc.nat(), num => {
        fc.pre(num % 3 === 0)
        fc.pre(num % 5 === 0)
        expect(fizzBuzz(num)).toBe('FizzBuzz')
      })
    )
  })

  test('3の倍数でかつ5の倍数ではない場合はFizzと返す', () => {
    fc.assert(
      fc.property(fc.nat(), num => {
        fc.pre(num % 3 === 0)
        fc.pre(num % 5 !== 0)
        expect(fizzBuzz(num)).toBe('Fizz')
      })
    )
  })

  test('5の倍数でかつ3の倍数ではない場合はBuzzと返す', () => {
    fc.assert(
      fc.property(fc.nat(), num => {
        fc.pre(num % 5 === 0)
        fc.pre(num % 3 !== 0)
        expect(fizzBuzz(num)).toBe('Buzz')
      })
    )
  })

  test('3の倍数でも5の倍数でもない場合は引数の数値をそのまま返す', () => {
    fc.assert(
      fc.property(fc.nat(), num => {
        fc.pre(num % 3 !== 0)
        fc.pre(num % 5 !== 0)
        expect(fizzBuzz(num)).toBe(num)
      })
    )
  })
})

fc.pre()は引数に渡した条件式が正の場合に検証をスキップする関数です。

fast-check では、標準で各アサートに対して 100 回のテストを行うので、前のテストに比べてかなりバグを見つけやすくなります。

実際にconsole.log()で引数に与えられる数値を出力してみると以下のとおりでした。
かなり幅広い値で検証しているのがわかると思います。

  test('3の倍数でかつ5の倍数の場合はFizzBuzzと返す', () => {
    const args: number[] = []
    fc.assert(
      fc.property(fc.nat(), num => {
        fc.pre(num % 3 === 0)
        fc.pre(num % 5 === 0)
        args.push(num)
        expect(fizzBuzz(num)).toBe('FizzBuzz')
      })
    )
    console.log(args)
  })
    // 出力 ↓
    //  [
    //             15,  5154547515, 14586534285, 22067216670, 11463699000,
    //             15,         150,         405, 24544203645, 15800293260,
    //    32212254585, 25782162600, 19507501455, 13932433035, 32212254465,
    //            240,  5894415900,  2475945960,  7330825875, 32212254630,
    //    29224485450,         240, 29473233660,         390,  8133294240,
    //    30365121465, 32212254420, 10316546130, 15568267905, 14022727380,
    //            450, 28486437015,          90, 16389325920,  1924987605,
    //    31884552180,         450,         330, 32212254345,   817391010,
    //    22209798855, 32212254330, 20007343365, 24800565105,   633917550,
    //    27951496950,  8008404135,  2756876070,  7432837200, 16562011815,
    //    13111531425,         165,   372921390, 28326375945, 30538334280,
    //    25317536220,         180, 18038638425, 32212254675,         180,
    //    21342528135, 32212254585, 13922248920, 11444807085, 31396742475,
    //    32212254705, 21541350405, 26881353345, 30568548300, 32212254705,
    //    32212254285,  7907215245, 18255486015,  8624909925,  8417630310,
    //             90,  4360582020,         300, 13438857525,         120,
    //    19155723930, 32212254555,  8267478945, 32212254270,         285,
    //    16459253745, 20202074865,  1353827010,  6384282060, 25864471440,
    //    15372941925, 11600763255,   928093095, 32212254465,  8311722375,
    //    18542294235, 23017659225,         240, 21950595210,  4564644165
    //  ]

おわりに

以上、Property Based Testing & fast-check の簡単な紹介でした。
Property Based Testing は今回初めて試してみたのですが、予想以上に便利に使えそうなので、色々今後も試していきたいです。

Discussion