1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

仕様から欠陥を引き出す——ブラックボックステスト4つの設計技法

1
Posted at

はじめに

仕様書を渡されてAIにテストコードを書かせた——けれど生成されたケースを眺めて、
「これで本当に十分なのか」と手が止まる、そんな経験はありませんか。

機能としては「ユーザーの年齢に応じて送料を変える」だけ。
コードも数十行しかない。
それでもAIが並べてくれたテストケースを前にすると、「いったい何個あれば足りるのか」
「この入力は試しているけど、別の入力で落ちたりしないか」と不安になります。

入力の組み合わせは事実上無限です。
全部試すのは無理だと頭ではわかっていても、AIが拾ったケースが本当に網羅しているのか、抜け漏れがないのか、直感だけだと判断できない。
レビューで「このケース漏れてない?」と指摘されてからしか気付けないのは、正直しんどいところです。

この記事では、その不安に効く設計技法として、
仕様の構造から欠陥を引き出すブラックボックステストを扱います。
ブラックボックステストはコードの中身ではなく、
仕様の入出力だけを見てテストを設計する手法です。
仕様(specification)はここでは「機能が何をすべきかを書いた文書」を指します。

紹介する技法は4つに絞ります。同値分割、境界値分析、決定表、状態遷移テストです。

TL;DR

  • ブラックボックステストは「仕様の構造を読み解いて欠陥を引き出す」設計技法。
    思いつきの羅列ではない
  • 仕様の構造には「入力範囲」「条件の組み合わせ」「状態」の3パターンがあり、
    それぞれに対応する技法(同値分割+境界値/決定表/状態遷移)がある
  • 1つの仕様に複数の構造があれば、技法を組み合わせて当てる

なぜ仕様起点なのか

テストの目的は欠陥を見つけることです。では、欠陥はどこに潜んでいるのでしょうか。

思いつきテストの限界

経験や直感だけでテストケースを並べると、
自分が今までに見たことのあるバグしか見つかりません。
これはAIに頼んでも同じです。「この関数のテストを書いて」とだけ伝えると、AIはAIなりの「よくあるパターン」を並べてきます。20歳、65歳、変な文字列
——ぱっと見もっともらしいケースが並びますが、これはAIが学習データから引っ張ってきた典型パターンに過ぎません。

人間が手で書こうが、AIに丸投げしようが、「仕様書のどこに欠陥が生まれやすいか」を意識して設計していないと、結局は誰かの頭の中にあるパターンの寄せ集めになります。

結果として、想定したパターンしかカバーされず、想定外の入力で落ちます。
レビューで「マイナスの年齢は?」「999歳は?」と指摘されてから慌てて足す、という流れになりがちです。

仕様には「欠陥が生まれやすい構造」がある

ここで発想を変えます。テストケースを「思いつき」で増やすのではなく、
仕様の中に欠陥が生まれやすい構造を見つけて、そこを狙い撃ちするという考え方です。

仕様を眺めていると、繰り返し出てくる3つの構造が見えてきます。

入力に範囲や種類があれば「同値分割」と「境界値分析」、複数の条件が結果を決めるなら「決定表」、過去の入力が今の挙動を変えるなら「状態遷移テスト」が効きます。

実務では「仕様理解→入力分析→分割→境界→ケース化」のような大まかな流れで進めることが多いです。
本記事ではこの中の「分割」「境界」を含む4つの技法そのものに絞って中身を見ていきます。

入力空間を切り分ける——同値分割

最初に取り上げるのは同値分割(Equivalence Partitioning)です。
同値分割は、同じ振る舞いをする入力をひとくくりにして、その塊から代表値を1つだけ選んでテストする技法です。

同じ振る舞いをするものをひとくくりにする

たとえば、ある関数のテストを書くとします。
年齢を整数で受け取って、送料を返す関数です。

入力できる整数は事実上無限にあります。0、1、2、3……と全部試すのは無理です。
じゃあ何個書けばいいのか。

ここで、同値分割の発想が効いてきます。
仕様を見て、「同じ結果を返す入力たち」をひとつの塊として捉えるのです。
この塊を 同値クラス(partition) と呼びます。
同じ塊の中の入力はどれを試しても同じ結果になるはず——という前提に立てば、
塊から1つ代表値を選んでテストすれば十分、ということになります。

具体例: 年齢で送料が変わるECサイト

具体的に考えます。次のような仕様の関数があるとしましょう。

年齢を整数で受け取り、送料を返す。

  • 18歳未満: 購入不可(エラー)
  • 18歳以上65歳未満: 通常送料 500円
  • 65歳以上: 送料無料 0円

この仕様だと、年齢を「同じ結果を返す塊」で切ると次のようになります。

image.png

これに加えて、仕様には書かれていない「不正値」も同値クラスとして扱います。
具体的にはマイナスの数値、文字列、Noneなど、仕様の対象外の入力です。

