CHAPTER 7 ・ 品質を守る / Lesson 28

テストを書いて、安心して直す

期待する答えを先に書いておく。assert と unittest で、直したら壊れた、を防ぎます。

目安 25分 ・ インストール不要

このレッスンが終わったら

「テストは、入力と期待する答えの組。関数を直したあと、すべて通れば、前と同じように動くと言える」と言える。

STEP 1

直したら、別のところが壊れていた

サクラ商事では、請求書の消費税額を求める関数 tax_amount を使っています。会社のルールは「円未満は切り捨て」です。初版は、田中さんが次のように作りました(消費税の考え方は Lesson 04 で学んだ通りです)。

消費税額を求める関数(初版)コードは書き換えられます
Ctrl / ⌘ + Enter でも実行
結果
▶ 実行を押すと、ここに結果が出ます

100円、80円。ここまでは問題なさそうです。3つ目は、15円の10%で1.5円なので、切り捨てなら1円のはずですが、2 と出ました。round は四捨五入のように見えて、「ちょうど半分のときは偶数に寄せる」という動きをするため、切り捨てとは違う結果になることがあります(Lesson 04 でも出てきた注意点です)。

こうした間違いは、画面を見て確かめるだけだと、見落としがちです。まして、関数を直すたびに、金額を全部見直すのは大変です。直したら別の金額がずれた、という後戻りも起こります。

そこで、「期待する答えを先に書いておいて、自動で比べる」やり方を使います。これをテストと呼びます。

STEP 2

期待する答えを、先に書く

いちばん手軽なのは、assert(アサート)です。assert は、「この式は正しいはず」と宣言する命令です。正しければ何も起きず、間違っていれば AssertionError で止まります。

assert で確かめるコードは書き換えられます
Ctrl / ⌘ + Enter でも実行
結果
▶ 実行を押すと、ここに結果が出ます

1つ目は静かに通り、2つ目で止まりました。ただ、assert だけだと、最初の失敗で止まってしまい、残りが確かめられません。そこで、「入力と期待する答え」の表を作り、for で全部回して、結果をまとめて見ます。

入力と期待の表で、全部確かめるコードは書き換えられます
Ctrl / ⌘ + Enter でも実行
結果
▶ 実行を押すと、ここに結果が出ます

5件のうち、2件が NG と出ました。15円と1,015円(どちらも、ちょうど半端が出る金額)です。表にしておけば、どの入力で壊れているかが分かります。

STEP 3

仕組みを読む

assert tax_amount(15, 10) == 1, "15円の税額は1円のはず" の部品
assert「正しいはず」と宣言する
tax_amount(15, 10)実際に動かして、答えを得る
==等しいか比べる
1期待する答え(先に決めておく)
"15円の税額は1円のはず"失敗したときの説明(省略できる)

日本語にすると、「15円・10%の税額は、1円になるはず。違ったら、そこで止めて知らせて」という意味です。

表を for で回す方法を、もう少し整えた標準ツールが unittest です。テストが何件あって、何件失敗したかを、まとめて報告してくれます。

unittest のテストの書き方
class TestTax(unittest.TestCase):テストをまとめる入れ物です(class は Lesson 15 で学びます。今は決まり文句として写せば大丈夫です)
def test_fraction(self):テストを1つ書きます。名前は必ず test で始めます
self.assertEqual(tax_amount(15, 10), 1)実際の答えと、期待する答えが等しいか確かめます
suite = unittest.defaultTestLoader.loadTestsFromTestCase(TestTax)TestTax の中のテストを集めます
unittest.TextTestRunner(stream=sys.stdout, verbosity=2).run(suite)集めたテストを動かして、結果を画面に出します
STEP 4

変えて、試す

① テストを動かして、失敗の読み方を知る。 先ほどの表を、unittest に書き直します。最初は初版の関数で動かし、失敗を読んだら、関数を切り捨てに直して、もう一度動かします。(2回動かすので、run_tests という関数にまとめています。テストは、動かす時点の tax_amount を呼ぶので、定義し直すと、直した関数が使われます。)

失敗 → 関数を直す → 全部通るコードは書き換えられます
Ctrl / ⌘ + Enter でも実行
結果
▶ 実行を押すと、ここに結果が出ます

