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

テストの独立性と再現性

前の実行に依存しない準備と後片付けを行い、修正前に失敗し修正後に成功する回帰テストを残します。

説明

テストが前のテストの設定に頼っていると、まとめて実行した場合だけ成功し、単独実行や順序変更で失敗することがあります。また、不具合を直すときは、その不具合を再現する入力をテストとして残す必要があります。

値引率を共有するPythonコードで、実行順による成功・失敗の違いを確認します。各テストが設定を用意し、終了後に戻す形へ直した後、101円の10%引きで丸め方を間違える不具合を回帰テストに追加します。

前の実行に依存しない準備と後片付けを行い、修正前に失敗し修正後に成功する回帰テストを残します。

1共有設定を使う二つの試験

test_configureは値引率10を設定しますが、test_discountは自分では設定しません。初期値は0です。

cat settings.py初期の値引率を読む cat test_shared.py各テストが準備する状態を読む

test_discountが期待する180円には、値引率10が必要です。別のテストがその状態を残すことに依存している構造になっています。

2設定する試験を先にすると通る

test_configure、test_discountの順に同じプロセスで実行します。

python3 run_tests.py test_shared.Shared.test_configure test_shared.Shared.test_discount設定するテストを先に実行する

2件とも成功します。しかし後半は前半が残した共有設定を利用しているので、この成功だけでは独立性を確認できません。

3単独では再現条件が欠ける

test_discountだけを新しいプロセスで実行します。

python3 run_tests.py test_shared.Shared.test_discount前のテストなしで単独実行する

実際の200円と期待する180円が不一致になります。初期の値引率は0なので、値引きされません。テスト自身が必要な設定を用意していなかったことが原因です。

4逆順でも失敗する

二つのテストの順序を逆にします。

python3 run_tests.py test_shared.Shared.test_discount test_shared.Shared.test_configure逆順に実行する

先頭のtest_discountが失敗します。実行順を固定して見えなくするのではなく、各テストの開始条件をそろえる必要があります。

5各テストで設定し、後で戻す

独立した版ではsetUpが毎回値引率を10にし、addCleanupで元の値へ戻します。

cat test_isolated.py準備と後片付けを読む python3 run_tests.py test_isolated.Isolated.test_discount単独でも必要な状態がそろうことを確認する python3 run_tests.py test_isolated.Isolated.test_discount test_isolated.Isolated.test_configure

単独と逆順の両方が成功します。addCleanupはテストが失敗した場合にも呼ばれる後片付けを登録できます。共有状態がある場合は、自分に必要な値を用意し、次の試験へ余計な状態を残さないようにします。

6金額の丸め方の不具合を読む

次は具体的な不具合の再現条件です。仕様では、値引き後の額に端数があれば切り捨てます。

cat bug.txt入力・期待結果・原因を読む python3 run_tests.py test_rounding.Existing既存の金額ケースを実行する

既存の100円と200円の試験は成功します。どちらも10%引きの結果が整数なので、切り捨てる順番の違いが表れません。

7101円の再現ケースを追加する

101円の10%引きが90円になることを確認します。値引きなしの条件も加えています。

python3 run_tests.py test_rounding.Regression不具合を再現する入力と期待値を試す

101円のケースが91円になり、FAILします。修正前のコードで失敗することを確かめると、このテストが対象の不具合を実際に検出することが分かります。

8丸める順番を直して全件再実行

値引額を先に切り捨てる計算から、値引き後の額を切り捨てる計算へ変更します。

diff -u切り捨ての位置の違いを確認する -L rounding.py -L rounding-fixed.py rounding.py rounding-fixed.py cp rounding-fixed.py rounding.py計算を修正する python3 run_tests.py test_rounding.Existing test_rounding.Regression既存と回帰のケースを再実行する

4件が成功します。修正だけで終わらず、101円のケースを残すことで、後の変更で同じ計算へ戻ってしまった場合も検出できます。

まとめ

テストは、単独実行や順序変更でも必要な開始状態を自分で用意できるようにします。

共有設定は後片付けで元へ戻し、ほかのテストへ影響を残さないようにします。

回帰テストでは具体的な再現入力を残し、修正前の失敗と修正後の成功を確認します。

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

シェル / Python 3

1
cat settings.py cat test_shared.py
rate=0
import unittest,settings
from quote import total
class Shared(unittest.TestCase):
    def test_configure(self):
        settings.rate=10
        self.assertEqual(total(100),90)
    def test_discount(self):
        self.assertEqual(total(200),180)
2
python3 run_tests.py test_shared.Shared.test_configure test_shared.Shared.test_discount
PASS test_shared.Shared.test_configure
PASS test_shared.Shared.test_discount
tests=2 failures=0 errors=0
3
python3 run_tests.py test_shared.Shared.test_discount
FAIL test_shared.Shared.test_discount: 200 != 180
tests=1 failures=1 errors=0
4
python3 run_tests.py test_shared.Shared.test_discount test_shared.Shared.test_configure
FAIL test_shared.Shared.test_discount: 200 != 180
PASS test_shared.Shared.test_configure
tests=2 failures=1 errors=0
5
cat test_isolated.py python3 run_tests.py test_isolated.Isolated.test_discount python3 run_tests.py test_isolated.Isolated.test_discount test_isolated.Isolated.test_configure
import unittest,settings
from quote import total
class Isolated(unittest.TestCase):
    def setUp(self):
        before=settings.rate
        self.addCleanup(setattr,settings,'rate',before)
        settings.rate=10
    def test_configure(self):self.assertEqual(total(100),90)
    def test_discount(self):self.assertEqual(total(200),180)
PASS test_isolated.Isolated.test_discount
tests=1 failures=0 errors=0
PASS test_isolated.Isolated.test_discount
PASS test_isolated.Isolated.test_configure
tests=2 failures=0 errors=0
6
cat bug.txt python3 run_tests.py test_rounding.Existing
BUG-17: 101円の10%引きが91円になる。
合意した計算: 値引き後の額を計算し、1円未満を切り捨てる。
101 × 90 / 100 = 90.9円 → 90円。
誤実装: 値引き額10.1円を先に10円へ切り捨て、101-10=91円。
PASS test_rounding.Existing.test_hundred
PASS test_rounding.Existing.test_two_hundred
tests=2 failures=0 errors=0
7
python3 run_tests.py test_rounding.Regression
FAIL test_rounding.Regression.test_101_yen: 91 != 90
PASS test_rounding.Regression.test_no_discount
tests=2 failures=1 errors=0
8
diff -u -L rounding.py -L rounding-fixed.py rounding.py rounding-fixed.py cp rounding-fixed.py rounding.py python3 run_tests.py test_rounding.Existing test_rounding.Regression
--- rounding.py
+++ rounding-fixed.py
@@ -1,2 +1,2 @@
 def total(amount,rate):
-    return amount - amount*rate//100
+    return amount*(100-rate)//100
PASS test_rounding.Existing.test_hundred
PASS test_rounding.Existing.test_two_hundred
PASS test_rounding.Regression.test_101_yen
PASS test_rounding.Regression.test_no_discount
tests=4 failures=0 errors=0

演習完了