代表値を1つずつ選んで、Pythonのpytestでテストを書くと次のようになります。

import pytest

def calculate_shipping(age: int) -> int:
    if age < 18:
        raise ValueError("購入できません")
    if age < 65:
        return 500
    return 0


@pytest.mark.parametrize("age, expected", [
    (10, "error"),   # 0〜17歳の代表
    (30, 500),       # 18〜64歳の代表
    (70, 0),         # 65歳以上の代表
])
def test_shipping_valid_classes(age, expected):
    if expected == "error":
        with pytest.raises(ValueError):
            calculate_shipping(age)
    else:
        assert calculate_shipping(age) == expected


@pytest.mark.parametrize("invalid_input", [-5, "abc", None])
def test_shipping_invalid_inputs(invalid_input):
    with pytest.raises((ValueError, TypeError)):
        calculate_shipping(invalid_input)

無限にあった入力空間が、3つの有効値クラスの代表(10, 30, 70)と無効値の代表に絞り込めました。これが同値分割の効果です。

有効値と無効値の両方を切る

同値分割でよく抜けがちなのが、無効値の同値クラスです。
仕様に書かれていない入力——None、負数、桁あふれ、文字列、空文字など——
も「同じように弾かれるべき入力の塊」として捉えます。

仕様に書かれた有効な範囲だけを切り分けて満足してしまうと、
不正値が来たときの挙動がノーテストのまま残ります。
実運用では不正値こそが落とし穴になりやすいので、有効値と無効値の両方を切るのが基本です。

同値分割だけだと拾えない欠陥がある

同値分割で代表値を選ぶとき、塊の真ん中あたりの値(30歳、70歳など)を選ぶ人が多いです。それで一通りのクラスはカバーできます。

ただ、ここで一つ疑問が出てきます。クラスの真ん中の値だけテストすれば、本当に十分なのでしょうか。実は、欠陥が集まりやすい場所がもう1つあります。

怪しい場所を狙う——境界値分析

境界値分析(Boundary Value Analysis)は、同値クラスの境目(境界)に欠陥が集中するという事実を利用した技法です。

なぜ境界に欠陥が集まるのか

同値分割では「18歳未満」「18〜64歳」「65歳以上」のようにクラスを切りました。
この クラスの境目(17と18の間、64と65の間) こそが、欠陥の巣になります。

理由はいくつかあります。

  • <<= の取り違え(age < 18 と書くべきところを age <= 18 と書いてしまう)
  • ループの終了条件のズレ(range(0, 10) で 10 が含まれるかどうかの勘違い)
  • 仕様自体の曖昧さ(「18歳から」と書かれているとき、18歳ちょうどは含まれるか)

開発者がバグを作りやすい場所は、テスターが先回りして攻めるべき場所です。

境界の両側を1点ずつテストする

境界を狙うときの基本は、境界の両側を1点ずつテストすることです。
境界の値そのものと、境界をはさんで隣にある値の両方を試して、境目の挙動が正しいかを確認します。

たとえば「age >= 18 なら購入可」という仕様であれば、

  • 18(境界そのものの値、いわゆる on点)
  • 17(境界をはさんで反対側の隣の値、いわゆる off点)

の両方を試します。両方が期待どおりの結果になっていれば、境目の判定が正しく書けていると判断できます。

数直線で見ると、こんなイメージです。

image.png

具体例: 年齢入力に境界値を当てる

先ほどの送料計算の例に境界値を当ててみます。

@pytest.mark.parametrize("age, expected", [
    # 「購入不可 / 通常送料」の境界
    (17, "error"),   # 境界の手前(off点)
    (18, 500),       # 境界そのもの(on点)
    # 「通常送料 / 送料無料」の境界
    (64, 500),       # 境界の手前(off点)
    (65, 0),         # 境界そのもの(on点)
])
def test_shipping_boundaries(age, expected):
    if expected == "error":
        with pytest.raises(ValueError):
            calculate_shipping(age)
    else:
        assert calculate_shipping(age) == expected

このテストが通れば、<<= の取り違えはまず起きていないと言い切れます。
同値分割で6ケース(有効値3+無効値3)、境界値で4ケース足して、合計10ケースで仕様の主要な振る舞いをカバーできました。

ここで扱った境界は「仕様に書かれた境界」です。
実装上は、仕様には現れない別の境界(配列の最大長、バッファサイズ、整数の桁あふれなど)も存在します。
これらはコード側を見ないと拾えないため、本記事のスコープ(仕様起点のテスト設計)からは外れます。

条件の組み合わせを攻める——決定表

ここまでの2つの技法は、「1つの入力変数」を切り分ける道具でした。
年齢という1つの変数を範囲で切る、その境目を狙う、という形です。

では、入力が複数の条件で組み合わさる場合はどうすればいいでしょうか。

