プロパティベーステストで、例では届かない入力を試す ── FsCheckとxUnitによるC#の実践

· · C#, .NET, テスト, プロパティベーステスト, FsCheck, xUnit, AI, 品質

Xでポスト はてなブックマーク
更新履歴(初版のみ・2026年11月01日公開)
初版公開
この記事を引用する(DOI: 10.5281/zenodo.23852485)

この記事はZenodoにアーカイブされています。常に最新版へ解決されるDOIと、いま表示している版に固定されたDOIの両方を下に示します。

小村 豪(2026)「プロパティベーステストで、例では届かない入力を試す ── FsCheckとxUnitによるC#の実践」合同会社小村ソフト. https://doi.org/10.5281/zenodo.23852485 https://comcomponent.com/blog/property-based-testing-fscheck/

DOI(最新版)
10.5281/zenodo.23852485
DOI(この版)
10.5281/zenodo.23852486

AIにコードとテストを書かせると、テストは「この入力ならこの結果になる」という例を並べた形になることがあります。例を選んだのはテストを書いた側なので、実装を書いたときに見落とした入力は、テストの例からも抜けていることがあります。

プロパティベーステストは例を並べる代わりに「どの入力でも成り立つはずの性質(プロパティ)」を書き、入力はツールに大量に作らせる方法です。性質が成り立たない入力が見つかると、ツールはその入力をできるだけ小さく縮めて報告します。

この記事では C# で FsCheck と xUnit を使ってプロパティベーステストを書く方法を、バグの入った小さな関数を例に説明します。コードは FsCheck 3.6.0 と FsCheck.Xunit 3.6.0 の資料と API に合わせて書いたもので、例は .NET 8 SDK と FsCheck.Xunit 3.6.0 で実行し、通るテストと失敗するテストが本文のとおりになることを確かめています。FsCheck 2.x の資料やサンプルとは書き方が違うところがあるので、2.x を使っている場合は、そのバージョンの資料で確かめてください。

この記事は「AIが書いたプログラムの品質を上げる」シリーズの4本目です。AIの書いたテストの弱点は「AIが書いたテストを信用してよいか」、実行されていないコードの見つけ方は「カバレッジでテストされていないコードを見つける」、確かめていないコードの見つけ方は「ミューテーションテストでテストをテストする」で扱っています。

例を並べるテストが確かめる範囲

例を並べるテストは選んだ入力について期待した結果が出るかを確かめます。選ばなかった入力については何も言いません。プロパティベーステストは入力を選ぶ作業をツールに任せ、テストを書く側は結果が満たすべき性質を書きます。

例を並べるテストとプロパティベーステストの違い例を並べるテストは書いた人が入力と期待値を選んで確かめ、プロパティベーステストはツールが入力を大量に作り、書いた性質が成り立つかを確かめて、成り立たなければ反例を縮めて報告することを示す。プロパティベーステスト成り立たない性質が成り立つかを見るツールが入力を大量に作る反例を縮めて報告例を並べるテスト期待値と比べる書いた人が入力を選ぶ

図1: 例を並べるテストは選んだ入力だけを確かめ、プロパティベーステストは作った入力で性質を確かめる。

Haskell 向けのテストツール QuickCheck の論文(2000年)は、関数が満たす性質を書き、その性質をランダムな入力で自動的に確かめる道具として QuickCheck を説明しています1。FsCheck も書いた性質がランダムに作った多数の入力で成り立つかを確かめ、失敗したときは最小の反例を表示する道具です2。

バグの入った分割の関数

例として、配列を決まった件数ごとのページに分ける関数を使います。AIが書いたと想定したコードで、ページ数の計算に誤りがあります。

public static class Paging
{
    public static List<T[]> Chunk<T>(IReadOnlyList<T> items, int size)
    {
        var pages = new List<T[]>();
        int pageCount = items.Count / size + 1; // 割り切れるときに1ページ多くなる
        for (int p = 0; p < pageCount; p++)
        {
            pages.Add(items.Skip(p * size).Take(size).ToArray());
        }
        return pages;
    }
}

items.Count / size + 1 は、件数が size で割り切れないときは正しいページ数になります。割り切れるとき(件数が0のときを含む)は1ページ多くなり、最後に空のページが付きます。

分割の関数のページ数の誤り件数がページの件数で割り切れないときは正しいページ数になり、割り切れるときや件数が0のときは1ページ多くなって空のページが付くことを示す。割り切れない(例: 10件、3件ずつ)割り切れる(例: 6件、0件)件数 ÷ size + 1 でページ数を出す件数は size で割り切れるか4ページで正しい1ページ多く、空のページが付く

