🎰

プロパティベーステストにおけるプロパティの考え方

2024/09/17に公開

種本:実践プロパティベーステスト ― PropErとErlang/Elixirではじめよう

主に1,3章の話をします。コード例の言語はC#で、NUnitFsCheckを使用します。

プロパティベーステストとは

プロパティベーステスト(Property-Based Testing、PBT)はソフトウェアのテスト手法の一つで、特定の入力値に対して期待される出力や動作を確認するのではなく、プロパティ(特性や特性の条件)が常に成立するかどうかを検証するものです

プロパティベーステストの詳細に触れる前に、このテスト手法の魅力について種本の一節を下に引用します。

(前略)その一例は、Thomas Arts による Erlang Factory 2016のスライド[1] とプレゼンテーション[2] で確認できます。この発表では、オープンソースのクラウドプロジェクトであるProject FIFO[3] に対して実施された、QuickCheck(標準的なプロパティベーステストのツール)によるテストについて語られています。この事例では、たった460行のプロパティベーステストで6万行もの本番環境のコードをカバーし、以下のような例を含む25個の重要なバグが発見されています。

(中略)

開発者が引き起こすソフトウェア障害は、平均すると1000行あたり6つとも言われています。460行のテストで25個もの重要なバグを発見するのは大手柄です。これは、テスト1行あたり本番環境のコード140行をカバーしながら、1000行のテストで50個以上のバグを見つけたことに相当します。これを聞いて「プロパティベーステストを使いこなしたい」と感じないなら、ほかに宣伝文句が思いつきません。

つまりは「プロパティベーステストによって少ないテストコードで多くのバグを発見できた」ということです。テストを書くのが好きではない(書くけど)私には、プロパティベーステストがとても魅力的に映りました。

テスト手法の分類と比較

何故プロパティベーステストではバグを効率的に発見できたのでしょうか。その前に、まずは一般的なテスト手法について押さえておく必要があります。

例として、配列をソートするSortArrayメソッドとSortArrayを検証するテストを考えます。言語はC#でNunitというテストツールを使用します。

事例ベーステスト

一般的なテストでSortArrayメソッドを検証する場合、以下のようなコードになるでしょう。

using System;
using NUnit.Framework;

[TestFixture]
public class ParameterizedExampleBasedTests
{
    [TestCase(new int[] { 3, 1, 2 }, new int[] { 1, 2, 3 })]
    [TestCase(new int[] { 5, 3, 4, 1 }, new int[] { 1, 3, 4, 5 })]
    [TestCase(new int[] { 1, 2, 3 }, new int[] { 1, 2, 3 })]
    [TestCase(new int[] { }, new int[] { })] // 空の配列
    [TestCase(new int[] { 2, 2, 2 }, new int[] { 2, 2, 2 })] // 同一の要素を持つ配列
    [TestCase(new int[] { -1, -3, 2, 0 }, new int[] { -3, -1, 0, 2 })] // 負の数と正の数を含む配列
    [TestCase(new int[] { 1000, 100, 10, 1 }, new int[] { 1, 10, 100, 1000 })] // 大きい数と小さい数の混合
    [TestCase(new int[] { int.MaxValue, int.MinValue, 0 }, new int[] { int.MinValue, 0, int.MaxValue })] // 最大値と最小値
    [TestCase(new int[] { 5, 5, 3, 1, 4, 1 }, new int[] { 1, 1, 3, 4, 5, 5 })] // 重複する要素を持つ配列
    public void SortArray_正しくソートされている(int[] input, int[] expected)
    {
        int[] result = SortArray(input);

        Assert.That(expected, Is.EquivalentTo(result));
    }

    // ソートする関数
    public int[] SortArray(int[] array)
    {
        Array.Sort(array);
        return array;
    }
}

この例では、NUnitが提供するTestCase属性(Attribute)を使用しています。TestCaseは入力されたデータをそのままテストメソッドに渡してテストを実行します。この属性を1つ記述するごとに、入力されたデータを使ったテストケースが1回実行され、同じテストメソッドを異なる入力データで複数回実行できます。

このように入力に用いる値や期待される値をテストケースごとに用意するテスト手法を事例ベーステストと種本では呼んでいます。


さて例で示したテストコードは重複なく綺麗に書かれていますが、大きく2つの問題を抱えていると思います。

