振る舞いを保つリファクタリング
構造を変える前後で何を保持するか決め、正常値だけでなく拒否する条件も確認します。
説明
リファクタリングは、外から見える振舞いを保ちながら内部の構造を変える作業です。正常な入力で同じ金額を返すだけでなく、空入力や不正な値に対する結果・例外も、保つべき振舞いに含まれます。
数量と単価から請求額の文字列を作る関数を使います。短く書き直した案で検証が消える問題を見つけ、その後、入力変換・金額計算・表示を分けた案へ置き換えます。元の関数と同じ入力を処理し、値と例外を照合します。
構造を変える前後で何を保持するか決め、正常値だけでなく拒否する条件も確認します。
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
cat invoice.pydef 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}'python3 run_tests.py test_invoice.Normal test_invoice.InvalidPASS 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
cp invoice-bad.py invoice.py
python3 run_tests.py test_invoice.NormalPASS test_invoice.Normal.test_empty PASS test_invoice.Normal.test_multiple PASS test_invoice.Normal.test_single tests=3 failures=0 errors=0
python3 run_tests.py test_invoice.InvalidFAIL 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
cat invoice-refactored.pydef 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))cp invoice-refactored.py invoice.py
python3 run_tests.py test_invoice.Normal test_invoice.InvalidPASS 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
python3 compare.py1: 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')python3 show_parts.pyparsed=(2, 100) amount=200 rendered=total=200
演習完了