図2: 件数がページの件数で割り切れるときだけ、空のページが1つ増える。

同じ想定で、例を並べたテストも書きます。10件を3件ずつに分ける例は正しく動くので、このテストは通ります。

public class ChunkExampleTests
{
    [Fact]
    public void Splits_ten_items_into_pages_of_three()
    {
        var pages = Paging.Chunk(Enumerable.Range(1, 10).ToArray(), 3);

        Assert.Equal(new[] { 3, 3, 3, 1 }, pages.Select(p => p.Length).ToArray());
    }
}

このテストが選んだ入力は、たまたま割り切れない件数でした。割り切れる件数を例に加えれば誤りは見つかりますが、加えるかどうかは書く側が思いつくかどうかで決まります。

図の実線は常に成り立つ関係、破線は条件付きの関係です(成立条件は詳細ページの各関係の説明に記載)。関係すべての一覧(全5件、根拠・確度つき)と主要概念の定義は知識マップ詳細ページにまとめています。データ: JSON-LD / Turtle

プロパティの書き方

プロパティは「どの入力でも成り立つはずのこと」です。業務のコードで使いやすい形には次のようなものがあります。

プロパティの主な形プロパティの主な形として、変換して戻すと元に戻る往復、出力が満たす不変条件、簡単な別の実装との比較、2回適用しても結果が変わらない冪等性の4つを示す。プロパティの主な形往復(戻すと元に戻る)出力の不変条件簡単な別の実装との比較冪等性(2回でも同じ)

図3: よく使うプロパティの形は、往復、不変条件、別の実装との比較、冪等性の4つ。

往復

符号化して復号すると元に戻る、書き出して読み込むと元に戻る、という性質です。分割の関数なら「分けたページをつなぎ直すと元の配列に戻る」と書けます。

出力の不変条件

出力がいつも満たすはずの条件です。分割の関数なら「どのページも1件以上 size 件以下」「ページ数は件数を size で割って切り上げた数」と書けます。

簡単な別の実装との比較

同じ結果を返すはずの、遅くても確かな別の実装と比べる性質です。テストの結果が正しいかを判定する仕組みはテストオラクルと呼ばれます3。プロパティベーステストでは、書いた性質がこの判定を自動で行います1。独立に作った別の実装をオラクルとして使う方法は、疑似オラクル(pseudo-oracle)として古くから知られています3。分割の関数なら、.NET 6 で加わった Enumerable.Chunk4 と結果を比べられます。

冪等性

2回適用しても1回と同じ結果になる性質です。文字列の正規化や、重複を取り除く処理などに使えます。

往復だけでは見つからない誤り

書いた性質が弱いと、誤りがあってもテストは通ります。分割の関数の誤りは空のページを1つ足すだけなので、ページをつなぎ直しても元の配列に戻ります。往復の性質は通り、不変条件の性質(どのページも1件以上)と別の実装との比較が失敗します。

分割の関数の誤りを見つける性質と見つけない性質空のページが1つ増える誤りに対し、ページをつなぎ直すと元に戻るという往復の性質は通ってしまい、どのページも1件以上という不変条件とEnumerable.Chunkとの比較は失敗して誤りを見つけることを示す。空のページが1つ増える誤り往復: つなぎ直すと元に戻る不変条件: どのページも1件以上Enumerable.Chunk との比較通る(見つけない)失敗する(見つける)失敗する(見つける)

図4: 往復の性質だけでは空のページを見つけられず、不変条件や別の実装との比較が見つける。

性質は1つで済ませず、形の違うものを組み合わせて書きます。

FsCheckとxUnitで書く

xUnit v2 のテストプロジェクトでは、FsCheck.Xunit のパッケージを入れます。FsCheck.Xunit 3.6.0 は FsCheck 3.6.0 と、xUnit の 2.4.1 以上 3.0 未満のパッケージに依存しています5。xUnit v3 を使う場合は、代わりに FsCheck.Xunit.v3 を入れます。名前空間は同じ FsCheck.Xunit です6。

dotnet add package FsCheck.Xunit --version 3.6.0

テストメソッドに [Property] 属性を付けると、FsCheck がメソッドの引数の値を作り、メソッドを繰り返し呼びます6。戻り値が false になる入力があれば、テストは失敗します。

using FsCheck;
using FsCheck.Fluent;
using FsCheck.Xunit;