条件の組み合わせは「決定表」で見える化する

たとえば「会員区分(一般/プレミアム)」「クーポンの有無」「購入額(10000円以上か未満か)」の3つで割引率が決まる仕様を想像してください。
条件は3つ、それぞれが2値を取るので、組み合わせは2³=8通りです。

これを散文で書くとすぐ混乱します。「プレミアム会員でクーポンありで10000円以上のときは……」と条件を言葉で並べると、抜け漏れに気付きにくくなります。

そこで使うのが 決定表(Decision Table) です。
決定表は、条件と結果をマトリクス形式で並べた表で、各列が1つのテストケースに対応します。
条件を列挙して、組み合わせを尽くして、それぞれの結果を仕様から決める
——この手順を踏むことで、組み合わせの抜けを構造的に防げます。

具体例: 割引判定ロジック

仕様を次のように決めます。

ECサイトの割引率を決定する。

  • プレミアム会員かつクーポン保持: 15%引き
  • プレミアム会員のみ(クーポンなし): 10%引き
  • 一般会員かつクーポン保持かつ10000円以上: 5%引き
  • それ以外: 割引なし

この仕様を決定表に落とすと次のようになります。

ケース プレミアム会員 クーポン保持 10000円以上 割引率
1 Yes Yes - 15%
2 Yes No - 10%
3 No Yes Yes 5%
4 No Yes No 0%
5 No No - 0%

