この記事はNTTドコモソリューションズ Advent Calendar 2025 12日目の記事です。
こんにちは、NTTドコモソリューションズの 吉村 優 です。
社内の開発プロジェクト向けに技術支援を行うチームに所属しており、特にテスト工程に対して機能性や UI/UX の改善に取り組んでいます。
これまで社内のさまざまなシステムのテスト支援に関わってきましたが、要件やデータ構造がどんどん複雑化しており、テストの難易度も上がっていると感じる場面が増えてきました。
こういった問題は、個別のテストケースを追加していくやり方だとどうしても限界があり、別の視点でテストできないか? と考えたときに興味を持ったのがプロパティベーステストでした。
この記事では、Java で使えるテストライブラリである jqwik を使ってプロパティベーステストをしてみたのでご紹介します。
プロパティベーステストとは
プロパティベーステスト(Property-Based Testing, PBT)は、入力をさまざまに変えながら「ソフトウェアが満たすべき性質(プロパティ)が保たれているか」を確認するテスト手法です。
よく行われる具体的な事例を用いた事例ベースのテストでは、
- 「10 を入力したら 20 が返る」
- 「0 未満の値や 1000 以上の値を入れたらエラーになる」
のように、「特定の入力」 → 「特定の出力」 を確認します。
これは仕様内の特定の1点の確認を連ねることで品質を積み上げていく取り組みです。
一方、PBT では以下のような性質に注目します。
- 「入力が 2 倍されるのであれば、出力は常に偶数になる」
- 「0 以上の値しか入力されないのであれば、結果は常に非負になる」
つまり「常に成立すべきルール」を定義して、それが多様な入力に対して成り立つのかをチェックしていくイメージです。
事例ベースのテストと比較すると、仕様や関数が本来持っている性質を確かめるところに特徴があります。
jqwikでPBTをしてみる
といってもなかなかイメージがわきにくいので、PBTで不具合が見つかる流れを実践していきます。
題材
ECサイトなどでよく見かけるポイント計算のロジックを想定します。ごく普通の仕様です。
- 購入金額にポイントの基本レート 0.1(=10%)をかける
- 小数点以下を切り捨てる
- キャンペーン適用時はポイントを2倍にする
つまり、付与ポイントは floor (購入金額 × 基本レート) × キャンペーン倍率 で求められます。
間違えた実装を用意する
今回は、この計算式を実装する際に「よくあるズレ」を意図的に入れた誤実装を用意しました。
public class PointCalculatorBuggy {
/**
* 付与ポイントを計算する
*/
public static int calcPoints(int amountYen, double baseRate, int campaignMultiplier) {
if (amountYen < 0) {
throw new IllegalArgumentException("amountYen must be >= 0");
}
if (campaignMultiplier <= 0) {
throw new IllegalArgumentException("campaignMultiplier must be > 0");
}
// キャンペーン倍率をかけてから小数点以下を切り捨てている
return (int) Math.floor(amountYen * baseRate * campaignMultiplier);
}
}
ぱっと見では問題なさそうな実装ですが、実は不具合があります。
テストケースを増やしていけばどこかで見つけられる可能性はありますが、検出の難しさもそこそこありそうです。
これをプロパティベーステストでどう見つけるかを次の章で見ていきます。
プロパティを考える
まず、事例ベースのテストの場合には、以下のようにいくつかのケースを試していきますが、どうやら問題なさそうに思えます。…が、実際には特定の値のときに正しい結果を得られません。
- 100 円の時は、20 pt →正常
- 501 円の時は、100 pt →正常
- 1050 円の時は、210 pt →正常
では、プロパティを考えてみます。
この題材には2倍キャンペーン時のポイントは「必ず偶数になる」という性質があります。
(他にも「0 円の時、ポイントは 0 になる」、「金額が増えるとポイントは同じかそれ以上になる」などが考えられます。)
これは、次のような理由から導かれる性質です。
- 基本レート(0.1)が小数であっても、
2. 小数点以下を切り捨てるで必ず整数になる - キャンペーン適用時は、その整数を×2するだけ
- ×2するので最終的な付与ポイントは必ず偶数になる
今回実装したコードに対して、このプロパティが「どんな金額を入力しても成り立つのか?」を jqwik に任せて確かめていきます。
jqwik でプロパティをテストする
先ほどのプロパティ「2倍キャンペーン時のポイントは偶数である」を jqwik を使ってテストしてみます。
jqwik では、テストしたいプロパティを @Property と記述し、 @ForAll で入力値が自動生成されます。
今回は金額 amountYen を 0〜100万の範囲でランダムに生成し、多様な入力でプロパティが破れていないかを確認します。
@Property
void twoTimesCampaignShouldProduceEvenPoints(
@ForAll @IntRange(min = 0, max = 1000000) int amountYen) {
int campaignMultiplier = 2;
double baseRate = 0.1;
int points = PointCalculatorBuggy.calcPoints(amountYen, baseRate, campaignMultiplier);
// 2倍キャンペーンならポイントは必ず偶数になる(付与ポイントを2で割ったあまりは0になる)
Assertions.assertEquals(
0,
points % 2,
() -> "奇数になった! amountYen=" + amountYen + " points=" + points);
}
これを実行してみると、
[ERROR] Failures:
[ERROR] PointCalculatorPropertyTest.twoTimesCampaignShouldProduceEvenPoints:16 奇数になった! amountYen=5 points=1 ==> expected: <0> but was: <1>
[ERROR] Tests run: 5, Failures: 1, Errors: 0, Skipped: 0
こんな感じで失敗します。
jqwik が ランダムに値を生成 → プロパティが成り立つかチェック → 成り立たない場合は最小反例への縮小(shrink)を自動で行います。
例えば、amountYen = 5 の場合、付与ポイントが 1 となり、偶数になるはずのプロパティが成立しない(=テスト失敗)パターンを提示してくれます。
このように、「たくさんの入力を自動で試して、プロパティが破れる最小のケースを見つけてくれる」のが、プロパティベーステストの面白いところです。
shrink とは?
先ほどのテストを実行すると、jqwik はランダムに生成した多数の入力を試して、プロパティが破れる反例を見つけてくれます。
ただし、見つかる反例は amountYen = 387257 のように大きな値のこともあります。
多くの PBT ライブラリはここからシンプルな反例へと縮小してくれます。この仕組みが Shrink です。
今回の例では、最終的に amountYen = 5 のような反例を提示してくれました。
この仕組みのおかげで「floor の位置がずれている」という間違いに気づきやすくなります。
Shrink は、PBT の中でもデバッグ支援として有用だなと思える仕組みのひとつです。
正しい実装に直して再テストする
さて、ミスに気づいたので、誤りのあった実装を正しいものに直します。
/**
* 付与ポイントを計算する
*/
public static int calcPoints(int amountYen, double baseRate, int campaignMultiplier) {
if (amountYen < 0) {
throw new IllegalArgumentException("amountYen must be >= 0");
}
if (campaignMultiplier <= 0) {
throw new IllegalArgumentException("campaignMultiplier must be > 0");
}
double raw = amountYen * baseRate;
int base = (int) Math.floor(raw); // 先に小数点以下を切り捨てる
return base * campaignMultiplier; // 最後にキャンペーン倍率をかける
}
この修正版に差し替えて先ほどのテストを実行すると、今度は試されたどの値に対しても「偶数であるというプロパティ」が破れずにテストが通過します。
PBT は他の技法と同様に「全探索」ではありませんが、仕様に基づいたプロパティを軸にテストを構築することで、事例ベースのテストでは拾いづらいズレや境界のミスに出会いやすくなるというのが今回の例からも見えてきます。
確率分布に沿った入力値生成
その他の機能の紹介になりますが、実運用時などを見据えて生成するデータの分布を指定することもできます。
[INFO] Running PointCalculatorPropertyTest
timestamp = 2025-12-03T09:59:48.325702, [PointCalculatorPropertyTest:twoTimesCampaignShouldProduceEvenPoints] (100) statistics =
# | label | count |
-----|-------|-------|---------------------------------------------------------------------------------
0 | 0 | 12 | ■■■■■■■■■■■■
1 | 1 | 5 | ■■■■■
2 | 2 | 15 | ■■■■■■■■■■■■■■■
3 | 3 | 8 | ■■■■■■■■
4 | 4 | 2 | ■■
5 | 5 | 8 | ■■■■■■■■
6 | 6 | 10 | ■■■■■■■■■■
7 | 7 | 9 | ■■■■■■■■■
8 | 8 | 10 | ■■■■■■■■■■
9 | 9 | 8 | ■■■■■■■■
10 | 10 | 13 | ■■■■■■■■■■■■■
例えば、業務系のシステムで、ある程度入力される値が想定できるのであれば、利用頻度の高い値を中心とした正規分布に従わせるのが良いかもしれません。
一方で、一般公開するシステムなどで意図しない入力やイタズラ的なデータも想定されるのであればエッジケースに重きを置いた分布にするのも良いかもしれません。
まとめ
今回の記事では、ポイント計算を題材に「本来偶数になるべき出力が、特定の条件で奇数になる」という不具合を、PBTによってどのように発見できるかを検証しました。
PBT では、入力と出力のペアを個別に確認するのではなく、「この関数は常にこう振る舞うべきである」というプロパティに注目します。
自分が実装したい機能のプロパティを見つけること自体が難しい場合もありますが、プロパティを探す中で仕様の考慮漏れや誤りに気がつけることもあります。
もちろん万能ではありませんが、事例ベースのテストだけでは見つけにくい間違いに気づきやすいという特徴があります。
特に以下のような場合とは相性が良いのではないでしょうか。
- 複雑な計算ロジックやルールベースの処理がある部分
- データ量や組み合わせが多く、網羅が難しい部分
実務でも「テストの観点が欠けていた」「境界で落ちた」など、事例ベースだけでは拾いにくい不具合に遭遇する場面があります。
今回のように性質そのものに注目することで、観点整理や仕様理解を支援してくれる点が有用だと感じました。
Java でも jqwik で手軽に試せるので「ちょっと別のアプローチでテストしてみたい」と感じたときに役立つ選択肢のひとつになればと思います。
記載されている会社名、製品名、サービス名は、各社の商標または登録商標です。