一覧PG-18-2 · デバッグと小さなテスト

不具合の再現と境界値テスト

商品金額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) PYCODE

1000のときも送料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

1
cat fees_bad.py
def charge(amount, ledger):
    assert amount >= 0, "negative amount"
    fee = 0 if amount > 1000 else 200
    ledger.append(amount + fee)
    return fee
2
python3 - <<'PYCODE' from fees_bad import charge for amount in (999, 1000, 1001): ledger = [] print(amount, charge(amount, ledger), ledger) PYCODE
999 200 [1199]
1000 200 [1200]
1001 0 [1001]
3
cat run_tests.py
import 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])
4
python3 run_tests.py fees_bad
test_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
5
cat assert_input.py python3 assert_input.py
from 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 []
6
python3 -O assert_input.py
accepted 200 [199]
7
cat fees_good.py
def 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 fee
8
python3 run_tests.py fees_good
test_above PASS
test_below PASS
test_boundary PASS
test_negative PASS
test_saved_total PASS
test_type PASS
test_zero PASS
9
python3 -O run_tests.py fees_good
test_above PASS
test_below PASS
test_boundary PASS
test_negative PASS
test_saved_total PASS
test_type PASS
test_zero PASS

演習完了