一覧SE-05-1 · テストの設計

境界値・組み合わせ・状態遷移のテスト

仕様から入力の範囲・境界・条件の優先順・許可された遷移を取り出し、見落としていた失敗を検出します。

説明

正常な入力で一度成功しただけでは、境界や禁止された操作まで正しく処理できるかは分かりません。入力を同じ扱いになる範囲に分ける方法、範囲の端を試す方法、条件の組合せや操作順を試す方法を使い分けます。

数量1〜4の予約受付と、draft・paid・shipped・cancelledを持つ注文処理を扱います。用意したPython実装とunittestを実行し、代表値では通る不具合を境界値で見つけ、正常な操作順では見えない遷移の誤りも確認します。run_tests.pyはunittestの結果をテスト名と失敗理由で表示します。

仕様から入力の範囲・境界・条件の優先順・許可された遷移を取り出し、見落としていた失敗を検出します。

1予約と状態の仕様

まず何を満たすべきかを読みます。受付判定は数量、受付時間、在庫の順です。注文状態は現在の状態に応じて次へ進める先が変わります。

cat spec.txt入力範囲と遷移の仕様を表示する

数量が不正で受付も終了している場合はquantityを返します。条件が複数同時に成立したときの優先順も、期待結果を決めるために必要です。

2範囲の代表値だけでは通る

有効範囲の代表として2、下側の無効範囲として-1、上側として5を試します。

python3 run_tests.py test_booking.Representatives三つの範囲の代表値を試す

3件ともPASSです。同じ扱いになると想定した入力群を同値クラスと呼びます。ただし代表値の成功だけで、範囲の端の実装が正しいとは限りません。

30・1・3・4・5を追加

有効範囲の端とその隣を試します。在庫と受付状態は固定し、数量だけによる違いが分かるようにしています。

python3 run_tests.py test_booking.Boundaries上下限と隣接値を試す

0と4でFAILになります。0を受け入れ、4を拒否しているため、下限と上限の比較に誤りがあります。境界値分析はこのような端の取り違えを見つけるための方法です。

4比較演算子の違いを確認

修正案との差分を読みます。

diff -u数量判定の修正箇所を表示する -L booking.py -L booking-fixed.py booking.py booking-fixed.py

無効な数量は1未満または4より大きい値です。修正前の0未満・4以上という条件が、仕様と一つずつずれていました。

5修正して代表値と境界を再試験

修正案を反映し、代表値と境界値の両方を実行します。

cp booking-fixed.py booking.py数量の条件を修正する python3 run_tests.py test_booking.Representatives test_booking.Boundaries既存と追加のケースを実行する

8件が成功します。追加したケースだけでなく、以前から通っていた入力も確認し、変更の影響を調べています。

6条件の組合せを表にする

数量が正しいか、受付中か、在庫が足りるかを決定表で表します。anyは、その行の結果には影響しない条件です。

cat decisions.csv条件と結果の対応を読む python3 run_tests.py test_booking.Decisions組合せと優先順を試す

数量が不正なら他条件にかかわらずquantity、受付終了なら在庫にかかわらずclosedです。在庫が数量と等しい場合はacceptedになります。条件を単独で試すだけでなく、優先順と境界の組合せも確認しています。

7正常な注文の流れは成功する

注文をdraftからpaid、shippedへ進める試験を実行します。

python3 run_tests.py test_order.HappyPath支払後に出荷する正常経路を試す

正常な経路は通ります。しかし支払を飛ばした出荷や、出荷後の取消しが拒否されるかは、この試験では調べていません。

8禁止された遷移を実行する

未払いで出荷、出荷後に取消し、取消し後に支払という三つを試します。期待するのはValueErrorと元の状態の保持です。

python3 run_tests.py test_order.Forbidden禁止経路と失敗後の状態を試す

3件とも例外が発生せずFAILになります。目標状態の名前が正しいかを調べるだけでは、現在の状態から移ってよいかを判定できません。

9現在の状態ごとに次の候補を決める

修正案では、各状態から進める集合を定義しています。出荷済みと取消し済みの集合は空です。

diff -u遷移判定の変更を読む -L order.py -L order-fixed.py order.py order-fixed.py cp order-fixed.py order.py現在状態に応じた判定へ置き換える python3 run_tests.py test_order.HappyPath test_order.Forbidden正常経路と禁止経路を再試験する

