- 技術 💻
同値分割・境界値分析|テストケース表とpytestコード例で解説
同値分割とは、入力値を「同じように処理されるグループ(同値クラス)」に分け、各グループから代表値を1つずつテストする技法です。境界値分析とは、そのグループの境目(最小値・最大値)とその隣の値を重点的にテストする技法です。 2つを組み合わせると、少ないテストケースで漏れの少ないテストが作れます。
この記事では、JSTQB Foundation Level シラバス v4.0 の定義に沿って 2値境界値分析と3値境界値分析の違いを整理し、「年齢による区分」「注文数量と送料」「文字数制限の入力欄」の3つの題材で、仕様 → 同値クラス表 → 境界値表 → テストケース表の流れをテンプレートにしました。さらに全ケースを pytest の parametrize で実装して実行し、わざと入れた < と <= の取り違えが境界値テストで検出される様子を実行結果で示します。
同値分割・境界値分析の早見表
| 項目 | 同値分割 | 境界値分析(2値) | 境界値分析(3値) |
|---|---|---|---|
| 一言でいうと | グループごとに代表値を1つ | 境界値と、隣のグループの最も近い値 | 境界値と、その両隣の値 |
| 見つけやすい不具合 | グループの処理そのものの誤り | < と <= の取り違え、境界のずれ |
2値に加え、>= を == と書く誤りなど |
| 使える条件 | どんな入力でも | 値に順序がある(数値・文字数・日付など) | 同左 |
| 1境界あたりの値 | ― | 2つ | 3つ |
| 範囲 1〜99 の例 | 0、50、100 | 0、1、99、100 | 0、1、2、98、99、100 |
| カバレッジの計算 | テストしたパーティション数 ÷ 全パーティション数 | テストした境界値の数 ÷ 全境界値の数 | 同左 |
| 用語 | 意味 | 例(範囲 1〜99) |
|---|---|---|
| 同値クラス(同値パーティション) | 同じ処理をされると考えられる値の集まり | 1〜99、0以下、100以上 |
| 有効同値クラス | 仕様どおり受け付けられる値 | 1〜99 |
| 無効同値クラス | 仕様上受け付けない値 | 0以下、100以上、文字列、null |
| 境界値 | 順序のある同値クラスの最小値・最大値 | 1、99(有効)、0、100(無効側) |
| 代表値 | 同値クラスから選ぶ1つの値 | 50 |
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「同値分割・境界値分析|テストケース表とpytestコード例で解説」
同値分割(有効・無効の同値クラス)とは
同値分割は「この値でうまくいくなら、同じグループの他の値でもうまくいくはず」と考えて、テストする値の数を減らす技法です。 JSTQB FL v4.0 では、同値分割は値を「パーティション(同値クラス)」に分け、各パーティションの値は同じように処理されるという前提に立ちます。パーティションには、仕様どおりの値の有効パーティションと、仕様に従わない値の無効パーティションがあります。
| 進め方 | やること | ポイント |
|---|---|---|
| 1 | 仕様から入力(出力)の条件を書き出す | 「1〜99個」「2〜20文字」など |
| 2 | 有効同値クラスを分ける | 処理が変わる点(送料が変わる数量など)でも分ける |
| 3 | 無効同値クラスを分ける | 範囲外(下・上)、型が違う、空、null |
| 4 | 各クラスから代表値を選ぶ | 無効クラスは1テストに1つだけ入れる |
無効同値クラスは、1つのテストケースに1つだけ入れるのが基本です。 複数の無効な値を同時に入れると、最初のチェックでエラーになり、他のチェックが正しく動いているかを確認できないためです。
同値分割のカバレッジは「テストしたパーティション数 ÷ 識別したパーティション数」で計算します。入力が複数あるときに、各入力のパーティションをそれぞれ1回以上通す考え方を、JSTQB FL v4.0 では Each Choice カバレッジと呼んでいます。
境界値分析(2値・3値)とは
境界値分析は、不具合が「境目」に集まりやすいことに注目した技法です。 仕様の「以上・未満」を実装で <= と < に取り違えると、境界の値だけ結果が変わるため、代表値のテストでは見つかりません。JSTQB FL v4.0 では、境界値分析は順序のあるパーティションにだけ使え、2値と3値の2つのやり方を説明しています。
| 種類 | 境界値1つあたりにテストする値 | 範囲「1000以上」の境界 1000 の例 |
|---|---|---|
| 2値境界値分析 | 境界値と、隣のパーティションの最も近い値 | 999、1000 |
| 3値境界値分析 | 境界値と、その両隣の値 | 999、1000、1001 |
2値と3値の判断基準
基本は2値で十分です。上限が決まっていない範囲や、条件の書き間違いの影響が大きい箇所は3値を使います。
| 状況 | おすすめ | 理由 |
|---|---|---|
| 範囲の上限と下限が両方ある(1〜99など) | 2値 | 反対側の境界のテストが、== の誤りなども拾いやすい |
| 「1000以上」のように片側だけの条件 | 3値 | 境界の内側(1001)がほかのテストで通らない |
| 料金・ポイントなど、値が直接結果に響く | 3値 | 誤りの影響が大きい |
| 文字数・件数の入力チェック | 2値+よくある抜け | 0・空・null を別に足すほうが効果的 |
| テストの数を抑えたい単純な範囲 | 2値 | 3値の追加分は多くの場合、同値クラスの代表値と重なる |
題材1:年齢による区分(3値境界値分析)
仕様
| 年齢 | 区分 |
|---|---|
| 0〜5歳 | 幼児 |
| 6〜12歳 | 子供 |
| 13〜64歳 | 大人 |
| 65〜120歳 | シニア |
| 上記以外、整数でない | エラー |
同値クラス表
| ID | 種類 | 同値クラス | 代表値 |
|---|---|---|---|
| EA1 | 有効 | 0〜5 | 3 |
| EA2 | 有効 | 6〜12 | 9 |
| EA3 | 有効 | 13〜64 | 30 |
| EA4 | 有効 | 65〜120 | 80 |
| EA5 | 無効 | 0未満 | -1 |
| EA6 | 無効 | 121以上 | 121 |
| EA7 | 無効 | 整数以外(null、文字列、小数、真偽値) | null |
境界値表(3値)
| 境界 | 下側の値 | 境界値 | 上側の値 |
|---|---|---|---|
| 0(有効の下限) | -1(無効) | 0 | 1 |
| 5/6(幼児と子供) | 5 | 6 | 7 |
| 12/13(子供と大人) | 11 | 12 | 13 |
| 64/65(大人とシニア) | 64 | 65 | 66 |
| 120(有効の上限) | 119 | 120 | 121(無効) |
境界の両側の値は重なるため、重複を除くとテストする値は -1、0、1、5、6、7、11、12、13、64、65、66、120、121 になります(119 は代表値で確かめられるので省略しています)。
テストケース表
| ID | 入力 | 期待結果 | 観点 |
|---|---|---|---|
| A01 | 0 | 幼児 | 下限 |
| A02 | 1 | 幼児 | 下限の内側 |
| A03 | 5 | 幼児 | 幼児の上限 |
| A04 | 6 | 子供 | 子供の下限 |
| A05 | 7 | 子供 | 子供の下限の内側 |
| A06 | 11 | 子供 | 子供の上限の内側 |
| A07 | 12 | 子供 | 子供の上限 |
| A08 | 13 | 大人 | 大人の下限 |
| A09 | 64 | 大人 | 大人の上限 |
| A10 | 65 | シニア | シニアの下限 |
| A11 | 66 | シニア | シニアの下限の内側 |
| A12 | 120 | シニア | 上限 |
| A13 | -1 | ValueError | 下限の外側 |
| A14 | 121 | ValueError | 上限の外側 |
| A15 | null(None) | TypeError | null |
| A16 | "20" | TypeError | 文字列 |
| A17 | 20.5 | TypeError | 小数 |
| A18 | True | TypeError | 真偽値 |
A18 の真偽値は Python 特有の抜けやすい観点です。 Python では True が整数の 1 として扱われる(isinstance(True, int) が True)ため、型チェックで明示的に除外しないと「1歳の幼児」として通ってしまいます。
題材2:注文数量と送料(2値境界値分析)
仕様
| 注文数量 | 送料 |
|---|---|
| 1〜4個 | 800円 |
| 5〜9個 | 400円 |
| 10〜99個 | 無料(0円) |
| 0以下、100以上 | エラー |
同値クラス表と境界値表(2値)
| ID | 種類 | 同値クラス | 代表値 | 境界値(2値) |
|---|---|---|---|---|
| EQ1 | 有効 | 1〜4(800円) | 2 | 1、4 |
| EQ2 | 有効 | 5〜9(400円) | 7 | 5、9 |
| EQ3 | 有効 | 10〜99(無料) | 50 | 10、99 |
| EQ4 | 無効 | 0以下 | -5 | 0 |
| EQ5 | 無効 | 100以上 | 200 | 100 |
2値境界値分析では、境界をはさむ「隣り合う2つの値」がそろうように選びます。 0と1、4と5、9と10、99と100 の4組で、すべての境界を確認できます。
テストケース表
| ID | 入力(数量) | 期待結果(送料) | 観点 |
|---|---|---|---|
| Q01 | 1 | 800 | 有効の下限 |
| Q02 | 4 | 800 | 800円の上限 |
| Q03 | 5 | 400 | 400円の下限 |
| Q04 | 9 | 400 | 400円の上限 |
| Q05 | 10 | 0 | 無料の下限 |
| Q06 | 99 | 0 | 有効の上限 |
| Q07 | 0 | ValueError | 下限の外側 |
| Q08 | 100 | ValueError | 上限の外側 |
題材3:文字数制限の入力欄(2値境界値分析+よくある抜け)
仕様
| 条件 | 内容 |
|---|---|
| 項目 | ニックネーム(必須) |
| 文字数 | 前後の空白を除いて 2〜20 文字 |
| 文字の数え方 | 全角・半角とも1文字 |
| エラー文 | 未入力:「必須です」/1文字以下:「2文字以上で入力してください」/21文字以上:「20文字以内で入力してください」 |
同値クラス表・境界値表
| ID | 種類 | 同値クラス | 境界値(2値) |
|---|---|---|---|
| EN1 | 有効 | 2〜20文字 | 2、20 |
| EN2 | 無効 | 1文字 | 1 |
| EN3 | 無効 | 21文字以上 | 21 |
| EN4 | 無効 | 未入力(null、空文字、空白のみ) | 0 |
テストケース表
| ID | 入力 | 期待結果 | 観点 |
|---|---|---|---|
| N01 | null(None) | 必須です | null |
| N02 | ""(空文字) | 必須です | 空 |
| N03 | " "(空白のみ) | 必須です | 空白だけ |
| N04 | "a"(1文字) | 2文字以上で入力してください | 下限の外側 |
| N05 | "ab"(2文字) | OK | 下限 |
| N06 | "a"×20 | OK | 上限 |
| N07 | "a"×21 | 20文字以内で入力してください | 最大長+1 |
| N08 | "あ"×20 | OK | 全角の上限 |
| N09 | "あ"×21 | 20文字以内で入力してください | 全角の最大長+1 |
| N10 | " ab "(前後に空白) | OK | 空白除去後の下限 |
文字数の仕様は「何を1文字と数えるか」を先に決めておくことが大切です。 Python の len() は文字(コードポイント)単位で数えるので全角も1文字ですが、DB の列がバイト数で制限されている場合や、絵文字・結合文字(見た目は1文字でも複数のコードポイント)を扱う場合は、境界値が仕様とずれることがあります。
pytest の parametrize で全ケースを実装する
テストケース表の1行を、pytest.param(入力, 期待結果, id="ケースID") の1行に対応させると、表とコードを突き合わせやすくなります。 id にケース ID を入れておくと、失敗したときにどの行の確認が通らなかったのかがすぐにわかります。
環境:Python 3.11.15、pytest 9.1.1。
python3 -m venv .venv && . .venv/bin/activate
pip install pytest
テスト対象のコード(rules.py)
# rules.py テスト対象の関数(Python 3.11)
def age_category(age):
"""年齢から料金区分を返す。0〜5:幼児 / 6〜12:子供 / 13〜64:大人 / 65〜120:シニア"""
if not isinstance(age, int) or isinstance(age, bool):
raise TypeError("age must be int")
if age < 0 or age > 120:
raise ValueError("age out of range")
if age <= 5:
return "幼児"
if age <= 12:
return "子供"
if age < 65:
return "大人"
return "シニア"
def shipping_fee(qty):
"""注文数量(1〜99)から送料を返す。1〜4個:800円 / 5〜9個:400円 / 10個以上:0円"""
if not isinstance(qty, int) or isinstance(qty, bool):
raise TypeError("qty must be int")
if qty < 1 or qty > 99:
raise ValueError("qty out of range")
if qty < 5:
return 800
if qty < 10:
return 400
return 0
def validate_nickname(name):
"""ニックネーム:前後の空白を除いて 2〜20 文字(全角・半角とも1文字)。OK なら None、NG なら理由を返す"""
if name is None:
return "必須です"
s = name.strip()
if s == "":
return "必須です"
if len(s) < 2:
return "2文字以上で入力してください"
if len(s) > 20:
return "20文字以内で入力してください"
return None
テストコード(test_rules.py)
# test_rules.py (pytest 9.1.1)
import os
import pytest
# 環境変数 TARGET=rules_buggy でバグ入りの実装をテストする
target = __import__(os.environ.get("TARGET", "rules"))
age_category = target.age_category
shipping_fee = target.shipping_fee
validate_nickname = target.validate_nickname
# ---------- 題材1:年齢による区分(3値境界値+無効値) ----------
@pytest.mark.parametrize(
"age, expected",
[
pytest.param(0, "幼児", id="A01-min"),
pytest.param(1, "幼児", id="A02-min_plus1"),
pytest.param(5, "幼児", id="A03-infant_max"),
pytest.param(6, "子供", id="A04-child_min"),
pytest.param(7, "子供", id="A05-child_min_plus1"),
pytest.param(11, "子供", id="A06-child_max_minus1"),
pytest.param(12, "子供", id="A07-child_max"),
pytest.param(13, "大人", id="A08-adult_min"),
pytest.param(64, "大人", id="A09-adult_max"),
pytest.param(65, "シニア", id="A10-senior_min"),
pytest.param(66, "シニア", id="A11-senior_min_plus1"),
pytest.param(120, "シニア", id="A12-max"),
],
)
def test_age_valid(age, expected):
assert age_category(age) == expected
@pytest.mark.parametrize(
"age, error",
[
pytest.param(-1, ValueError, id="A13-below_min"),
pytest.param(121, ValueError, id="A14-above_max"),
pytest.param(None, TypeError, id="A15-null"),
pytest.param("20", TypeError, id="A16-str"),
pytest.param(20.5, TypeError, id="A17-float"),
pytest.param(True, TypeError, id="A18-bool"),
],
)
def test_age_invalid(age, error):
with pytest.raises(error):
age_category(age)
# ---------- 題材2:注文数量と送料(2値境界値) ----------
@pytest.mark.parametrize(
"qty, expected",
[
pytest.param(1, 800, id="Q01-min"),
pytest.param(4, 800, id="Q02-fee800_max"),
pytest.param(5, 400, id="Q03-fee400_min"),
pytest.param(9, 400, id="Q04-fee400_max"),
pytest.param(10, 0, id="Q05-free_min"),
pytest.param(99, 0, id="Q06-max"),
],
)
def test_shipping_valid(qty, expected):
assert shipping_fee(qty) == expected
@pytest.mark.parametrize(
"qty", [pytest.param(0, id="Q07-zero"), pytest.param(100, id="Q08-max_plus1")]
)
def test_shipping_invalid(qty):
with pytest.raises(ValueError):
shipping_fee(qty)
# ---------- 題材3:文字数制限の入力欄(2値境界値+よくある抜け) ----------
@pytest.mark.parametrize(
"name, expected",
[
pytest.param(None, "必須です", id="N01-null"),
pytest.param("", "必須です", id="N02-empty"),
pytest.param(" ", "必須です", id="N03-spaces"),
pytest.param("a", "2文字以上で入力してください", id="N04-len1"),
pytest.param("ab", None, id="N05-len2"),
pytest.param("a" * 20, None, id="N06-len20"),
pytest.param("a" * 21, "20文字以内で入力してください", id="N07-len21"),
pytest.param("あ" * 20, None, id="N08-zen20"),
pytest.param("あ" * 21, "20文字以内で入力してください", id="N09-zen21"),
pytest.param(" ab ", None, id="N10-trim_len2"),
],
)
def test_nickname(name, expected):
assert validate_nickname(name) == expected
実行結果(正しい実装)
pytest -v test_rules.py
platform linux -- Python 3.11.15, pytest-9.1.1, pluggy-1.6.0
test_rules.py::test_age_valid[A01-min] PASSED [ 2%]
test_rules.py::test_age_valid[A02-min_plus1] PASSED [ 5%]
test_rules.py::test_age_valid[A03-infant_max] PASSED [ 8%]
test_rules.py::test_age_valid[A04-child_min] PASSED [ 11%]
test_rules.py::test_age_valid[A05-child_min_plus1] PASSED [ 13%]
test_rules.py::test_age_valid[A06-child_max_minus1] PASSED [ 16%]
test_rules.py::test_age_valid[A07-child_max] PASSED [ 19%]
test_rules.py::test_age_valid[A08-adult_min] PASSED [ 22%]
test_rules.py::test_age_valid[A09-adult_max] PASSED [ 25%]
test_rules.py::test_age_valid[A10-senior_min] PASSED [ 27%]
test_rules.py::test_age_valid[A11-senior_min_plus1] PASSED [ 30%]
test_rules.py::test_age_valid[A12-max] PASSED [ 33%]
test_rules.py::test_age_invalid[A13-below_min] PASSED [ 36%]
test_rules.py::test_age_invalid[A14-above_max] PASSED [ 38%]
test_rules.py::test_age_invalid[A15-null] PASSED [ 41%]
test_rules.py::test_age_invalid[A16-str] PASSED [ 44%]
test_rules.py::test_age_invalid[A17-float] PASSED [ 47%]
test_rules.py::test_age_invalid[A18-bool] PASSED [ 50%]
test_rules.py::test_shipping_valid[Q01-min] PASSED [ 52%]
test_rules.py::test_shipping_valid[Q02-fee800_max] PASSED [ 55%]
test_rules.py::test_shipping_valid[Q03-fee400_min] PASSED [ 58%]
test_rules.py::test_shipping_valid[Q04-fee400_max] PASSED [ 61%]
test_rules.py::test_shipping_valid[Q05-free_min] PASSED [ 63%]
test_rules.py::test_shipping_valid[Q06-max] PASSED [ 66%]
test_rules.py::test_shipping_invalid[Q07-zero] PASSED [ 69%]
test_rules.py::test_shipping_invalid[Q08-max_plus1] PASSED [ 72%]
test_rules.py::test_nickname[N01-null] PASSED [ 75%]
test_rules.py::test_nickname[N02-empty] PASSED [ 77%]
test_rules.py::test_nickname[N03-spaces] PASSED [ 80%]
test_rules.py::test_nickname[N04-len1] PASSED [ 83%]
test_rules.py::test_nickname[N05-len2] PASSED [ 86%]
test_rules.py::test_nickname[N06-len20] PASSED [ 88%]
test_rules.py::test_nickname[N07-len21] PASSED [ 91%]
test_rules.py::test_nickname[N08-zen20] PASSED [ 94%]
test_rules.py::test_nickname[N09-zen21] PASSED [ 97%]
test_rules.py::test_nickname[N10-trim_len2] PASSED [100%]
============================== 36 passed in 0.03s ==============================
表の36ケースがすべて通りました。 -v を付けると、[Q03-fee400_min] のようにケース ID ごとの結果が表示されます。
わざと入れた境界のバグを境界値テストで検出する
ここでは、送料の判定の qty < 5 を qty <= 5 と書き間違えたバグ入りの実装(rules_buggy.py)を用意し、同値分割の代表値だけのテストと、境界値テストで結果を比べます。
--- rules.py
+++ rules_buggy.py
- if qty < 5:
+ if qty <= 5: # ← バグ:正しくは qty < 5
代表値だけのテスト(同値分割のみ)
# test_ep_only.py 同値分割の代表値だけで作ったテスト(比較用)
import os
import pytest
shipping_fee = __import__(os.environ.get("TARGET", "rules")).shipping_fee
@pytest.mark.parametrize("qty, expected", [(2, 800), (7, 400), (50, 0)])
def test_shipping_representative(qty, expected):
assert shipping_fee(qty) == expected
TARGET=rules_buggy pytest -v test_ep_only.py
test_ep_only.py::test_shipping_representative[2-800] PASSED [ 33%]
test_ep_only.py::test_shipping_representative[7-400] PASSED [ 66%]
test_ep_only.py::test_shipping_representative[50-0] PASSED [100%]
============================== 3 passed in 0.02s ===============================
代表値 2・7・50 は、どれもバグの影響を受けないため、すべて通ってしまいました。
境界値テスト
TARGET=rules_buggy pytest -q test_rules.py
....................F............... [100%]
=================================== FAILURES ===================================
_____________________ test_shipping_valid[Q03-fee400_min] ______________________
qty = 5, expected = 400
@pytest.mark.parametrize(
"qty, expected",
[
pytest.param(1, 800, id="Q01-min"),
pytest.param(4, 800, id="Q02-fee800_max"),
pytest.param(5, 400, id="Q03-fee400_min"),
pytest.param(9, 400, id="Q04-fee400_max"),
pytest.param(10, 0, id="Q05-free_min"),
pytest.param(99, 0, id="Q06-max"),
],
)
def test_shipping_valid(qty, expected):
> assert shipping_fee(qty) == expected
E assert 800 == 400
E + where 800 = shipping_fee(5)
test_rules.py:64: AssertionError
=========================== short test summary info ============================
FAILED test_rules.py::test_shipping_valid[Q03-fee400_min] - assert 800 == 400
1 failed, 35 passed in 0.04s
境界値の 5 個(Q03)だけが失敗し、「400円になるはずが800円」と原因の箇所まで特定できました。 同値分割で範囲を決め、境界値分析で境目を確かめる、という組み合わせの効果がわかります。
2値では見つからず、3値で見つかるバグ
片側だけの条件では、2値境界値分析で見つからないバグがあります。 「購入金額が1000円以上ならポイント2倍」という仕様を、>= ではなく == と書いてしまった例で確認します。
# point.py 仕様:購入金額が 1000 円以上ならポイント2倍
def is_double_point(amount: int) -> bool:
return amount == 1000 # ← バグ:正しくは amount >= 1000
# test_point.py 2値と3値の境界値テストを比べる
import pytest
from point import is_double_point
@pytest.mark.parametrize("amount, expected", [(999, False), (1000, True)])
def test_two_value(amount, expected): # 2値:境界値と、隣のパーティションの最も近い値
assert is_double_point(amount) is expected
@pytest.mark.parametrize("amount, expected", [(999, False), (1000, True), (1001, True)])
def test_three_value(amount, expected): # 3値:境界値と、その両隣
assert is_double_point(amount) is expected
test_point.py::test_two_value[999-False] PASSED
test_point.py::test_two_value[1000-True] PASSED
test_point.py::test_three_value[999-False] PASSED
test_point.py::test_three_value[1000-True] PASSED
test_point.py::test_three_value[1001-True] FAILED
E assert False is True
E + where False = is_double_point(1001)
FAILED test_point.py::test_three_value[1001-True] - assert False is True
========================= 1 failed, 4 passed in 0.03s ==========================
(pytest -v test_point.py の出力から、結果の行とエラーの行を抜き出しています。)2値の 999・1000 はどちらも期待どおりに見えますが、3値で足した 1001 で誤りが見つかりました。 上限のない条件では、境界の内側の値を確かめる3値が役立ちます。
複数の入力の組み合わせ:デシジョンテーブル・ペアワイズとの使い分け
同値分割・境界値分析は「1つの入力の値」を絞り込む技法です。入力が複数あり、その組み合わせで結果が変わる場合は、デシジョンテーブルやペアワイズを組み合わせます。
| 技法 | 向いている場面 | テストの数 | 例 |
|---|---|---|---|
| 同値分割 | 1つの入力の値の種類が多い | クラス数 | 年齢、数量 |
| 境界値分析 | 値に順序があり、境目で結果が変わる | 境界の数×2(3値は×3) | 1〜99個、2〜20文字 |
| デシジョンテーブル | 条件の組み合わせで結果が決まるルール | 有効なルール(列)の数 | 会員種別×数量×配送地域で送料 |
| ペアワイズ(オールペア法) | 互いに独立した設定項目が多く、全組み合わせが多すぎる | 全組み合わせより大幅に少ない | 端末×ブラウザ×言語×会員種別 |
| 状態遷移テスト | 状態によって受け付ける操作が変わる | 遷移の数 | 注文の受付→発送→完了 |
デシジョンテーブルの例
仕様:プレミアム会員は送料無料。一般会員は10個以上で無料、それ以外は800円。配送先が遠方地域の場合は、無料の場合も含めて1000円を加算します。
| 条件/ルール | R1 | R2 | R3 | R4 | R5 | R6 |
|---|---|---|---|---|---|---|
| プレミアム会員 | Y | Y | N | N | N | N |
| 数量10個以上 | ― | ― | Y | Y | N | N |
| 遠方地域 | N | Y | N | Y | N | Y |
| 送料 | 0 | 1000 | 0 | 1000 | 800 | 1800 |
# test_decision.py デシジョンテーブル → parametrize
import pytest
def shipping_fee_v2(qty: int, premium: bool, remote_area: bool) -> int:
"""プレミアム会員は無料。一般は10個以上で無料、それ以外800円。遠方地域は +1000円(無料の場合も加算)"""
base = 0 if premium or qty >= 10 else 800
return base + (1000 if remote_area else 0)
@pytest.mark.parametrize(
"rule, qty, premium, remote, expected",
[
("R1", 3, True, False, 0),
("R2", 3, True, True, 1000),
("R3", 10, False, False, 0),
("R4", 10, False, True, 1000),
("R5", 3, False, False, 800),
("R6", 3, False, True, 1800),
],
)
def test_decision_table(rule, qty, premium, remote, expected):
assert shipping_fee_v2(qty, premium, remote) == expected
6 passed in 0.01s
デシジョンテーブルの「数量10個以上」の Y/N を決める値に、境界値(9 と 10)を使うと、組み合わせと境界の両方を確認できます。
ペアワイズの例(allpairspy)
ペアワイズは「どの2つの項目を取り出しても、値の組み合わせが1回以上出てくる」ようにテストを選ぶ方法です。 多くの不具合は1つか2つの条件の組み合わせで起きる、という考え方にもとづいています。
pip install allpairspy # 2.5.1 で確認
# pairwise.py (allpairspy 2.5.1)
from itertools import combinations, product
from allpairspy import AllPairs
params = [
["PC", "スマホ", "タブレット"], # 端末
["Chrome", "Edge", "Safari", "Firefox"], # ブラウザ
["日本語", "英語"], # 表示言語
["一般会員", "プレミアム会員"], # 会員種別
]
all_count = len(list(product(*params)))
cases = list(AllPairs(params))
print(f"全組み合わせ: {all_count} 件 / ペアワイズ: {len(cases)} 件")
for i, c in enumerate(cases, 1):
print(f"{i:2}: {' / '.join(c)}")
# すべての2因子の値の組が1回以上出てくるか確認
need = {(i, a, j, b) for i, j in combinations(range(len(params)), 2)
for a in params[i] for b in params[j]}
got = {(i, c[i], j, c[j]) for c in cases for i, j in combinations(range(len(params)), 2)}
print("2因子の組をすべて網羅:", need <= got, f"({len(need)} 組)")
全組み合わせ: 48 件 / ペアワイズ: 12 件
1: PC / Chrome / 日本語 / 一般会員
2: スマホ / Edge / 英語 / 一般会員
3: タブレット / Safari / 英語 / プレミアム会員
4: タブレット / Firefox / 日本語 / 一般会員
5: スマホ / Firefox / 日本語 / プレミアム会員
6: PC / Safari / 日本語 / 一般会員
7: PC / Edge / 日本語 / プレミアム会員
8: PC / Firefox / 英語 / 一般会員
9: スマホ / Chrome / 英語 / プレミアム会員
10: タブレット / Chrome / 日本語 / 一般会員
11: タブレット / Edge / 日本語 / 一般会員
12: スマホ / Safari / 日本語 / 一般会員
2因子の組をすべて網羅: True (44 組)
48件の全組み合わせが12件になり、2項目の値の組(44組)はすべて含まれていることをコードで確認できました。 ただし、3項目以上の組み合わせでだけ起きる不具合は対象外になるため、重要な組み合わせ(例:特定の端末と会員種別の組)は個別に追加します。
よくある抜けチェックリスト(0・空・最大長+1・全角・null)
境界値分析に加えて、次の値を1つずつ確認すると、入力チェックの抜けを減らせます。
| 観点 | 試す値 | 確認すること |
|---|---|---|
| 0 | 0、0.0、"0" | 0が有効か無効か、仕様に書いてあるか |
| 負の数 | -1 | 数量・金額でマイナスを受け付けないか |
| 空 | ""、[]、{} | 必須チェックが働くか |
| 空白のみ | " "、全角スペース | 前後の空白を除いてから判定しているか |
| null | None、null、項目そのものがない | 「未入力」と同じ扱いになるか、500 エラーにならないか |
| 最大長 | ちょうど最大長 | 保存・表示で切れないか |
| 最大長+1 | 最大長より1文字多い | エラーになるか、黙って切り捨てていないか |
| 全角 | 全角の文字・数字 | 文字数の数え方、全角数字を数値として受け付けるか |
| 絵文字・結合文字 | 絵文字、濁点の結合文字 | 見た目の文字数と len() の違い |
| 型の違い | 数値欄に文字列、小数、真偽値 | 型チェックで弾けるか(Python の bool に注意) |
| 最大値・最小値 | 整数の上限、日付の範囲 | 桁あふれや日付の変換エラーがないか |
| 日付の境界 | 月末、うるう年の2月29日、年の切り替わり | 期間の判定がずれないか |
テスト仕様書・単体テスト・pytest の記事との関係
この記事は「どの値をテストするか」を決める技法に絞っています。 テスト仕様書の書式や観点の洗い出しは既存記事「テスト仕様書の書き方」、単体テストと結合テストの範囲の違いは「単体テストと結合テストの違い」、fixture や mock など pytest 全体の使い方は「pytest 使い方」で扱っています。
| 工程 | この記事の技法の使いどころ |
|---|---|
| テスト設計 | 同値クラス表・境界値表でテストする値を決める |
| テスト仕様書 | テストケース表をそのまま転記する(ケースIDをそろえる) |
| 単体テストの実装 | テストケース表 → pytest.param に1行ずつ対応 |
| 不具合の分析 | 失敗したケース ID から、どの境界かを特定する |
よくある質問
Q. 同値分割と境界値分析は、どちらを先にやりますか? A. 同値分割が先です。 まず同値クラスを分けないと、どこが境界なのかが決まりません。同値クラス表を作り、その最小値・最大値を境界値表に書き出す順番で進めます。
Q. 境界値をテストすれば、代表値のテストは不要ですか? A. 多くの場合、境界値のテストが代表値の役割も兼ねます。 ただし、同値クラスの中で計算結果が変わる処理(数量に応じた金額計算など)では、範囲の中ほどの値も1つ入れておくと安心です。
Q. 2値と3値、どちらを使えばよいですか? A. 範囲の両端が決まっている単純な条件は2値、片側だけの条件や結果への影響が大きい条件は3値がおすすめです。本文の「ポイント2倍」の例のように、3値でしか見つからない誤りもあります。
Q. 浮動小数点や日付の境界はどう選びますか? A. 「隣の値」をどの精度で取るかを仕様で決めます。 金額が小数第2位までなら 999.99 と 1000.00、日付なら前日と当日です。時刻を含む場合は、タイムゾーンと「その日の終わり」の扱いも確認します。
まとめ
- 同値分割は値を同値クラスに分けて代表値をテスト、境界値分析はその境目をテストする技法で、組み合わせて使う
- JSTQB FL v4.0 では、2値は「境界値と隣のパーティションの最も近い値」、3値は「境界値と両隣」。境界値分析は順序のある値に使う
- 仕様 → 同値クラス表 → 境界値表 → テストケース表の順に作り、ケース ID で pytest の
pytest.paramとそろえる - 代表値だけのテストは
<と<=の取り違えを見逃し、境界値テストは検出することを実行結果で確認した - 片側だけの条件では3値境界値分析が有効。
>=を==と書いた誤りは3値で見つかった - 複数入力の組み合わせはデシジョンテーブルやペアワイズと使い分け、境界値と組み合わせる
- 0・空・null・最大長+1・全角・真偽値のチェックリストで、入力チェックの抜けを減らす
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「同値分割・境界値分析|テストケース表とpytestコード例で解説」
株式会社RJCでは、テスト技法を仲間と学び合っています。こうした知識を共有しながら、日々技術を磨いています。
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。