「-」は ドントケア(Don't Care) と呼び、その条件が結果に影響しないことを示します。
たとえばプレミアム会員でクーポンがあれば、購入額にかかわらず15%引きになるので、購入額の列はドントケアです。

ドントケアを使うことで、本来なら8通り必要なケースが5通りに圧縮できました。
表の各行をそのままテストケースに落とすと、pytestでは次のように書けます。

def calculate_discount(is_premium: bool, has_coupon: bool, amount: int) -> float:
    if is_premium and has_coupon:
        return 0.15
    if is_premium:
        return 0.10
    if has_coupon and amount >= 10000:
        return 0.05
    return 0.0


@pytest.mark.parametrize("is_premium, has_coupon, amount, expected", [
    (True,  True,  5000,  0.15),  # ケース1
    (True,  False, 5000,  0.10),  # ケース2
    (False, True,  10000, 0.05),  # ケース3
    (False, True,  5000,  0.0),   # ケース4
    (False, False, 5000,  0.0),   # ケース5
])
def test_discount(is_premium, has_coupon, amount, expected):
    assert calculate_discount(is_premium, has_coupon, amount) == expected

決定表を作る手順は次のように整理できます。

  1. 結果に影響する条件をすべて列挙する
  2. 条件の取りうる値を組み合わせて行を作る
  3. 各組み合わせの結果を仕様から決める
  4. 結果に影響しない条件をドントケアにまとめて、行を圧縮する

組み合わせ爆発への向き合い方

条件が増えると、組み合わせは2のn乗で爆発します。条件が10個あれば1024通りです。
すべてを試すのは現実的じゃないです。

最初の手としては、先ほどのドントケアでの圧縮が有効です。

それでも収まらない場合は、
組み合わせを賢く間引く別の手法(直交配列・ペアワイズ法など)が存在します。
これらは決定表とは違う発想に立つ技法で、本記事では深入りしませんが、組み合わせ爆発に直面したときの選択肢として頭の片隅に置いておくと役立ちます。

過去の入力が結果を変える——状態遷移テスト

ここまでの3技法(同値分割・境界値・決定表)は、
同じ入力なら同じ結果になる」という前提で成り立っていました。

ところが、実際の機能には「過去の入力(=現在の状態)」が今の挙動を変えるものがあります。
たとえばログイン状態によってAPIの返り値が変わったり、
注文ワークフローのように段階を経て進むものです。

こういう機能のテストには、 状態遷移テスト(State Transition Testing) が効きます。
状態遷移テストは、機能を「状態」と「状態間の遷移」のモデルとして捉えて、
遷移を網羅するようにテストを設計する技法です。

状態を持つ機能の例

身近な例だと、ECサイトの注文ステータスがそうです。
注文は「カート」から始まり、「確定」「支払い済」「発送済」「完了」と段階を経ます。
途中で「キャンセル」されることもあります。

「支払い済」の注文に対して confirm() を呼んでも何も起きませんが、
「カート」の注文に confirm() を呼ぶと「確定」に進みます。
同じ confirm() という入力が、状態によって結果を変えるのです。

状態を持つ機能は、身近なところにいくつもあります。

  • ECサイトの注文ステータス(カート→確定→支払い済→発送済→完了)
  • ユーザーセッション(未ログイン→ログイン→ロックアウト)
  • ファイルのオープン状態(クローズ→オープン→読み書き中)
  • ステートマシンを持つAPI(コネクションの確立→通信→切断)

状態遷移図で「ありえる遷移」と「ありえない遷移」を見える化する

状態遷移テストでは、まず状態遷移図を書きます。状態をノード、遷移をエッジ(矢印)として図示する形です。注文ワークフローを書くと次のようになります。

図にすると、「ありえる遷移」と「ありえない遷移」がはっきり見えます。
たとえば「発送済から確定に戻る」ような遷移は仕様上ありえません。
図に矢印が描かれていないからです。

逆に言うと、図を書く過程で「この遷移はありえるんだっけ?」という疑問が浮かぶことがあります。これが状態遷移図の価値です。仕様の曖昧な部分が浮き上がります。

状態遷移表に落としてテストケースにする

図だけだとテストケースに直接落としづらいので、状態遷移表に変換します。
行を現在の状態、列を入力イベント、セルを次の状態にした表です。

現在の状態\イベント confirm pay ship deliver cancel
Cart Confirmed - - - Cancelled
Confirmed - Paid - - Cancelled
Paid - - Shipped - Cancelled
Shipped - - - Done -
Done - - - - -
Cancelled - - - - -

表を埋めることで、テストすべき遷移と、無効な遷移として確認すべき箇所が両方見えてきます。

何をテストするか

状態遷移テストの基本は、すべての遷移(矢印)を最低1度はテストすることです。
図に描いた矢印を全部辿るようにテストケースを並べます。

注文ワークフローの例なら、最低限のテストは次のようになります。

@pytest.mark.parametrize("from_state, event, expected_state", [
    ("Cart",      "confirm", "Confirmed"),
    ("Confirmed", "pay",     "Paid"),
    ("Paid",      "ship",    "Shipped"),
    ("Shipped",   "deliver", "Done"),
    ("Cart",      "cancel",  "Cancelled"),
    ("Confirmed", "cancel",  "Cancelled"),
    ("Paid",      "cancel",  "Cancelled"),
])
def test_valid_transitions(from_state, event, expected_state):
    order = Order(state=from_state)
    order.handle(event)
    assert order.state == expected_state

余裕があれば、無効な遷移(「Done」状態で confirm を呼ぶなど)でも状態が変わらないこと、もしくは適切なエラーになることも確認します。
状態遷移表で「-」になっているセルがそのまま無効遷移のテストケースになるので、表があると設計が機械的に進みます。

仕様の構造から技法を選ぶ

4つの技法を見てきました。実務では「いつどれを使うか」を判断する必要があります。

迷ったときに立ち戻る軸はシンプルです。
仕様の構造を見て、その構造に対応する技法を選ぶこと。
これまで紹介した4技法は、仕様の異なる構造に効く道具として並列に並んでいます。

仕様の構造×技法の対応表

仕様の構造 効く技法
入力に範囲や種類がある 年齢で送料が変わる、金額で割引率が変わる 同値分割 + 境界値分析
複数の条件が結果を決める 会員区分×クーポン×購入額で割引が決まる 決定表
過去の入力が結果を変える(状態がある) 注文ワークフロー、ログインセッション 状態遷移テスト

仕様書を読みながら、「これは範囲か?」「組み合わせか?」「状態か?」と問うてみると、どの技法を当てるべきかが自然と見えてきます。

1つの仕様に複数の構造がある場合は組み合わせる

実務の仕様は、1つの構造で綺麗に収まらないことのほうが多いです。

たとえば先ほどの注文ワークフローを思い出してください。
状態遷移という構造に加えて、それぞれの状態の中で「注文金額による割引判定」が走るかもしれません。
この場合は次のように技法を重ねて当てられます。

  • 状態遷移テスト: 注文ステータスの遷移(カート→確定→支払い済……)を網羅
  • 決定表: 各状態の中で走る割引判定(会員区分×クーポン×購入額)を網羅
  • 境界値分析: 割引のしきい値(10000円ちょうどか未満か)を狙う

つまり、1記事=1技法ではなく、仕様を分解して、各部分に合う技法を当てるのが実態に近い使い方です。

おわりに

ブラックボックステストの4つの技法は、それぞれ独立した道具のように見えて、根っこは1つに繋がっています。
仕様の構造を読み解いて、欠陥が生まれやすい場所にテストを当てに行く
——この考え方が共通の背骨です。

道具は道具であって、目的ではありません。
目的は欠陥を見つけることで、技法の名前を覚えることではありません。
仕様書を読みながら「これは範囲か?組み合わせか?状態か?」と問う癖をつけるだけで、テスト設計の解像度はぐっと上がります。

完璧な網羅は無理です。
それでも、思いつきで並べていた頃と比べれば、抜け漏れの量は段違いに減ります。
仕様の形に沿って攻めるだけで、テストは「不安なお守り」から「欠陥を狩る道具」に変わります。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?