4件が成功します。正常経路が保たれ、拒否した操作によって状態が変わらないことも確認できました。状態遷移の試験では、入力だけでなく操作前の状態と操作後の状態を対応させます。

まとめ

同値クラスの代表値と、範囲の端を狙う境界値は補い合います。

決定表では条件の組合せと優先順を明らかにし、入力が重なったときの期待結果を決めます。

状態遷移では正常な経路に加え、禁止された操作と拒否後に残る状態も確認します。

操作履歴と解説を振り返れます。

シェル / Python 3

1
cat spec.txt
予約数量は整数の1〜4。範囲外ならquantityを返す。
数量が正しくても受付終了ならclosedを返す。
受付中でも在庫が数量未満ならstockを返す。
すべてを満たせばacceptedを返す。
注文状態: draft→paid→shipped。draftまたはpaidからcancelledへ進める。
shippedとcancelledは終端状態で、その後の遷移は拒否する。
2
python3 run_tests.py test_booking.Representatives
PASS test_booking.Representatives.test_middle
PASS test_booking.Representatives.test_negative
PASS test_booking.Representatives.test_over
tests=3 failures=0 errors=0
3
python3 run_tests.py test_booking.Boundaries
PASS test_booking.Boundaries.test_five
FAIL test_booking.Boundaries.test_four: 'quantity' != 'accepted'
- quantity
+ accepted

PASS test_booking.Boundaries.test_one
PASS test_booking.Boundaries.test_three
FAIL test_booking.Boundaries.test_zero: 'accepted' != 'quantity'
- accepted
+ quantity

tests=5 failures=2 errors=0
4
diff -u -L booking.py -L booking-fixed.py booking.py booking-fixed.py
--- booking.py
+++ booking-fixed.py
@@ -1,5 +1,5 @@
 def reserve(quantity,stock,is_open):
-    if type(quantity) is not int or quantity < 0 or quantity >= 4:
+    if type(quantity) is not int or quantity < 1 or quantity > 4:
         return 'quantity'
     if not is_open:
         return 'closed'
5
cp booking-fixed.py booking.py python3 run_tests.py test_booking.Representatives test_booking.Boundaries
PASS test_booking.Representatives.test_middle
PASS test_booking.Representatives.test_negative
PASS test_booking.Representatives.test_over
PASS test_booking.Boundaries.test_five
PASS test_booking.Boundaries.test_four
PASS test_booking.Boundaries.test_one
PASS test_booking.Boundaries.test_three
PASS test_booking.Boundaries.test_zero
tests=8 failures=0 errors=0
6
cat decisions.csv python3 run_tests.py test_booking.Decisions
quantity_valid,is_open,stock_enough,result
false,any,any,quantity
true,false,any,closed
true,true,false,stock
true,true,true,accepted
PASS test_booking.Decisions.test_closed_even_without_stock
PASS test_booking.Decisions.test_invalid_even_if_closed
PASS test_booking.Decisions.test_stock_equal
PASS test_booking.Decisions.test_stock_short
tests=4 failures=0 errors=0
7
python3 run_tests.py test_order.HappyPath
PASS test_order.HappyPath.test_paid_then_shipped
tests=1 failures=0 errors=0
8
python3 run_tests.py test_order.Forbidden
FAIL test_order.Forbidden.test_cancel_after_shipping: ValueError not raised
FAIL test_order.Forbidden.test_reopen_cancelled: ValueError not raised
FAIL test_order.Forbidden.test_skip_payment: ValueError not raised
tests=3 failures=3 errors=0
9
diff -u -L order.py -L order-fixed.py order.py order-fixed.py cp order-fixed.py order.py python3 run_tests.py test_order.HappyPath test_order.Forbidden
--- order.py
+++ order-fixed.py
@@ -1,7 +1,8 @@
 class Order:
     def __init__(self):self.state='draft'
     def move(self,target):
-        if target not in {'paid','shipped','cancelled'}:
+        allowed={'draft':{'paid','cancelled'},'paid':{'shipped','cancelled'},'shipped':set(),'cancelled':set()}
+        if target not in allowed[self.state]:
             raise ValueError('transition rejected')
         self.state=target
         return self.state
PASS test_order.HappyPath.test_paid_then_shipped
PASS test_order.Forbidden.test_cancel_after_shipping
PASS test_order.Forbidden.test_reopen_cancelled
PASS test_order.Forbidden.test_skip_payment
tests=4 failures=0 errors=0

演習完了