一覧SE-09-1 · 保守性と性能の改善

振る舞いを保つリファクタリング

構造を変える前後で何を保持するか決め、正常値だけでなく拒否する条件も確認します。

説明

リファクタリングは、外から見える振舞いを保ちながら内部の構造を変える作業です。正常な入力で同じ金額を返すだけでなく、空入力や不正な値に対する結果・例外も、保つべき振舞いに含まれます。

数量と単価から請求額の文字列を作る関数を使います。短く書き直した案で検証が消える問題を見つけ、その後、入力変換・金額計算・表示を分けた案へ置き換えます。元の関数と同じ入力を処理し、値と例外を照合します。

構造を変える前後で何を保持するか決め、正常値だけでなく拒否する条件も確認します。

1一つの関数が持つ処理を読む

invoiceは文字列の整数変換、数量と単価の確認、行金額の加算、表示文字列の作成を行います。

cat invoice.py元の変換・検証・計算・表示を読む

数量は正、単価は0以上という条件があります。これを分割するときも、条件に反した入力を拒否する振舞いを残す必要があります。

2元の振舞いをテストで確認

正常系と不正入力をまとめて実行します。

python3 run_tests.py test_invoice.Normal test_invoice.Invalid正常値と例外の条件を確認する

6件が成功します。空の明細はtotal=0、不正な数量と単価はValueErrorです。こうした振舞いを先に確認してから構造を変えます。

3短くしただけの案は正常系を通る

計算をsumへまとめた案を反映します。まず正常系だけを実行します。

cp invoice-bad.py invoice.py短く書き直した案を適用する python3 run_tests.py test_invoice.Normal正常系だけを確認する

3件とも成功します。ソースが短くなり正常な金額も同じですが、これだけでは元の振舞いを保ったとは判断できません。

4検証を落とした変更を検出

不正入力の試験を実行します。

python3 run_tests.py test_invoice.Invalid元の拒否条件が残っているか確認する

数量0と負の単価で、ValueErrorが発生せず失敗します。例外の条件を削除したことは、内部構造だけの変更ではなく、振舞いの変更です。

5責務ごとに分けた案を読む

入力を変換して検証するparse_line、行金額のline_amount、表示のrenderへ分けた案を確認します。

cat invoice-refactored.py分割後の関数と呼出し順を読む

invoiceは処理の順序を組み合わせる役割です。各行の変換と検証はparse_lineへまとめ、計算に不正な値を渡さない順序を保っています。

6分割後も同じ試験を使う

正しい分割案を反映します。期待値や例外の条件を変えずに再試験します。

cp invoice-refactored.py invoice.py変換と検証を保った分割案へ替える python3 run_tests.py test_invoice.Normal test_invoice.Invalid同じ期待値と例外を再確認する

6件が成功します。構造を変えるために期待値を都合よく書き換えるのではなく、同じ条件を満たすかを確認しました。

7戻り値と例外を元の関数へ照合

compare.pyは8種類の入力を元の関数と現在の関数へ渡し、値または例外の型とメッセージを比較します。

python3 compare.py元の関数と現在の値・例外を照合する

空入力、正常な文字列、不正な範囲、非数値、欠落キー、None、先頭0付き数値の8件で一致します。これは確認した入力の範囲の一致であり、すべての入力についての証明ではありません。

8変更箇所を役割から探せる形にする

分割した各関数へ順に値を渡し、中間の結果を表示します。

python3 show_parts.py変換・計算・表示の間を流れる値を確認する

入力は整数の組(2, 100)、金額は200、表示はtotal=200です。入力形式を変えるならparse_line、表示を変えるならrenderが候補になります。役割を分けることで、後の変更を調べる場所を絞れます。

まとめ

リファクタリングで保持する振舞いには、正常な結果だけでなく拒否と例外も含まれます。

短いコードになっても、元の検証が落ちていれば振舞いが変わっています。

入力変換・計算・表示を分け、同じテストと元の結果で変更前後を照合します。

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

シェル / Python 3

1
cat invoice.py
def invoice(rows):
    total=0
    for row in rows:
        qty=int(row['qty'])
        price=int(row['price'])
        if qty<=0 or price<0:
            raise ValueError('invalid line')
        total+=qty*price
    return f'total={total}'
2
python3 run_tests.py test_invoice.Normal test_invoice.Invalid
PASS test_invoice.Normal.test_empty
PASS test_invoice.Normal.test_multiple
PASS test_invoice.Normal.test_single
PASS test_invoice.Invalid.test_negative_price
PASS test_invoice.Invalid.test_non_numeric
PASS test_invoice.Invalid.test_zero
tests=6 failures=0 errors=0
3
cp invoice-bad.py invoice.py python3 run_tests.py test_invoice.Normal
PASS test_invoice.Normal.test_empty
PASS test_invoice.Normal.test_multiple
PASS test_invoice.Normal.test_single
tests=3 failures=0 errors=0
4
python3 run_tests.py test_invoice.Invalid
FAIL test_invoice.Invalid.test_negative_price: ValueError not raised
PASS test_invoice.Invalid.test_non_numeric
FAIL test_invoice.Invalid.test_zero: ValueError not raised
tests=3 failures=2 errors=0
5
cat invoice-refactored.py
def parse_line(row):
    qty,price=int(row['qty']),int(row['price'])
    if qty<=0 or price<0:
        raise ValueError('invalid line')
    return qty,price

def line_amount(line):
    qty,price=line
    return qty*price

def render(total):
    return f'total={total}'

def invoice(rows):
    return render(sum(line_amount(parse_line(row))for row in rows))
6
cp invoice-refactored.py invoice.py python3 run_tests.py test_invoice.Normal test_invoice.Invalid
PASS test_invoice.Normal.test_empty
PASS test_invoice.Normal.test_multiple
PASS test_invoice.Normal.test_single
PASS test_invoice.Invalid.test_negative_price
PASS test_invoice.Invalid.test_non_numeric
PASS test_invoice.Invalid.test_zero
tests=6 failures=0 errors=0
7
python3 compare.py
1: equal=True result=('value', 'total=0')
2: equal=True result=('value', 'total=200')
3: equal=True result=('ValueError', 'invalid line')
4: equal=True result=('ValueError', 'invalid line')
5: equal=True result=('ValueError', "invalid literal for int() with base 10: 'x'")
6: equal=True result=('KeyError', "'price'")
7: equal=True result=('TypeError', "int() argument must be a string, a bytes-like object or a real number, not 'NoneType'")
8: equal=True result=('value', 'total=0')
8
python3 show_parts.py
parsed=(2, 100)
amount=200
rendered=total=200

演習完了