public class ChunkProperties
{
    private static int ToSize(int rawSize) => Math.Abs(rawSize % 10) + 1; // 1〜10にそろえる

    [Property]
    public bool Concatenating_pages_restores_items(int[] items, int rawSize)
    {
        var pages = Paging.Chunk(items, ToSize(rawSize));
        return pages.SelectMany(page => page).SequenceEqual(items);
    }

    [Property]
    public bool Every_page_has_1_to_size_items(int[] items, int rawSize)
    {
        int size = ToSize(rawSize);
        return Paging.Chunk(items, size).All(page => page.Length >= 1 && page.Length <= size);
    }

    [Property]
    public bool Matches_Enumerable_Chunk(int[] items, int rawSize)
    {
        int size = ToSize(rawSize);
        var expected = items.Chunk(size).ToList();
        var actual = Paging.Chunk(items, size);
        return actual.Count == expected.Count
            && actual.Zip(expected).All(pair => pair.First.SequenceEqual(pair.Second));
    }
}

int[] と int の値は FsCheck に組み込まれた生成器が作ります。文字列のような参照型の生成器は既定では null を作りません7。ページの件数は rawSize から1〜10に寄せています。こうすると、組み込みの生成器と、後で説明する縮小の手順をそのまま使えます。

3つのうち、1つ目の往復の性質は通ります。2つ目と3つ目は、件数が割り切れる配列(空の配列を含む)が作られたときに失敗します。実際に実行すると、2つ目と3つ目はどちらも最初の入力の空の配列で失敗しました。

Property属性のテストが動く流れFsCheckが入力を作ってテストメソッドを呼び、性質が成り立てば次の入力に進み、決めた件数まで続けば成功とし、成り立たない入力が見つかればその入力を縮めてから失敗として報告する流れを示す。成り立つまだ達した成り立たない入力を作るテストメソッドを呼ぶ性質は成り立つか決めた件数に達したか成功入力を縮める失敗として報告

図5: FsCheck は入力を作っては確かめ、成り立たない入力が見つかると縮めてから報告する。

試す入力の件数は MaxTest で変えられます8。既定のまま成功すると、FsCheck は100件のテストに通ったと報告します9。

反例の縮小

性質が成り立たない入力が見つかると、FsCheck はその入力を縮めて、まだ失敗する最小の反例を探します9。この作業は縮小(shrinking)と呼ばれます。

ランダムに作った入力はたまたま長い配列や大きな数になっていることがあります。そのまま報告されると、どこに誤りがあるのかを読み取りにくくなります。縮めたあとの反例は小さいので、どこで誤りが起きるのかを追いやすくなります。分割の関数の誤りは件数が0のときにも起きるので、縮めていけば空の配列のような小さな反例にたどり着けます。

反例を縮める流れ失敗した入力から、より小さな候補を作って性質を確かめ直し、まだ失敗する候補があればそれを新しい反例として縮小を続け、どの候補も失敗しなくなったところで最小の反例として報告する流れを示す。失敗するどの候補も通る失敗した入力より小さな候補を作る候補でも失敗するかその候補を新しい反例にする最小の反例として報告

図6: 失敗し続ける限り、より小さな候補へ置き換えて反例を縮める。

FsCheck の資料は失敗したときの報告を次のような形で示しています9。1行目には何件目で失敗したか、何回縮めたかと、括弧に入った2つの数が出ます。「Original」が最初に見つかった入力、「Shrunk」が縮めたあとの入力です。

Falsifiable, after 3 tests (2 shrinks) (3469625241648231580,10457760963846162097).
Last step was invoked with size of 4 and seed of (18049846384052835765,8010492880672050043).
Replay directly at failing step with (18049846384052835765,8010492880672050043,4).
Original:
[0; 0; 0; 1]
Shrunk:
[0; 1]
with exception:
System.Exception: Expected true, got false.

この例は資料の F# の例で、この記事の分割の関数の出力ではありません。

入力を作るArbitrary

組み込みの生成器では業務の値をうまく作れないときは、自分で生成器を書きます。FsCheck では値を作る生成器と、値を縮める手順をまとめたものを Arbitrary と呼びます7。

public readonly record struct PageSize(int Value);

public class PagingArbitraries
{
    public static Arbitrary<PageSize> PageSizes() =>
        Gen.Choose(1, 10).Select(n => new PageSize(n)).ToArbitrary();
}