1つはテストケースの管理が大変な点です。例にあるテストケースは9つしかありませんが、SortArrayメソッドが複雑になったり引数が増えたりした場合には、網羅性を確保するためにテストケースの数を増やす必要があります。またその上で、テスト対象の入出力に変化があった場合、最悪全てのテストケースの更新を迫られるかもしれません。テストコードにおいてもメンテナンスのコストはなるべく低くしたいです。

2つ目はバグを発見できる適切なテストケースの設定が難しい点です。事例ベーステストは予め定義された入力セットに依存するため、開発者が想定していなかったシナリオやエッジケースについてはテストが行われず、予期せぬバグを見逃す可能性があります。人間が想定していなかったバグを検出できるようなテストケースを、人間が設定しようとしているわけですから、この問題は本質的な難しさを抱えています。

プロパティベーステスト

それでは次にプロパティベースのテストの例を見てみましょう。

using System;
using NUnit.Framework;
using FsCheck;
using FsCheck.NUnit;

public class PropertyBasedTests
{
    [Property]
    public void SortArray_各要素が次の要素以下になる(int[] input)
    {
        int[] result = SortArray(input);

        Assert.Multiple(() =>
        {
            for (int i = 0; i < result.Length - 1; i++)
            {
                Assert.That(result[i], Is.LessThanOrEqualTo(result[i + 1]));
            }
        });
    }

    // ソートする関数
    public int[] SortArray(int[] array)
    {
        Array.Sort(array);
        return array;
    }
}

PropertyはFsCheckというプロパティベーステスト用のツールが提供する属性です。このProperty属性をテストメソッドの前に書くことで、FsCheckが自動的にランダムな入力データをテストメソッドに入力します。そしてSortArrayの出力が「各要素が次の要素以下になる」というプロパティ(特性)を満たしているかについて自動的に検証されます。ちなみにこのテストでは、入力データをランダムに差し替えつつ、100回(デフォルト値)テストメソッドが実行されます。

このように、テスト対象のプログラムに対してランダム生成したデータを用い、プロパティが満たされるかを確認する手法をプロパティベーステストと呼びます。


では、先ほどの事例ベーステストの問題点をプロパティベーステストの観点から見てみましょう。

事例ベーステストでは「テストケースの管理が大変」という問題を抱えていました。一方、プロパティベーステストではランダムに入力データを生成するため、テストケースを個別に作成し管理する必要がなくなりました。これにより、テスト管理の負担が軽減されます。

また、「バグを発見できる適切なテストケースの設定が難しい」という課題もありました。プロパティベーステストではランダムにデータを生成するため、開発者が想定していなかった入力パターンも自動的に作られます。これによりテストケースを個別に考える手間が省けるだけでなく、予期せぬバグを発見しやすくなりました。

先述した事例ベースの問題点は、プロパティベーステストの導入によって軽減できると言えるでしょう。

プロパティベーステストの注意点

プロパティベーステストの利点について述べてきましたが、事例ベーステストが不要になったわけではありません。両者には適した場面があり相互補完が可能です。

まず、プロパティベーステスト用のライブラリが存在しない言語ではプロパティベーステストの難易度が上がります。有名なライブラリだと、コード例で使用したFsCheck(.NET)やfast-check(TypeScript)、hypothsis(python)などが挙げられます。これらのライブラリを使えない場合は、他のライブラリを使用するか、自作するか、ライブラリを使わずにプロパティベーステストを記述するかを選択する必要があります。

また事例ベーステストが適している状況として、テスト対象の全ての入力パターンが明確でそれらを漏れなくカバーできるケースが挙げられます。入力パターンが無数に存在する条件下では、ランダムに入力データを生成し大量にテストを実行できるプロパティベーステストの方が網羅性において分があります。しかし入力パターンを全て網羅できる状況ではプロパティベースのランダム性はかえって邪魔で、全パターンを確実にテストできる事例ベースの方が好ましいです。

既知のバグや特定条件下の問題を再現したい場合も事例ベースの方が適しているでしょう。極端な例ですが、入力値(32bit整数型)が1000000の時にエラーが発生する場合、単純なプロパティベーステストでは\frac{1}{2^{32}}の確率で再度1000000が入力されるのを待つ必要があります。このようなケースではプロパティベーステストだけでなく事例ベースでもテストを実行できる環境を整えられると理想的です。

