しばらく前のことになりますが、プロパティベーテストライブラリ kiri-check を作りました。そのときの知見でいくつか記事を Zenn に書きました。
以上の記事を書いてから状況がだいぶ変わったので、この機会に気軽に読める内容で書いておこうと思います。
プロパティベーステストとは
名著 「実践プロパティベーステスト」 の紹介文が一番です。太字は私がつけています。
テストケースはコンピューターで書くべき! でもどうやって? その答えが「プロパティベーステスト」です
従来のユニットテストでは、人間が「入力に対してコードが返すべき値」を考えて、その通りの結果が得られるかどうかをテストします。 これに対してプロパティベーステストでは、 数万にも及ぶ多様なテストケースをコンピューターで自動生成し、その大量のテストを水面下で実行する ことによって、どんな入力に対してどんな問題が起きるかをテストします。 人間には思いもつかない入力まで網羅できる ことから、単に手間をかけずにテストケースを増えせるだけでなく、場合によっては 仕様に潜むバグさえもあぶり出せる 強力なテスト手法です。
いいことづくめですね! でも、残念ながらプロパティベーステストは一般的に普及していません。この記事のタイトルで初めて目にした方も多いと思います。
なぜマイナーなのか? プロパティベーステストライブラリはどの言語でも実装されている方がいますので、使えないことはありません。その理由は、ぶっちゃけ 難しいから です。
大量のテストケース (入力値) を自動生成するのはいいとして、そのテストの成否を判断するには期待値が必要です。期待値をどうやって生成するのでしょうか? テスト対象のコードを使って期待値を生成する? それじゃ意味がありませんよね。テスト対象のコードが間違っていても 100% 成功してしまいます。
同じ仕様でもう一回実装する
そこで、期待値をチェックするためのテストコードを実装します。「本番実装と同じコードになるのでは?」と思われるかもしれませんが、今度は「確実に仕様通りの実装」を目指します。参照実装とも言います。「やはり本番実装と同じコードになるのでは?」と思われるかもしれません。
でも、本番実装は現実的な制限も考慮するので、綺麗なコードになりません。パフォーマンス、メンテナンス、運用環境など、様々な条件が絡まって実装方法が変わってきます。一方、プロパティベーステストのために用意する実装は、最適化は二の次です。仕様の実装が最優先であり、それ以外の工夫は可能な限りすべきではありません。 (テストにあまりにも時間がかかるなら最適化が必要です)
例として、パスワードをチェックする関数を考えてみます。パスワードの仕様を「アルファベット、数字、記号 @$!%*?&
のすべての種類を含めた8文字以内の文字列」とします。
本番実装は正規表現でパターンマッチするとします。コードが短く済みますし、パターンを見れば内容がわかります。
bool validatePassword(String password) {
final regex = RegExp(
r'^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$',
);
return regex.hasMatch(password);
}
この関数をテストするために、もっと実装を単純化した関数を考えてみます。 (実際のテストコードは expect
によるチェックを挟みます)
bool validatePasswordForTest(String password) {
if (password.length < 8) return false;
bool hasLowerCase = false;
bool hasUpperCase = false;
bool hasDigit = false;
bool hasSpecialChar = false;
for (int i = 0; i < password.length; i++) {
final char = password[i];
if (char.codeUnitAt(0) >= 'a'.codeUnitAt(0) &&
char.codeUnitAt(0) <= 'z'.codeUnitAt(0)) {
hasLowerCase = true;
} else if (char.codeUnitAt(0) >= 'A'.codeUnitAt(0) &&
char.codeUnitAt(0) <= 'Z'.codeUnitAt(0)) {
hasUpperCase = true;
} else if (char.codeUnitAt(0) >= '0'.codeUnitAt(0) &&
char.codeUnitAt(0) <= '9'.codeUnitAt(0)) {
hasDigit = true;
} else if ('@\$!%*?&'.contains(char)) {
hasSpecialChar = true;
}
}
return hasLowerCase && hasUpperCase && hasDigit && hasSpecialChar;
}
こちらの関数では「8文字以内」「アルファベット小文字」「アルファベット大文字」「数字」「記号」の5つの条件に分けてチェックしています。コード量も倍以上に膨らみ、人に見せれば「正規表現でいいんじゃないですか?」と聞かれそうです。
ただ、どちらが確実な実装(バグが入りにくい実装)かと言うと後者でしょう。ミスしやすい正規表現に比べると、それぞれの条件を個別にチェックするほうが安全です。コードの意図もわかりやすい。この関数で生成した期待値を、本番実装で生成した実際値と比較します。
この方法は手間がかかりますが、自動生成される大量のテストケース (入力値) を関数で捌けるようになります。10万件のパスワードのテストも余裕です。
参照実装のテスト方法も考える必要があります。テストのためにテストを書くといった感じで回りくどいことこの上ありませんが、最も確実な方法は信頼できる第三の実装(外部ライブラリやツール)を使うことです。詳しくは「実践プロパティベーステスト」を読んでください。
プロパティベーステストは難しい
プロパティベーステストの「プロパティ」とは、テスト対象のコードが満たすべき性質や法則のことです。常に成り立つべき条件 (不変条件や事後条件) や、期待する振る舞いなどの 規則そのもの をテストします。
先程のパスワード生成だと「アルファベット、数字、記号 @$!%*?&
のすべての種類を含めた8文字以内の文字列」の仕様から導かれる性質や法則がプロパティとみなせます。仕様と規則は異なるので注意してください。
「本番実装だって仕様に従って実装してるのに?」と思われるかもしれません。でも、「 (期待値による) 答え合わせ」と「規則そのもののテスト」は異なります。
答え合わせでは指定した期待値のみのチェックになるので、開発者が予想できるバグしか見つけられません。潜在的なバグは本番環境でのユーザーの予期しない行動で発覚します。規則そのもののテストと大量のテストケースの自動生成を組み合わせれば、開発者が予想だにしない複雑なバグを事前に見つけやすくなります。
ただ、規則を発見するのは難しいです。ありがちなのが、テストコードが本番実装の焼き直しになってしまうケースです。そうなるとテストコードが本番実装と同じミスをするので、問題を抱えたままテストが成功してしまいます。
また、実際にやってみるとわかりますが、本番実装のときの思考と規則を見つけるときの思考は全然違います。
本番実装に取り組んでいるときは意外と規則を気にしていなかったりしませんか?「引数がAであればBを返す」「Aの状態であればBする」みたいな、総当たりの場合分けで答えに近づこうとします。
規則を見つけるときは逆になります。先に多数の事例を考え、そこから共通する要素を見つけ出そうとします (抽象化) 。これは心身共に消耗する作業です。プロパティベーステストは疲れます。
ステートフルテストをサポートするライブラリもあります。ステートフルテストは状態遷移のテストで、人間には思いつかない手順での操作によるバグを発見できます。
有名なところだと、 LebelDB のステートフルテストは 特定の 17 ステップで発生するバグ を発見しました。さらにそのバグの修正後、今度は 31 ステップで発生するバグを発見しました。
…とまあ強力なステートフルテストですが、普通の (ステートレス) プロパティテストより設計も実装も難しくなります。採用するケースも少なくなりますし、ステートフルテストをサポートするライブラリも少数派です。 kiri-check はステートフルテストをサポートしていますが、サンプル程度の小さなコードすら実装が面倒でした。
プロパティベーステストはやっぱり難しい?
そんなわけでいまいち普及しないプロパティベーステストですが、AIの精度向上によって風向きが変わってきました。 AIと相談しながらテストを設計してもらえばいい のです。
例として、 Claude Code (Claude Sonnet 4) に FizzBuzz のテストを kiri-check で実装してもらいました。以下、プロンプトです。
FizzBuzzの簡単なプロパティテストのコードを提示して。 (※ 事前に kiri-check のドキュメントを与えています)
⏺ FizzBuzzのプロパティテストの例を示します:
// 生成されたコードに適宜コメントを加えています
import 'package:kiri_check/kiri_check.dart';
// kiri-check は test パッケージと組み合わせて使います
import 'package:test/test.dart';
String fizzBuzz(int n) {
// あえて実装を消しています。ご自分で実装してみてください
}
void main() {
property('FizzBuzz properties', () {
// 1から1000までの間の整数をランダムに生成
// デフォルトでは100回のテストを実行します
forAll(integer(min: 1, max: 1000), (n) {
final result = fizzBuzz(n);
// 3の倍数ならFizzを含む
if (n % 3 == 0) {
expect(result.contains('Fizz'), isTrue);
}
// 5の倍数ならBuzzを含む
if (n % 5 == 0) {
expect(result.contains('Buzz'), isTrue);
}
// 15の倍数ならFizzBuzz
if (n % 15 == 0) {
expect(result, equals('FizzBuzz'));
}
// 3でも5でも割り切れない場合は数値の文字列
if (n % 3 != 0 && n % 5 != 0) {
expect(result, equals(n.toString()));
}
});
});
}
このテストでは FizzBuzz のチェックを4段階に分けて行っています。 expect(result, equals(n.toString()))
のように、テスト側でも期待値を動的に生成して比較します。ここが期待値を直接指定する普通のテスト (事例ベーステスト) と異なります。このテストを実行すると、 1 から 1000 の範囲の整数がランダムに 100 回生成され、 100 通りの FizzBuzz がテストされます。
プロパティベーステストに興味を持たれたら、ぜひ一度 kiri-check を試してみてください。