不具合の再現と境界値テスト
商品金額1000以上なら送料無料、未満なら200、負数と整数以外は拒否する請求処理を扱います。境界の不具合を再現し、assertを入力検査に使う問題も確認してから、同じテストで修正前後を比較します。
説明
不具合を修正するときは、問題の入力を小さく再現し、仕様から決めた期待値をテストに残します。正常な値だけでなく、判定が切り替わる境界と、拒否すべき入力も確認します。
商品金額1000以上なら送料無料、未満なら200、負数と整数以外は拒否する請求処理を扱います。境界の不具合を再現し、assertを入力検査に使う問題も確認してから、同じテストで修正前後を比較します。
1不具合を含む小さな処理
fees_bad.pyは送料を返し、送料込みの請求額をledgerへ追加します。
cat fees_bad.py再現に必要な関数だけを表示1000以上が無料という仕様に対して、コードは>1000です。また、負数の拒否にassertを使っています。保存前の検査と境界の条件を、それぞれ確認します。
2境界だけで再現する
ファイル読込みや表示画面を介さず、999、1000、1001を同じ関数へ渡します。
python3 - <<'PYCODE'
from fees_bad import charge
for amount in (999, 1000, 1001):境界の前後を比較
ledger = []
print(amount, charge(amount, ledger)同じ関数を呼ぶ, ledger)
PYCODE1000のときも送料200になり、請求額1200が保存されます。問題を再現するのに、大きな画面や他の入力は不要です。直前・境界・直後の三つで、等号の扱いを切り分けられます。
3期待値と例外をテストに記す
run_tests.pyはunittestの検査を実行し、検査名と判定を表示します。期待値は仕様から固定しています。
cat run_tests.py期待値・例外型・保存状態の検査境界三点に加え、0、負数、True、保存された請求額を検査します。負数はValueError、整数以外のTrueはTypeErrorを期待し、例外型を曖昧にしません。負数の拒否後にはledgerが空のままかも確認します。
4修正前の検査結果
修正前の実装を同じテストへ渡します。
python3 run_tests.py固定したテストを実行 fees_bad修正前の対象境界、負数の例外型、Trueの拒否に失敗します。通常の999や1001だけを試した場合は、境界の欠陥を見逃します。検査の合格数だけでなく、失敗した入力と期待した条件を確認します。
5assertで拒否した場合
負数を渡し、assertによる拒否と保存状態を表示します。
cat assert_input.py負数を渡す最小の試料
python3 assert_input.py通常実行通常実行ではAssertionErrorになり、ledgerは空です。しかしこれは必須の入力検査に適した方法ではありません。次に実行時の最適化を変えます。
6最適化でassertが除かれる
同じ試料を-O付きで実行します。
python3 -Oassertを除く最適化で実行 assert_input.py同じ負数の入力assertが除かれ、負数-1が受理されて199を保存します。assert内の検査や副作用が実行されることを、必須の処理として頼るべきではありません。
7検査と境界を修正する
修正済みのfees_good.pyでは明示的な型・値の検査と、>=1000の条件を使います。
cat fees_good.py保存前の検査と境界条件を修正保存前にTypeErrorまたはValueErrorを送出するため、拒否した入力でledgerを更新しません。送料の判定には等号が入り、1000でも無料になります。
8同じテストで修正後を確認
期待値を変えず、検査対象だけを修正版へ切り替えます。
python3 run_tests.py同じ検査を再実行 fees_good修正後の対象七つの検査が全てPASSです。修正に合わせて期待値を都合よく変更していないため、同じ仕様に対する差を確認できます。この入力集合での確認であり、あらゆる入力の正しさの証明ではありません。
9最適化時も必須の検査を保つ
修正版のテストを-O付きで実行します。
python3 -O run_tests.py最適化を有効にしてテスト fees_good必須検査を明示した実装明示的なifと例外、unittestのassertEqualやassertRaisesによる検査は残り、同じ七件がPASSします。Pythonのassert文とunittestの検査メソッドでは、最適化時の扱いが異なります。
まとめ
元の失敗条件を保った小さな再現コードで、原因と無関係な部分を減らします。
期待値は実装の再実行ではなく仕様から決め、境界・例外型・更新後の状態を確認します。
必須の入力検査はassertだけに頼らず、明示的な条件と例外で実装します。
操作履歴と解説を振り返れます。
シェル / Python 3
cat fees_bad.pydef charge(amount, ledger):
assert amount >= 0, "negative amount"
fee = 0 if amount > 1000 else 200
ledger.append(amount + fee)
return feepython3 - <<'PYCODE'
from fees_bad import charge
for amount in (999, 1000, 1001):
ledger = []
print(amount, charge(amount, ledger), ledger)
PYCODE999 200 [1199] 1000 200 [1200] 1001 0 [1001]
cat run_tests.pyimport importlib,sys,unittest
charge=importlib.import_module(sys.argv[1]).charge
class FeeTests(unittest.TestCase):
def test_below(self):
self.assertEqual(charge(999, []), 200)
def test_boundary(self):
self.assertEqual(charge(1000, []), 0)
def test_above(self):
self.assertEqual(charge(1001, []), 0)
def test_zero(self):
self.assertEqual(charge(0, []), 200)
def test_negative(self):
ledger = []
with self.assertRaises(ValueError):
charge(-1, ledger)
self.assertEqual(ledger, [])
def test_type(self):
with self.assertRaises(TypeError):
charge(True, [])
def test_saved_total(self):
ledger = []
self.assertEqual(charge(999, ledger), 200)
self.assertEqual(ledger, [1199])
suite=unittest.defaultTestLoader.loadTestsFromTestCase(FeeTests)
for test in suite:
result=unittest.TestResult()
test.run(result)
problems=result.failures+result.errors
name=test.id().split('.')[-1]
print(name, "FAIL" if problems else "PASS")
for _, trace in problems:
print(" "+trace.strip().splitlines()[-1])python3 run_tests.py fees_badtest_above PASS test_below PASS test_boundary FAIL AssertionError: 200 != 0 test_negative FAIL AssertionError: negative amount test_saved_total PASS test_type FAIL AssertionError: TypeError not raised test_zero PASS
cat assert_input.py
python3 assert_input.pyfrom fees_bad import charge
ledger = []
try:
fee = charge(-1, ledger)
print("accepted", fee, ledger)
except AssertionError as error:
print(type(error).__name__, str(error), ledger)
AssertionError negative amount []python3 -O assert_input.pyaccepted 200 [199]
cat fees_good.pydef charge(amount, ledger):
if type(amount) is not int:
raise TypeError("amount must be int")
if amount < 0:
raise ValueError("negative amount")
fee = 0 if amount >= 1000 else 200
ledger.append(amount + fee)
return feepython3 run_tests.py fees_goodtest_above PASS test_below PASS test_boundary PASS test_negative PASS test_saved_total PASS test_type PASS test_zero PASS
python3 -O run_tests.py fees_goodtest_above PASS test_below PASS test_boundary PASS test_negative PASS test_saved_total PASS test_type PASS test_zero PASS
演習完了