public class ChunkPropertiesWithArbitrary
{
    [Property(Arbitrary = new[] { typeof(PagingArbitraries) })]
    public bool Every_page_has_1_to_size_items(int[] items, PageSize size) =>
        Paging.Chunk(items, size.Value).All(page => page.Length >= 1 && page.Length <= size.Value);
}

Gen.Choose(1, 10) は1以上10以下の整数を作ります10。Select で業務の型に変え、ToArbitrary で Arbitrary にします。[Property] の Arbitrary に型を渡すと、そのテストメソッドでだけ、この型にある Arbitrary を返すメソッドが使われます6。

Arbitraryの中身と縮小の関係Arbitraryは値を作る生成器と値を縮める手順をまとめたもので、縮める手順を渡さずに生成器だけから作ると、その型の値は縮められないことを示す。Arbitrary生成器(値を作る)縮める手順渡さないと縮めない

図7: Arbitrary は生成器と縮める手順の組で、縮める手順がなければその値は縮まらない。

生成器だけから Arbitrary を作ると、縮める手順は付きません。FsCheck の資料は、縮める手順を指定しない場合は縮小をしないと説明しています7。上の PageSize は縮められないので、反例の PageSize は見つかったときの値のまま報告されます。ToArbitrary には縮める手順を渡す引数もあります10。前の節のように組み込みの型から値を導くほうが手軽なこともあります。

条件を付けた性質

入力に前提を付けたいときは、When で条件を書けます11。

[Property]
public Property Every_page_has_1_to_size_items_when_size_is_positive(int[] items, int size) =>
    Paging.Chunk(items, Math.Max(size, 1)).All(page => page.Length >= 1 && page.Length <= size)
        .When(size > 0);

条件を満たさない入力は捨てられます11。条件がめったに成り立たないと、捨てる入力が上限に達して、決めた件数を試す前にテストが終わります。このとき FsCheck は「Arguments exhausted after 97 tests.」のように、条件を満たして試せた件数を報告します11。条件で絞るより、条件を満たす値を最初から作る生成器を書くほうが、試せる件数を確保できます。

失敗を再現するReplay

プロパティベーステストの入力はランダムに作るので、同じテストをもう一度走らせても、同じ入力が作られるとは限りません。失敗を直したかを確かめるには、失敗したときと同じ入力を作らせる必要があります。

失敗の報告の1行目にある括弧の中の2つの数を、[Property] の Replay に渡すと、同じ入力を作り直せます。FsCheck.Xunit の説明では、出力された組をそのまま貼り付けてよく、3つ目の値として最初の大きさ(size)も指定できるとされています10。報告の「Replay directly at failing step with」の行に出る3つの数がこの形です。次の数は資料の出力例から取ったものです。

[Property(Replay = "3469625241648231580,10457760963846162097")]
public bool Every_page_has_1_to_size_items(int[] items, int rawSize)
{
    int size = Math.Abs(rawSize % 10) + 1;
    return Paging.Chunk(items, size).All(page => page.Length >= 1 && page.Length <= size);
}
Replayで失敗を再現する流れ失敗の報告に出た2つの数をReplayに貼って同じ入力で失敗を再現し、実装を直して通ることを確かめ、最後にReplayを外して通常のランダムな入力に戻す流れを示す。失敗の報告に出た2つの数Replay に貼る同じ入力で失敗を再現実装を直して通ることを確かめるReplay を外す

図8: 失敗の報告に出た数を Replay に渡して再現し、直したら Replay を外す。

直ったことを確かめたら Replay を外します。付けたままにすると、そのテストは毎回同じ入力しか試さなくなります。縮めたあとの反例は例を並べるテストとして別に残しておくと、同じ誤りが戻ったときにすぐ分かります。

プロパティベーステストの限界

プロパティベーステストが確かめるのは、書いた性質だけです。往復の性質で見たとおり、弱い性質しか書いていなければ、誤りがあっても通ります。どの性質を書けば誤りを見つけられるかは、仕様とコードを読んで考える必要があります。テストオラクルを自動で用意するのが難しいことは、テストの研究でも「テストオラクル問題」として扱われています3。

入力は作った分だけしか試しません。成功は「試した入力では成り立った」という意味で、すべての入力で正しいことの証明にはなりません。複雑な生成器は1件ごとに時間がかかるので、試す件数とテスト全体の時間の兼ね合いも考えます。

シリーズで扱う3つの方法の役割カバレッジは実行されていないコードを見つけ、ミューテーションテストは実行されているが結果を確かめていないコードを見つけ、プロパティベーステストは書いた人が思いつかなかった入力で性質を確かめることを示す。テストの弱いところを探すカバレッジミューテーションテストプロパティベーステスト実行されていないコード結果を確かめていないコード思いつかなかった入力

