Property-based Testingとは、ランダムに生成された多様な入力値に対し、テスト対象が一定の特性を常に満たすかをテストする手法です。ごく簡単な例としては次のようなテストです。
テスト対象 | 入力された整数値に0を掛ける関数 |
満たして欲しい特性 | テスト対象の出力値が常に0 |
テスト内容 | ランダムな整数値を関数に入力し、出力を0と比べる、という処理を繰り返す。 |
一般的なテスト(Example-based Testing)では一つ一つのテストケースを手動実装するのに対し、Property-based Testingでは入力の範囲とテスト対象が満たすべき特性を包括的に記述します。その範囲でテスト対象への入力値をランダムに自動生成し、常に満たすべき特性を確認する、という処理を何度も繰り返すことで、より効率的かつ強力にバグを検出できる場合があります。また、テストによって仕様を記述するビヘイビア駆動開発の観点でも、個別のテストケースを書き並べるよりも見通しが良くなる場合があります。
この記事では、Property-based Testingを実践する場合に、どのようにテストを記述すれば良いのか、勘所を紹介します。
また、PythonでProperty-based Testingを実行するためのHypothesisについて、以下の記事で説明してします。ご興味のある方は合わせてご覧ください。
出力が特定の値になるかテストするのは難しい
まずは、Property-based Testingでどのようにテストを実装すれば良いのか説明する前に、他のテスト手法も合わせて一般的に、テストの通過条件を決定することの難しさについて考えてみましょう。
例としてExample-based Testingのいくつかのテストケースを想像してみます。
- あなたはカーナビの最短経路計算ロジックを実装しています。ある道路地図と現在地・目的地を入力したら、10kmのある経路が出力されました。さて、この結果は正しいでしょうか?もっと短い9kmや8kmの距離の経路が存在しないとどうして言えるでしょうか?
- 独自文法のパラメータ設定テキストをデシリアライズする関数を実装しています。ある設定テキストを入力したところ、あるパラメータ群が得られました。この結果は元のテキストと正しく整合しているでしょうか?
- 302169+1958776*21を計算したところ、41436465という結果が得られました。この結果は正しいでしょうか?
これらの例から分かるように、テスト対象が本来どういう出力を計算すれば望ましいのかは簡単なロジックでは判断できません。人間は簡単に判断できてもプログラム化が難しい場合や、人間でも正しいか判断が難しいものなど様々です。
もし入力に対して望ましい出力結果を自動計算するテスト用プログラムがあったとしたら、それが元々テスト対象として作りたかったものだというタマゴ・ニワトリ問題になります。このような、テスト対象の望ましい出力を求めることは元のテスト対象を正しく実装するのと同じくらい難しいという問題は、Test Oracle Problem(テストオラクル問題)と呼ばれています。
Test Oracle Problemを回避してどのようにテストケースを作れば良いのかが、Property-based Testingに限らず一般的なソフトウェアテスティングの課題となります。
出力そのものではなく満たすべき特性をテストする
Test Oracle Problemが課題になる理由の一つは、テスト対象の出力が望ましい値と完全一致することをテスト通過条件にしようとしているためです。出力そのものではなく、テスト対象(の出力)が満たすべき特性(Property)のテストであれば、比較的簡単かつ短いコードで実装することができる場合も多いです。
なのでProperty-based Testingなのです。Example-based Testingでも出力そのものではなく満たすべき特性をテストすることもありますし、Property-based Testingの説明として入力を多数生成することばかりに着目してしまうこともありますが、実はちゃんと名は体を表していました。
満たすべき特性の内容は当然テスト対象に依存しますが、大まかなやり方は先人によって整理されています。この節ではいくつかの方法を紹介します。
関数と逆関数のテスト
関数y=f(x)
に対してx=g(y)
を計算する関数を逆関数と言います。
このように説明されると純粋数学にしか関係のない概念に思えてしまいますが、実はソフトウェア開発でも非常に頻繁に現れる概念です。
関数と逆関数のパターン | 例 |
二回実行すると元に戻る処理 | 配列の反転 |
対になっている処理のペア | シリアライズとデシリアライズ 暗号化と復号化 |
逆関数を持つ関数のテストや、関数と逆関数のセットのテストについては、任意の入力について関数の出力結果に逆関数を適用したら正しく元に戻るか、をテストする方法が考えられます。
Python Hypothesis風に書くと以下のようになります。
from hypothesis import given, strategies
@given(strategies.text())
def test_g_inverts_f(s):
assert g(f(s)) == s # fが関数、gが逆関数
なお、点の座標回転のように、2回ではなく一定回適用したら元に戻る処理でも同じ方法でテスト可能です。
Metamorphic Testing(メタモルフィックテスティング)
Metamorphic Testingとは、ある入力x
に対するテスト対象の出力y
と、x
にある変換を施したx'
に対する出力結果y'
を比較する手法です。
説明によく使われる例は正弦関数sin
です。任意の実数x
について、x'=x+pi
と置くとy=sin(x)
とy'=-sin(x')
は常に等しくなります。
こちらについても、数学的な関数だけでなくソフトウェア開発でも以下のようなテストが考えられます。
テスト対象 | Metamorphic Testing |
集合、優先度付きキューなどのデータ構造 | どのような順序で値を入力しても最終的なデータ構造が等しくなるか |
何らかの推薦システムにおける順序決定 | 推薦対象群Xの順序が、Xの部分集合の推薦でも維持されているか |
仕様は変わらないリファクタリング | リファクタリング前後の関数の結果が一致するか |
異常停止や例外発生のテスト
そもそも、出力値が満たすべき特性を個別に考えずとも、計算の途中で不正に止まらずに値が常に出力されることもテスト対象が満たすべき特性です。
Property-based Testingのライブラリは、単体モジュールレベルのモンキーテスト用の便利なフレームワークとしても利用することができます。例えばPythonのHypothesisでは、出力に対するアサーション以外でも、テストの実行中にキャッチした例外は全てテスト失敗として報告してくれます。
参考文献
- https://www.infoq.com/jp/news/2020/04/property-based-testing-guide/
- https://qiita.com/tokumoto/items/cd3d17cae3b099badaf6
- その他Web上のブログ等
この記事のいくつかの用語は、細かいニュアンスまで含めて厳密な定義がある言葉ではまだないと思いますし、文献によっても微妙に定義が異なりますが……、それでも上記の説明は明らかに違うよという方がいたらごめんなさい。