またプロパティベーステストの注意点としてプロパティの定義が難しい点も挙げられます。特にプロパティが弱かったり不足していたりすると、失敗してほしいテストが成功してしまう危険性があります。事実、先述したプロパティベースのテスト例はプロパティが不足しており、例えばSortArray()が常に1, 2, 3を返す場合でも「各要素が次の要素以下になる」というプロパティは満たされてしまいます。テストを意図通りの挙動にするためには適切なプロパティを考える必要があります。

「適切なプロパティを考えるって具体的にどうすりゃいいんだよ」と疑問を持たれた方も多いと思います。次の章ではプロパティを考える方法について述べたいと思います。

プロパティを考える4つの方法

種本ではプロパティを考えるための4つの方法が記載されています。

  • テスト対象をモデル化する
  • 事例テストを汎化する
  • 不変条件に着目する
  • 対称プロパティを使う

テスト対象をモデル化する

モデル化では、非常に単純な実装をモデルとして書き、それを本物の実装と競争させます。

例えば、ソート済みの配列と値を入力して、配列内にその値が存在するかを確認するExists()メソッドをテストする場合を考えてみましょう。Exists()メソッドは内部で二分探索を使って計算量を効率化していますが、計算量を考慮しないのであれば、Array.IndexOf(input, value) >= 0という単純な実装でもExists()と同じ結果が得られます。モデル化を使ったプロパティでは、複雑な実装と簡単なモデルを比較し、出力が一致するかどうかでテストの合否を判断します

using System;
using NUnit.Framework;
using FsCheck;
using FsCheck.NUnit;

[TestFixture]
public class ExistsTests
{
    [Property]
    public void Exists_モデル化された実装と結果が一致する(int[] input, int value)
    {
        // 配列をソートする
        Array.Sort(input);

        // 配列に値が存在するか確認
        bool expected = Array.IndexOf(input, value) >= 0;
        bool result = Exists(input, value);

        // 期待値とExistsメソッドの結果を比較
        Assert.That(expected, Is.EqualTo(result));
    }

    // ソート済みの配列内に値が存在するかを二分探索で確認する
    public bool Exists(int[] sortedArray, int value) { /* 複雑な処理 */ }
}

モデル化の特徴は以下になります。

  • 副作用や依存関係が多いステートフルシステムの結合テストに有効
    • 「システムが何をするか」は複雑だが、「ユーザーが何を知覚するか」は単純なケースが多いため
  • 常にこの手法を使えるわけではない
    • モデル化実装を思いつかない可能性がある上に、その実装が単純かつ自明である必要がある
  • テストに時間がかかりすぎる場合がある
  • (個人的な感想だが、)テスト対象となるメソッドの出力結果をそのまま丸ごと比較できるのが非常に強力
    • 「モデル化された実装と結果が一致する」というプロパティだけで、多くの入力をカバーできてしまう

事例テストを汎化する

プロパティの定義に、「事例テストの汎化」が役に立つかもしれません。

その名の通り、この手法では通常のユニットテストをまず書き、それらを抽象化してプロパティに昇華させます。直接プロパティを考えるのではなく、事例ベースのテストを挟み込むことで、優れたプロパティの発見を期待できます。

以下の特徴が考えられます。

  • どんなケースでも適用できる考え方
  • 通常のユニットテストを書くため、直接プロパティを考えるより冗長

不変条件に着目する

不変条件(invariant)とは、「常に(=すべての入力ケースに対して)真になるはず」という条件や事実を指します

詳細の前に、以下に不変条件を例示したいと思います。

  • お店では、在庫以上の数の商品を売れない
  • 二分探索木では、左の子は親より値が小さく、右の子は親より値が大きくなる
  • いったんデータベースに挿入したレコードは読み戻り可能でなければならず、行方不明になってはいけない

不変条件はその性質上、満たされなければテスト対象の実装が間違っていると考えられますが、満たされたとしても正しい実装とは限りません。「お店では、在庫以上の数の商品を売れない」という条件が満たされたとしても、りんごとみかんの商品IDが取り違えられていたり在庫数のカラムに値段が入力されていたりする可能性があります。このような実装はプロパティこそ満たしていますが明らかに正しくありません。

この問題は、プロパティの数を増やすことで緩和が可能です。単一の不変条件だけではコードが期待通りに動作していることを示せなくても、他のプロパティを追加することで、より強固なテストを作成できます。

