F# 用の Property Based Testing フレームワーク「Testexp」を作った

**決定論的**で**カスタム可能**な(= **再現性**と**自由度**が高い)ランダム値ジェネレータと、それを使った property based testing (以下PBT) のフレームワーク「Testexp」を作りました[^0]。 .NET 6 と .NET Standard 2.1 で動きます。

https://github.com/mino-ri/Testexp

https://www.nuget.org/packages/Minori.Testexp

[^0]: F#のPBTフレームワークはFsCheckなどが有名ですが、コードの見た目が好きじゃないことや、決定論的な擬似ランダム値を使いたかったことから自作することにしました。

:::note info
**Property based testing (PBT)** とは、テスト対象の関数に**具体的な引数**を与えてテストを行うのではなく、**引数の性質や条件**を指定し、そこからランダムに生成された引数を使ってテストを行う手法です。
:::

:::note info
**決定論的**とは、(この記事では)同じコードを書いて同じ環境で動かせば同じ結果が得られることです。
:::

# サンプルコード

テストは `testing` コンピュテーション式を使って記述します。`ArgGen.xxxx` でランダムな値を生成し、 `test` でテスト関数を実行、 `Assert.xxxx` で結果を検証します[^1]。
Testexp は Visual Studio との連携機能などは持たないので、他のユニットテストフレームワーク(下記コードは Xunit)と組み合わせて使います。

[^1]: `test` と `Assert` の使用は必須ではなく、引数の自動生成機能だけを使うこともできるように設計してあります。

```fsharp
module Test
open Testexp
open Xunit

// テスト対象の関数
let plus (x: int) (y: int) = x + y

[<Fact>]
let ``正の数の和はどちらよりも大きい`` () =
    testing {
        // ランダムな引数を生成
        let! x = ArgGen.intRange 1 10000
        let! y = ArgGen.intRange 1 10000
        // テスト関数を実行
        let! result = test plus (x, y)
        // 結果を検証
        Assert.greaterThan x result
        Assert.greaterThan y result
    }
```

このコードで `x` , `y` それぞれに1～9999のランダムな整数が64回ずつ渡され、64² = 4096 回のテストが行われます。

# 引数ジェネレータ

Testexp のコンセプトである**決定論的**で**カスタム可能**なランダム値を生成する機構が引数ジェネレータです。引数ジェネレータは `IArgumentGenerator<'T>` 型[^2]の値であり、 `ArgGen` モジュールを使って生成します。
PBTにおける「性質や条件からランダムな引数を生成する機能」を担当します。

[^2]: みんな大好きモナド則を満たします。

## カスタム可能な引数ジェネレータ

引数ジェネレータは `ArgGen` モジュールの関数を使って生成します。範囲内のランダムな数値を生成する `ArgGen.intRange` や、ランダムな文字列を生成する `ArgGen.string` などのほか、具体的な値を指定する `ArgGen.forEach` も用意してあります。

```fsharp
testing {
    // 0 ≦ x < 100 のランダムな整数
    let! randomInt = ArgGen.intRange 0 100
    // 0～9文字のランダムなASCII文字で構成される文字列 (引数も引数ジェネレータ!)
    let! randomString = ArgGen.string ArgGen.asciiChar (ArgGen.intRange 0 10)
    // もはやランダムではないが、指定値すべてを1回ずつ生成する
    let! targeted = ArgGen.forEach [| "りんご"; "バナナ"; "ゴリラ" |]
}
```

さらに、上記コードの `ArgGen.string` のように**引数ジェネレータを組み合わせて新たな引数ジェネレータを作ったり**、引数ジェネレータをおなじみの `map` や `filter` といった関数で加工したりできます。例えば、次のようなコードで「奇数の2乗数を生成する引数ジェネレータ」を定義することができます。

```fsharp
let oddSquare min max =
    ArgGen.intRange min max
    |> ArgGen.filter (fun x -> x % 2 = 1)
    |> ArgGen.map (fun x -> x * x)
```

`argGen` コンピュテーション式を使うと、より複雑な引数ジェネレータを作ることもできます。
これらの機能を組み合わせるとかなり自由に引数ジェネレータを作ることができるので、**プロジェクト固有の型に対する引数ジェネレータ**なんかを作って使いまわせるところが嬉しいわけです。

```fsharp
// 例: 決められたフォーマットの顧客IDがあったとして、それを生成する
let customerId =
    argGen {
        let! upper = ArgGen.intRange 1 10000
        and! lower = ArgGen.intRange 1 10000
        return $"C-%04d{upper}-%04d{lower}"
    }
```

## 決定論的な引数ジェネレータ

Testexp の引数ジェネレータは**決定論的**です。つまり、テストコードを書き換えない限り**毎回同じ擬似ランダム値が生成されます**。これにはメリットとデメリットがあります。

### メリット

* 一度成功したテストは永続的に成功する
* たまに失敗するテストがある場合、ロジック内に確率で発生するバグがあることが**確定する**

メリットは一言で言えば**再現性が高い**ことです。数多のエンジニアを苦しめてきた「たまにテストが失敗する」現象から解放されます。
特定の引数でのみ発生するバグは**毎回テストを失敗させる**ので「たまに」ではなくなりますし、仮にたまに失敗するテストがある場合、**テスト引数は毎回同じ**なのでロジック側の問題であることが分かります[^4]。

[^4]: これが起こるということは純粋関数ではないということです。関数型言語を使うなら重要なロジックほど純粋関数にするのが吉です。

### デメリット

* 擬似ランダム値に含まれない引数で発生するバグを見つけられない

逆に毎回同じ引数であるということは直接的なデメリットでもあり、いわゆる**テストケース漏れ**が発生します。うまく再現性との折り合いをつけて解消したいところです。

# その他

* 組み込みの `test` 関数は、カリー化スタイル・タプルスタイルどちらの関数にも対応しています。
* 組み込みの `test` と `Assert.xxxx` を使ってテストを実行した場合、テスト失敗時に引数と戻り値が(例外のメッセージとして)表示されます。