1回目の出力を読みます。慌てず、次の順に見てください。

  • test_fraction ... FAIL:どのテストが失敗したか
  • self.assertEqual(tax_amount(15, 10), 1) の行:どの入力で失敗したか
  • AssertionError: 2 != 1:実際の答え(2)と、期待した答え(1)が違う
  • 最後の FAILED (failures=1):失敗が1件。全部通れば OK

2回目は3件とも ok になり、最後に OK と出ます。関数を直したあと、全部のテストが通る。これが「前と同じように動く」と言える根拠です。

② バグを見つけたら、まずテストにする。 次は日付の例です。「支払期日が休日なら、翌営業日にずらす」関数に、「9月13日(土)が期日のとき、9月16日(火)にならない」という報告がありました(14日は日曜、15日は祝日の敬老の日だからです)。直す前に、そのケースを表に足して、失敗することを確かめます。

報告されたケースをテストにしてから、直すコードは書き換えられます
Ctrl / ⌘ + Enter でも実行
結果
▶ 実行を押すと、ここに結果が出ます

if は1回しか確かめないので、土曜 → 日曜で止まっていました。while なら、休日でなくなるまで繰り返します。直したあと、他の2件も通っていることまで確かめられました。テストがなければ、「直したつもりが、別の日を壊した」ことに気づけません。

STEP 5

自分で書く

お題① 税込金額を求める関数 with_tax が、表のケースを通りません。円未満の税額は切り捨てになるよう直して、NG の一覧が空([])になるようにしてください。

お題①:失敗するケースを通すコードは書き換えられます
Ctrl / ⌘ + Enter でも実行
結果
▶ 実行を押すと、ここに結果が出ます
ヒントを見る

税込金額は「税抜 + 税額」です。税額は price * rate // 100(// は割り算の整数部分)で、円未満が切り捨てになります。

答えの例を見る
def with_tax(price, rate):
  return price + price * rate // 100

cases = [(1000, 10, 1100), (25, 10, 27), (15, 8, 16), (99, 8, 106), (0, 10, 0)]
failed = [(p, r) for p, r, want in cases if with_tax(p, r) != want]
print(failed)

お題② TestTax に、「軽減税率8%・1,000円のとき、税額は80円」を確かめるテストを足してください。テストの名前は test で始めます。結果の「テスト数」と「全部通ったか」が表示されます。

お題②:テストを1つ足すコードは書き換えられます
Ctrl / ⌘ + Enter でも実行
結果
▶ 実行を押すと、ここに結果が出ます
ヒントを見る

def test_reduced_rate(self): の下に、self.assertEqual(tax_amount(1000, 8), 80) と書きます。字下げ(インデント)は、他のテストと同じ位置にそろえます。

答えの例を見る
import io
import unittest

def tax_amount(price, rate):
  return price * rate // 100

class TestTax(unittest.TestCase):
  def test_normal(self):
      self.assertEqual(tax_amount(1000, 10), 100)

  def test_reduced_rate(self):
      self.assertEqual(tax_amount(1000, 8), 80)

suite = unittest.defaultTestLoader.loadTestsFromTestCase(TestTax)
result = unittest.TextTestRunner(stream=io.StringIO()).run(suite)
print(result.testsRun, result.wasSuccessful())

お題③ 「5万円以上の経費は要確認」の関数 needs_check が、ちょうど50,000円のとき、要確認にならない不具合があります。境界のケースを含む表で失敗を確かめ、関数を直して、失敗一覧が空になるようにしてください。

お題③:境界のバグを直すコードは書き換えられます
Ctrl / ⌘ + Enter でも実行
結果
▶ 実行を押すと、ここに結果が出ます
ヒントを見る

「以上」は、ちょうどその金額も含みます。> ではなく、>= を使います。

答えの例を見る
def needs_check(amount):
  """5万円以上は、要確認(True)にする"""
  return amount >= 50000

cases = [(49999, False), (50000, True), (50001, True), (0, False)]
failed = [a for a, want in cases if needs_check(a) != want]
print(failed)
STEP 6

仕事ならどう使う?

理解チェックunittest で、実際に動かされるテストの名前(メソッド名)は、どれでしょう?

理解チェック関数を直したあと、テストがすべて通りました。このことから、確実に言えるのは?