不変条件を使ったプロパティの特徴として以下が考えられます。

  • どんなケースでも適用できる考え方
  • 単一の不変条件だけでは不十分で、他のプロパティも併用する必要がある。

対称プロパティを使う

ある操作に対して、その逆の操作ができる場合は、対称プロパティ(symmetric property)を使えるかもしれません。

対称プロパティを適用できる操作の例として、以下が考えられます。

  • 文字列の編集とその取り消し
  • フランス語から英語、英語からスペイン語、スペイン語から再びフランス語への翻訳
  • 複数のサーバーを経て元のサーバーに戻るまでメッセージを渡す
  • ゲーム内で元の位置に戻るまで全方向に歩くキャラクター

上記の例で重要なのは、一連の操作を実行すると操作開始前の状態に戻る(と期待できる)点です。例では、最初にフランス語を用意して一連の翻訳を実行すると再度フランス語に戻っています。そして元の状態に戻る特性を利用して、対称プロパティでは最初のフランス語と最終出力のフランス語が一致するかどうかを確認します。

つまり、初期状態と一連の操作を実行した後の状態の一致を確認するプロパティを対称プロパティと呼びます。

対称プロパティが強力なのは、各操作を一度にテストできる点です。以下のコード例では、EncodeメソッドとDecodeメソッドが同時にテストされています。

using System;
using NUnit.Framework;

[TestFixture]
public class EncodeDecodeTests
{
    // Encode メソッド(例として文字列をBase64でエンコード)
    public string Encode(string input)
    {
        var plainTextBytes = System.Text.Encoding.UTF8.GetBytes(input);
        return Convert.ToBase64String(plainTextBytes);
    }

    // Decode メソッド(Base64でエンコードされた文字列をデコード)
    public string Decode(string encoded)
    {
        var base64EncodedBytes = Convert.FromBase64String(encoded);
        return System.Text.Encoding.UTF8.GetString(base64EncodedBytes);
    }

    // Encode -> Decodeのテスト
    [Test]
    [TestCase("Hello World")]
    [TestCase("プロパティベーステスト")]
    [TestCase("1234567890")]
    [TestCase("")]
    public void Encode_Decode_元の文字列が復元される(string input)
    {
        // エンコード
        string encoded = Encode(input);
        
        // デコード
        string decoded = Decode(encoded);
        
        // 元の入力とデコード結果が同じであることを確認
        Assert.That(input, Is.EqualTo(decoded));
    }
}

また注意点として、この例の対称プロパティだけでは十分なテストにはなっていません。例えばEncode/Decodeメソッドが入力された文字列をそのまま返す場合もこのプロパティは満たされてしまいます。対策するとしたら、「Encodeメソッドの出力がBase64になっている」といった不変条件も加える必要があります。ざっくり全体的に操作の挙動を確認する対称プロパティに、各操作に着目する不変条件を足すことで、最小限のコードで強固なテストを組み立てられます。

対称プロパティを使用する際の特徴などを以下にまとめます。

  • テストしたいメソッドをまとめてテストできるため、手間がかかりにくい
  • 常にこの手法を使えるわけではない
    • 一連の操作を実行すると元の状態に戻るケースでのみ使用できる
    • 情報の損失がある操作が挟まると元の状態には戻らないため使えないかも
  • 不変条件を組み合わせることで、強固なテストを組み立てられる

まとめ

  • プロパティベーステストはプロパティ(特性や特性の条件)が常に成立するかどうかを検証するテスト手法です
  • テストには事例ベースとプロパティベースの2つがあり、相互補完的な関係がある
  • プロパティを考えるための足掛かりとして、4つの方法がある

本記事では、プロパティベーステストの概要からプロパティの考え方について述べました。実践プロパティベーステストでは、紹介した4つのプロパティの考え方を実際に利用していたり、ステートフルなプロパティベーステストの方法について述べられていたりします。興味のある方は是非読んでみてください。

脚注
  1. http://www.erlang-factory.com/static/upload/media/1461230674757746pbterlangfactorypptx.pdf ↩︎

  2. https://youtu.be/iW2J7Of8jsE?si=u_pCEuXxl5xVgoLe ↩︎

  3. https://project-fifo.net/ ↩︎

Discussion