図9: カバレッジ、ミューテーションテスト、プロパティベーステストは、テストの別々の弱いところを探す。

カバレッジは実行されていないコードを、ミューテーションテストは実行されていても結果を確かめていないコードを見つけます。プロパティベーステストは書いた人が思いつかなかった入力を試します。AIが書いたコードとテストを受け入れる前に、この3つを組み合わせると、テストがどこまで確かめているかを別々の方向から調べられます。

関連記事

参考リンク

  1. Koen Claessen and John Hughes, QuickCheck: A Lightweight Tool for Random Testing of Haskell Programs. ICFP 2000。関数の性質を書き、ランダムな入力で自動的に確かめる道具としての説明。 ↩ ↩2

  2. FsCheck, FsCheck: Random Testing for .NET. 性質をランダムに作った多数の入力で確かめ、失敗したときは最小の反例を表示すること。 ↩

  3. Earl T. Barr, Mark Harman, Phil McMinn, Muzammil Shahbaz and Shin Yoo, The Oracle Problem in Software Testing: A Survey. IEEE Transactions on Software Engineering, 2015。テストオラクルの定義、テストオラクル問題、疑似オラクル。 ↩ ↩2 ↩3

  4. Microsoft Learn, Enumerable.Chunk method. 要素を指定した件数以下の配列に分ける .NET の標準のメソッド。 ↩

  5. NuGet, FsCheck.Xunit 3.6.0 の依存関係. FsCheck 3.6.0 と xunit.extensibility.execution 2.4.1 以上 3.0 未満への依存。 ↩

  6. FsCheck, Running tests. FsCheck.Xunit と FsCheck.Xunit.v3、[Property] 属性と Arbitrary・Replay の指定。 ↩ ↩2 ↩3

  7. FsCheck, Test data: generators, shrinkers and Arbitrary instances. Arbitrary が生成器と縮める手順の組であること、縮める手順の既定は縮小しないこと、参照型の生成器が既定で null を作らないこと。 ↩ ↩2 ↩3

  8. FsCheck, PropertyAttribute. MaxTest・Arbitrary・Replay の説明。 ↩

  9. FsCheck, Quick Start. 成功時と失敗時の報告の形と、反例の縮小。 ↩ ↩2 ↩3

  10. NuGet, FsCheck 3.6.0 と FsCheck.Xunit 3.6.0. パッケージに含まれる API の説明(Gen.Choose が両端を含むこと、ToArbitrary に縮める手順を渡す引数があること、Replay に出力の組を貼り付けられること)。 ↩ ↩2 ↩3

  11. FsCheck, Properties. When による条件付きの性質と、条件を満たさない入力を捨てることと「Arguments exhausted」の意味。 ↩ ↩2 ↩3

同じタグを共有する最新の記事です。さらに近い話題で知識を深められます。

このテーマと近いトピックページです。記事を起点に、関連するサービスや他の記事へ進めます。

この記事は次のサービスページにつながります。近い入口からご覧ください。

よくある質問

この記事のテーマについて、相談時によくある質問をまとめています。

プロパティベーステストとは何ですか?
入力と期待値の組を並べる代わりに、どの入力でも成り立つはずの性質(プロパティ)を書き、入力はツールに大量に作らせるテストの方法です。性質が成り立たない入力が見つかると、ツールはその入力をできるだけ小さく縮めて報告します。
C#でプロパティベーステストを書くには何を使えばよいですか?
この記事ではFsCheckを使います。xUnit v2ではFsCheck.Xunit、xUnit v3ではFsCheck.Xunit.v3のパッケージを入れると、テストメソッドに[Property]属性を付けて書けます。記事のコードはFsCheck 3.6.0のAPIに合わせています。
プロパティベーステストが通れば、実装は正しいと言えますか?
言えません。確かめるのは書いた性質だけで、入力もランダムに作った一部です。弱い性質しか書いていなければ、誤りがあっても通ります。カバレッジやミューテーションテストと組み合わせて、テストが何を確かめているかを別の方向からも調べます。

著者プロフィール

記事の著者プロフィールページです。

小村 豪

合同会社小村ソフト 代表

Windows ソフト開発、技術相談、不具合調査を中心に、既存資産が残る案件や原因が見えにくい障害調査に強みがあります。

ブログ一覧に戻る