テストを書く前に — 本当に必要なこと
テストコードを書く前に行うべき思考法:あいまいな要件を正確な契約に落とし込み、コードをテスト可能にし、境界値を意識してケースを選び、コードを実行せずに期待値を知り、テストすべきでないことを判断する — すべて純粋なPythonとassertを使って解説します。
どうやって学びますか?
解決する問題
以下は、試験の点数のリストの平均を計算する関数と、私たちがこのような関数をチェックするときによくやる方法です — 2、3回呼び出して結果を見てみます:
def average(scores):
return sum(scores) / len(scores)
print(average([80, 90, 70]))
print(average([100]))80.0
100.0どちらの答えも正しいです。関数は「動作する」ので、そのままプログラムに組み込まれます。1週間後、まだ結果が1件もないクラスの処理で同じ行に到達します:
def average(scores):
return sum(scores) / len(scores)
print(average([]))Traceback (most recent call last):
File "/home/you/school/report.py", line 5, in <module>
print(average([]))
^^^^^^^^^^^
File "/home/you/school/report.py", line 2, in average
return sum(scores) / len(scores)
~~~~~~~~~~~~^~~~~~~~~~~~~
ZeroDivisionError: division by zero「試したときは動いた」というのは事実でした。しかしそれは、「たまたま試した2つの入力に対して動いた」という意味にしかなりません。手動によるチェックには、この出来事が浮き彫りにした2つの弱点があります。
1つ目は、その瞬間に思いついたことしかカバーできないという点です。そして空のリストは、まさにその瞬間には誰も思いつかないような入力の典型です。
2つ目は、繰り返し実行されないという点です。2回のprint呼び出しは一度実行され、一度確認され、そして削除されました。来月に関数が変更されたとき、誰もそれを再実行しません。
自動テストはこの2つ目の弱点を解決します。そして、このコースの残りの部分ではpytestを使ってそうしたテストを書く方法を学んでいきます。しかし、1つ目の弱点をより注意深く見てみてください。なぜなら、どんなツールもこの弱点を解決してはくれないからです。もし今すぐ空のリストに対するチェックを書きたいとしましょう。そこには何と書くべきでしょうか?average([])は0を返すべきでしょうか?Noneを返すべきでしょうか?それともエラーを送出すべきでしょうか?誰も決めていません。誰も決めていない振る舞いに対してテストを書くことはできませんし、あらかじめ分かっていない答えをチェックすることもできません。
これこそが、この章で扱うテーマです。テストコードの最初の1行を書く前に行うべき思考です。これを正しく行えば、テストはほぼ自然と書けるようになります。これを怠ると、パスするのにバグを見逃してしまうテストを作ることになります。
この章を終える頃には
- テストとは何かを1文で説明できるようになる:振る舞いに関する実行可能な主張
- あいまいな要件を正確な契約に落とし込めるようになる — 入力、出力、エラー、副作用
- テストしにくいコードを見極め、純粋なコアと薄いシェルに再構築できるようになる
- 同値クラスと境界値を使って、表形式でケースを計画できるようになる
- コードとは独立して期待値を導き出せるようになる — オラクル
- テストすべきでないこと、そして十分なテストが揃ったタイミングを判断できるようになる
- 計画を素の
assertチェックとして記述して実行し、それだけではまだ不十分である理由を説明できるようになる
前提条件: なし — ここからコースが始まります。Python 3と、関数の書き方の知識が必要です。pytestはまだ必要ありません。この章の内容はすべて純粋なPythonで実行できます。
テストを書く前に
このコースのすべての章にこの名前のセクションがあり、それぞれのトピックについて同じ6つの質問に答えていきます。この章はその質問の出発点となるものなので、ここに一挙にまとめます。これらを控えておいてください。このコースの残りの部分では、これらをチェックリストとして使用します。
- 正確には何が約束されているか? 契約:これらの入力に対してはこの出力、これらの入力に対してはこのエラー、そしてこれらの副作用(または副作用なし)。
- そのコードはテストから呼び出せるか? 入力を引数として受け取り結果を返しているか — それともキーボードから入力を読み取り、画面に出力し、時計を見ているか?
- どのケースをテストするか? 各種類の入力から1つずつ、すべての境界の両側、無効な入力、空の値、そして非常に大きな値。
- 正しい答えをどうやって知るか? 手計算または仕様から導き出す — 決してテスト対象のコードを実行して得てはならない。
- 何を用意しておく必要があるか? Pythonのバージョン、環境、テストファイルの配置場所 — そして後の章では、そのトピックが必要とするファイル、設定、ツール。
- 何をテストしないか? Python自体、他人のライブラリ、単純すぎて間違えようのないコード、そして誰も約束していない振る舞い。
上記で取り上げたaverageにこれらを適用してみましょう。全体を網羅できるほど小さな関数です。
契約に不備があるため、最初のステップはその不備を埋めることです。確認したところ、「点数が何もないときの平均は意味をなさないので、ValueErrorを送出する」という回答が得られたとしましょう。この関数はすでにテスト可能です — リストを受け取り、数値を返します。この章で用意しておく必要があるのはPython 3だけです。そしてケースは以下の通りです:
| ケース | 入力 | 期待値 | 理由 | | --- | --- | --- | --- | | 通常ケース | [80, 90, 70] | 80.0 | 通常の使用法: (80 + 90 + 70) / 3 | | 点数が1つ | [100] | 100.0 | 平均が存在する最小のリスト | | 空 | [] | ValueError | 先ほど壊れたケース。仕様が確定 |
リストに載っていないもの:sumが正しく足し算を行うか、あるいは/が正しく割り算を行うか — それらはPythonの仕事であり、あなたや私が書くどんなコードよりもはるかに徹底的にテストされています。
この章の残りの部分では、6つの質問を順番に取り上げ、最後に1つの実践的な関数に対してプロセス全体を実行します。
テストとは実際には何なのか
テストとは、振る舞いに関する実行可能な主張です:この入力が与えられたら、この結果を期待する。「結果」には厳密に3つの種類があります:
- 戻り値 —
average([80, 90, 70])は80.0を返す - エラー —
average([])はValueErrorを送出する - 副作用 —
add_score(scores, 70)の実行後、リストscoresの末尾に70が追加されている
Pythonには主張を書き留めるための文がすでに用意されています:assertです。条件が真であれば何もしませんが、偽であればAssertionErrorを送出します。決定された契約に従うようにしたaverageを使って、それぞれの種類の主張を1つずつ示します:
def average(scores):
if not scores:
raise ValueError("average() of an empty list")
return sum(scores) / len(scores)
def add_score(scores, new_score):
scores.append(new_score)
# 1. A return value: given this input, expect this output.
assert average([80, 90, 70]) == 80.0
# 2. An error: given this input, expect this exception.
try:
average([])
except ValueError:
pass
else:
raise AssertionError("average([]) should raise ValueError")
# 3. A side effect: given this call, expect this change to the world.
scores = [80, 90]
add_score(scores, 70)
assert scores == [80, 90, 70]
print("3 claims checked")3 claims checkedそれぞれの主張に何が必要かに注目してください。自分で選択した入力、あらかじめ知っている結果、そしてコードを呼び出してその動作を確認する手段です。6つの質問は、テストを書き始める前にこの3つがすべて揃っていることを確認するために存在します。また、エラーのチェックがいかに面倒かにも注目してください。「これはエラーを送出すべきである」と表現するためだけに、try/except/elseの5行を費やしています。第4章では、pytestがこれをわずか1行に変えてくれます。
1. 契約:あいまいな要件から正確な約束へ
要件は通常、次のような1文として届きます:「会員は高額注文で10%引き。」 一見すると十分な説明に思えます。これを注意深い2人の開発者に渡して、どうなるか見てみましょう:
# "Members get 10% off big orders." Two honest readings of the same sentence.
def discount_by_asha(total, is_member):
if is_member and total > 1000:
return total * 0.10
return 0
def discount_by_ravi(total, is_member):
if is_member and total >= 1000:
return round(total * 0.10)
return 0
for total in [500, 1000, 1234.56]:
print(total, discount_by_asha(total, True), discount_by_ravi(total, True))500 0 0
1000 0 100
1234.56 123.456 123どちらの開発者も間違ってはいません。元の文には、ちょうど1000が「高額」に含まれるのかどうか、あるいはどのように端数処理を行うのかが書かれていませんでした。それぞれが異なる方法でそのギャップを埋めたため、1000の注文に対して一方は0の割引、もう一方は100の割引を適用しました。どちらが正しいかを判定できるテストは存在しません。なぜなら、「正しさ」が一度も定義されていないからです。
契約とは、テストができるほど十分に要件を明確化したものです。1文の要件から契約へと落とし込むには、質問を投げかけます — 毎回同じ質問です:
- 入力。 どんな型か?どの範囲か?上限や下限はあるか、そしてその境界値自体は含まれるか?ゼロ、負の値、空の値、
Noneはどう扱うか? - 出力。 正確には何が返されるか — 割引額か、それとも新しい合計金額か?型は何か?どこまで丸めるか?
- エラー。 どの入力を拒否するか、そしてどのように拒否するか — どの例外を送出するか?
- 副作用。 何かを変更するか — 渡されたリスト、ファイル、データベースなど — あるいは何かを出力するか?それともまったく何もしないか?
割引についてこれらの質問を投げかけると答えが得られます。そしてその答えは、コードがある場所 — docstring(ドキュメント文字列)— に記述します:
def member_discount(total, is_member):
"""Return the discount on an order, in currency units.
- total: the order total before discount, a number >= 0.
A negative total raises ValueError.
- is_member: True or False.
- Members get 10% of the total when the total is 1000 or more
(1000 itself qualifies). Everyone else gets 0.
- The result is rounded to 2 decimal places.
- Returns the discount amount, not the new total.
- No side effects: reads nothing, prints nothing, changes nothing.
"""
if total < 0:
raise ValueError(f"total must not be negative, got {total}")
if is_member and total >= 1000:
return round(total * 0.10, 2)
return 0
print(member_discount(999.99, True))
print(member_discount(1000, True))
print(member_discount(1234.56, True))
print(member_discount(5000, False))
try:
member_discount(-5, True)
except ValueError as error:
print("ValueError:", error)0
100.0
123.46
0
ValueError: total must not be negative, got -5docstringの各行は、今やテストでチェックできる項目となっており、すべてのチェックに明確な期待値が存在します。これこそが良い契約かどうかの判定基準です:見知らぬ誰かがコードを読むことなく、任意の入力に対する期待値を書くことができるか? もし書けないなら、まだ確認すべき質問が残っています。
その質問には誰が答えるのでしょうか?要件のオーナー — プロダクトオーナー、講師、クライアント、あるいはあなた自身です。質問できる人が誰もいない場合は、自分で決定し、その決定を契約に書き込んで先へ進んでください。明示された決定であれば、後から見直して変更することができます。暗黙の決定は、誰かを困惑させるその日までコードの中に潜み続けるだけです。
2. テスト可能なコード:純粋なコアと薄いシェル
テストは、選んだ入力を使って関数を呼び出し、返ってきた結果を比較します。しかし関数によっては、それが不可能なものもあります。以下はカフェのハッピーアワー — 17:00から19:00の間はドリンクが20%引き — を処理する関数です:
from datetime import datetime
def drink_price():
price = float(input("Price: "))
hour = datetime.now().hour
if 17 <= hour < 19:
price = price * 0.8
print(f"You pay {price:.2f}")実行して価格を入力すると正しく動作します。では、別のファイルcheck_drinks.pyからこれをチェックしてみましょう:
from drinks import drink_price
result = drink_price()
print("returned:", result)自動チェックはキーボードの前に誰もいない状態で実行されます。< /dev/nullを使うと、プログラムにまさにその状態 — 中身が何もない入力 — を与えることになります:
$ python check_drinks.py < /dev/null
Price: Traceback (most recent call last):
File "/home/you/cafe/check_drinks.py", line 3, in <module>
result = drink_price()
^^^^^^^^^^^^^
File "/home/you/cafe/drinks.py", line 5, in drink_price
price = float(input("Price: "))
^^^^^^^^^^^^^^^^
EOFError: EOF when reading a line価格を渡してやれば実行されます — しかし、返ってくる値を見てみてください:
$ echo 10 | python check_drinks.py
Price: You pay 10.00
returned: None3つの個別の問題があり、どれもよく見られるものです:
- 引数として受け取る代わりに
input()で入力を読み取っているため、テストが入力を選択できません。 - 結果を返す代わりに画面に出力しているため、テストは
Noneを受け取ることになり、比較するものが何もありません。 - 関数自身が時計を読み取っています。先ほどの実行は朝の7時前だったため答えは
10.00でしたが、夕方の5時半に同じコマンドを実行すると8.00になります。このチェックは実行した時間によってパスしたり失敗したりします — 不安定な(flaky)テストです。これはテストが全くないよりも悪質です。なぜなら、人々はそのテストを無視することを覚えてしまうからです。
解決策はテストの小細工ではありません。構造の変更です。すなわち、判定(ロジック)と入出力を分離することです。判定部分は純粋な関数になります — 必要なものはすべて引数として受け取り、答えは戻り値として返し、それ以外のものには一切触れません。キーボード、画面、そして時計は、その周囲を取り囲む薄いシェルへと移動します:
from datetime import datetime
def drink_price(price, hour):
"""Return the price to pay for a drink.
20% off from 17:00 up to, but not including, 19:00.
"""
if 17 <= hour < 19:
return round(price * 0.8, 2)
return price
def main():
# The thin shell: all the input, output and clock reading lives here.
price = float(input("Price: "))
print(f"You pay {drink_price(price, datetime.now().hour):.2f}")
if __name__ == "__main__":
main()これで、チェックは1日のうちのいつでも、好きな時間を指定できるようになります:
from drinks import drink_price
assert drink_price(10, 16) == 10
assert drink_price(10, 17) == 8.0
assert drink_price(10, 18) == 8.0
assert drink_price(10, 19) == 10
print("all checks passed")all checks passedキーボードの前にいる人から見れば、プログラムの動作は以前とまったく同じです。変わったのは、テストする価値のある部分 — ルール — を単体でテストできるようになった点であり、シェルは非常に薄いため、そこで間違えようのある要素はほとんど残っていません。
テストを書く前に気づけるように、テストしにくいコードの兆候をまとめておきます:
| 兆候 | テストに支障をきたす理由 | 一般的な解決策 | | --- | --- | --- | | ロジック内でinput()を呼んでいる | テストが入力を選択できない | 引数として受け取るようにする | | 結果をprint()するだけである | 比較するために返ってくる値がない | 値を返し、シェル側で出力する | | 内部でdatetime.now()やrandomを使っている | 実行するたびに結果が変わってしまう | 時刻や乱数値を外部から渡す | | グローバル変数を読み取りまたは変更している | テスト同士が影響し合ってしまう | 引数として渡し、新しい値を返す | | 内部で固定のファイルやURLを開いている | テストにそのファイルやネットワークが必要になる | データ、またはパスを外部から渡す |
いつでもコードの設計を変更できるとは限りません — コードが他の人の所有物である場合もありますし、入出力そのものが本来の振る舞いである場合もあります。pytestにはそうしたケースのためのツールがあります。capsysは出力されたテキストをキャプチャし(第9章)、tmp_pathは使い捨てのフォルダを提供し(第9章)、monkeypatchとモックは時計、ネットワーク、環境変数を差し替えます(第10章および第11章)。しかし、設計の見直しこそが最優先です。テストしやすいコードは、通常、理解するのも簡単だからです。
3. ケースの選び方
すべての入力をテストすることはできません。drink_priceだけでも、すべての時間におけるすべての価格を受け付けます。私たちの仕事は、他のケースが見逃してしまうような間違いを検出できる、少数のケースの集まりを選ぶことです。その作業の大部分は、2つの考え方によって行われます。
同値クラス。 契約上同じように扱われる入力をグループ化します。グループの任意の1つの要素が、残りの要素を代表します。もしdrink_price(10, 12)が正しければ、drink_price(10, 11)もほぼ確実に正しいはずです。なぜなら、どちらも同じコードの行で処理されるからです。契約を厳格化して、0〜23の範囲外の時間、または負の価格に対してはValueErrorを送出するようにしたとしましょう。その場合、時間は5つのクラスに分類されます:
hour: ... -2 -1 | 0 1 ... 15 16 | 17 18 | 19 20 ... 23 | 24 25 ...
invalid | full price | 20% off | full price | invalid境界値。 ミスはクラス全体に均等に散らばるわけではありません。境界(エッジ)に集中します。なぜなら、境界こそが<と<=の選択が行われる場所であり、「19:00まで」がコードに落とし込まれる場所だからです。そのため、すべての境界について、その両側の値 — 一方のクラスの最後の値と、次のクラスの最初の値 — をテストします。ある境界値Lに対しては、L - 1、L、L + 1を確認することを意味します。カウントや長さの場合は、0や1も対象になります。
「クラスごとに1つの代表値」では不十分である理由はここにあります。以下では、ハッピーアワーのテストで、17 <= hourの代わりに17 < hourと書いてしまうという、ありがちなミスを犯しています:
def drink_price(price, hour):
# A slip: 17 < hour instead of 17 <= hour.
if 17 < hour < 19:
return round(price * 0.8, 2)
return price
# Three "typical" cases, one from each valid class:
assert drink_price(10, 12) == 10
assert drink_price(10, 18) == 8.0
assert drink_price(10, 21) == 10
print("typical cases passed")
# The boundary:
assert drink_price(10, 17) == 8.0
print("boundary passed")typical cases passed
Traceback (most recent call last):
File "/home/you/cafe/boundary.py", line 15, in <module>
assert drink_price(10, 17) == 8.0
^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionError各クラスから1つずつのケース:すべてパスしますが、5時に来店した顧客は定価を支払うことになります。境界値のケースは、この問題を即座に検知します。
クラスと境界値によって入力の形状がカバーされます。次に、実際にバグを発見してきた以下の質問を確認していきます:
- 無効な入力 — 契約が拒否すると定めているもの:負の価格、時間
24。 - 空とゼロ —
[]、""、0、価格0。「何もない」状態は正しく振る舞うか? None— 契約に記載されている場合のみ。記載がない場合はケースとしません(質問6を参照)。- 大きな値 — 非常に長いリスト、非常に大きな数値。上限があるものは、上限値でテストします。
- 丸め — 計算によって結果が保持する桁数以上の小数が生じる箇所。
これらをまとめたものがテスト計画です。テストコードを書く前に、ケースごとに1行ずつ作成します。最後の列が最も重要です — 理由を説明できない行は、不要な行です。
| ケース | 入力(price, hour) | 期待値 | 理由 | | --- | --- | --- | --- | | 有効な最小の時間 | (10, 0) | 10 | 有効範囲の境界 | | 定価の最後の時間 | (10, 16) | 10 | 17:00境界の直前 | | 割引の最初の時間 | (10, 17) | 8.0 | 17:00境界上 | | 割引の最後の時間 | (10, 18) | 8.0 | 19:00境界の直前 | | 再び定価になる最初の時間 | (10, 19) | 10 | 19:00境界上 — 「含まない」 | | 有効な最大の時間 | (10, 23) | 10 | 有効範囲の境界 | | 無料のドリンク | (0, 17) | 0 | 価格がゼロ | | 丸め | (2.49, 17) | 1.99 | 2.49 × 0.8 = 1.992、小数第2位に丸める | | 時間が小さすぎる | (10, -1) | ValueError | 範囲の直前 | | 時間が大きすぎる | (10, 24) | ValueError | 範囲の直後 | | 負の価格 | (-1, 12) | ValueError | 無効な入力 |
そして、この計画を厳格化した関数に対するチェックコードへと落とし込みます:
def drink_price(price, hour):
"""Return the price to pay for a drink.
- price: a number >= 0. hour: a whole number 0-23.
- 20% off from 17:00 up to, but not including, 19:00.
- The result is rounded to 2 decimal places.
- An hour outside 0-23 or a negative price raises ValueError.
"""
if not 0 <= hour <= 23:
raise ValueError(f"hour must be 0-23, got {hour}")
if price < 0:
raise ValueError(f"price must not be negative, got {price}")
if 17 <= hour < 19:
return round(price * 0.8, 2)
return price
def raises_value_error(price, hour):
try:
drink_price(price, hour)
except ValueError:
return True
return False
assert drink_price(10, 0) == 10 # lowest valid hour
assert drink_price(10, 16) == 10 # last full-price hour before
assert drink_price(10, 17) == 8.0 # first discounted hour
assert drink_price(10, 18) == 8.0 # last discounted hour
assert drink_price(10, 19) == 10 # first full-price hour after
assert drink_price(10, 23) == 10 # highest valid hour
assert drink_price(0, 17) == 0 # free drink stays free
assert drink_price(2.49, 17) == 1.99 # rounding: 1.992 -> 1.99
assert raises_value_error(10, -1) # just below the valid range
assert raises_value_error(10, 24) # just above the valid range
assert raises_value_error(-1, 12) # negative price
print("11 checks passed")11 checks passed11行のテストがあり、それぞれに明確に説明できる理由が存在します。
4. オラクル:コードなしで答えを知る
すべてのチェックは、コードが返した答えと期待される答えを比較します。期待される答えの情報源のことをオラクルと呼びます。オラクルには1つの鉄則があります:テスト対象のコードそのものであってはならないということです。
言うまでもなく当たり前のことに聞こえるかもしれませんが、これがどれほど簡単に破られてしまうかを見れば納得がいくでしょう。以下では、member_discountに10%ではなく1%にしてしまうバグがあり、2つのチェックを用意しています:
def member_discount(total, is_member):
if total < 0:
raise ValueError(f"total must not be negative, got {total}")
if is_member and total >= 1000:
return round(total * 0.01, 2) # bug: 1%, not 10%
return 0
# Wrong: the expected value comes from the code under test.
expected = member_discount(2000, True)
assert member_discount(2000, True) == expected
print("self-check passed, expected was", expected)
# Right: the expected value was worked out by hand. 10% of 2000 is 200.
assert member_discount(2000, True) == 200
print("hand-check passed")self-check passed, expected was 20.0
Traceback (most recent call last):
File "/home/you/shop/oracle.py", line 15, in <module>
assert member_discount(2000, True) == 200
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionError1つ目のチェックは、関数を自分自身と比較しています。これは正しい関数でもパスしますし、この壊れた関数でもパスしますし、誰かが書くかもしれない他のどんなバージョンでもパスします — 失敗することがあり得ないため、何も教えてくれません。
ここまで露骨に書く人はいません。しかし、同じミスは通常、次の2つのいずれかの形に変装して現れます:
- 出力をコピーする。 関数を実行すると
20.0と出力されたので、その20.0をチェックに貼り付けます。もしコードが最初から間違っていた場合、チェックはそのバグを保護することになってしまいます。 - 計算式を繰り返す。 期待値をコードと同じ計算である
2000 * 0.01と記述します。コードのロジックのコピーは、コードのミスをも共有してしまいます(後述の「問題が起きたとき」で、実際にこれが動作している様子を示します)。
優れたオラクルを、おおむねよく使われる順に挙げます:
- 契約をもとに手計算で導き出す。計算が簡単になるような入力を選びましょう。1873.41ではなく2000を選ぶことで、「その10%」を暗算できるようになります。
- 仕様書に書かれた具体例。 要件に「1500の注文は150引き」と書かれていれば、その行自体がテストになります。
- 既知の事実。 水は100 °C(212 °F)で沸騰する、空のリストの長さは0である、など。
- 別の信頼できる手法。 より単純で低速な計算処理や、同じ処理を別の方法で実行する標準ライブラリの関数など:
import statistics
def average(scores):
if not scores:
raise ValueError("average() of an empty list")
return sum(scores) / len(scores)
samples = [[80, 90, 70], [1, 2], [5], [10, 0, 0, 0]]
for scores in samples:
assert average(scores) == statistics.mean(scores), scores
print("agrees with statistics.mean on", len(samples), "samples")agrees with statistics.mean on 4 samplesstatistics.meanは他の人が別の方法で書いたものなので、それと結果が一致することには意味があります(小数の計算結果を==で比較すると落とし穴にはまることがあります — Pythonでは0.1 + 0.2 == 0.3はFalseになります。ここでの値は安全ですが、浮動小数点数を正しく比較する方法は第5章で解説します)。
もしこれらの方法のいずれを使っても期待値を導き出せないとしたら、それはテストの問題ではありません。コードが何をすべきかをまだ理解していないことを意味しています — 契約に立ち戻ってください。
5. 用意しておくべきもの
この章で必要なのはPython 3だけです。どのバージョンが入っているかを確認してください:
$ python3 --version
Python 3.12.3このコースではPython 3.12を使用しますが、3.10以降であればほぼすべてのコードが動作します。Windowsでのコマンドは通常py --versionです。
次の章以降では、テストを実行する前にさらに3つのものを用意しておく必要があり、第1章でそのそれぞれをセットアップします:
- プロジェクト用の仮想環境。テストツールを、コードを実行するのと同じPython環境にインストールするためです。
- そこにインストールされたpytest。
- テストの配置場所。テストファイルはコードの隣、または
tests/フォルダ内に配置し、名前はtest_で始めます。第1章では前者の方法から始め、第3章で後者の方法へと移行してその理由を説明します。
その後の章では、設定ファイル、プラグイン、ディスク上のサンプルデータなど、章ごとの要件が追加されます。それぞれ自身の「テストを書く前に」セクションにそれらをリストアップしています。
6. テストすべきでないこと、そしていつ終えるか
あなた自身のコードのミスを検知できないテストは、書く時間、実行する時間、保守する時間を浪費するだけで、何の見返りも与えてくれません。以下のようなテストは除外してください:
- Python自体。
sortedが並べ替えを行うか、あるいはroundが端数処理を行うかではありません。あなたの関数が正しい対象を正しい順序で並べ替えているかをテストしてください。 - 他人のライブラリ。
requestsがリクエストを送信するか、あるいはpandasがCSVを読み取れるかではありません。あなたのコードがその結果を使って何をするかをテストしてください — ライブラリの振る舞いを確認したい場合は、前提条件を記録する小さなチェックが1つあれば十分です。 - 単純すぎて間違えようのないコード。 定数を返すだけの関数や、引数を保持するだけのクラスなどです。どんな間違いがあり得るのか想像もつかないのであれば、テストでそれを検知することもできません。
- 誰も約束していない振る舞い。 契約に
drink_priceへ文字列を渡すことについての記述がなければ、テストを書いても現在たまたまどう動作しているかを固定化するだけにすぎません。契約に追加してテストするか、さもなければ除外してください。 - 「何をするか」ではなく「どうやるか」。 関数が内部でどのヘルパー関数を呼び出したか、あるいはどのような順序でステップを実行したかではありません。何が入り、何が出てくるかをテストしてください。そうすれば、内部を自由に書き直すことができ、テストによってそれが今も正しく動作していることを証明できます。
では、どれくらいテストがあれば十分なのでしょうか?決まった数はありませんが、ルールはあります。以下を満たしたときが「十分」です:
- すべての同値クラスに少なくとも1つのケースがある
- すべての境界の両側にケースがある
- 契約が約束しているすべてのエラーにケースがある
- これまでに修正したすべてのバグに専用のケースがあり、気づかないうちに再発することがない
そこまで来たらテストの追加をやめましょう。新しいケースを追加したくなったときは、毎回こう問いかけてみてください:これは他のケースが見逃してしまうどんな間違いを検出できるだろうか? 答えが思いつかないなら、それは重複です。drink_price(10, 12)の隣にdrink_price(10, 11)を置いても、新しいバグは何一つ検知できません。
完全な例
それでは、1つの実践的な関数を使って、最初から最後までのプロセス全体を実行してみましょう。
届いたままの要件: 「送料は市内小包が60、全国小包が120。重い小包は追加料金がかかり、1キロ増えるごとに追加料金は市内20、全国30。30 kgを超えるものは受け付けない。」
質問と、それに対して得られた回答:
| 質問 | 回答 | | --- | --- | | 基本料金は何キロまでカバーするか? | 最初の1キロ — ちょうど1 kgまでは基本料金のまま。 | | 1.2 kgは1 kgと2 kgのどちらで計算されるか? | 端数は1 kgに切り上げ:1.2 kgは2 kgとして計算される。 | | 30 kgそのものは受け付けられるか? | はい。30は許可され、それを超えるものは拒否される。 | | 0 kgや負の重量はどうなるか? | 拒否される — データ入力のミスとみなす。 | | ゾーンはどう表記されるか? | 厳密に"local"または"national"。"Local"を含め、それ以外はすべて拒否される。 | | 何が返されるか? | 送料の整数値。画面出力や保存は行われない。 |
契約とテスト可能な関数であるshipping.pyです。すべてを引数として受け取り、数値を返します。入出力がまったく含まれていないため、分離すべきシェルは存在しません:
import math
BASE = {"local": 60, "national": 120} # covers the first kilogram
PER_EXTRA_KG = {"local": 20, "national": 30}
MAX_WEIGHT_KG = 30
def shipping_cost(weight_kg, zone):
"""Return the shipping cost of one parcel, as a whole number.
- weight_kg: a number. At or below 0, or above 30: ValueError.
- zone: "local" or "national", exactly. Anything else: ValueError.
- The base price covers the first kilogram (1 kg included).
- Every started kilogram after that costs extra: 1.2 kg is charged as 2 kg.
- No side effects: reads nothing, prints nothing, changes nothing.
"""
if zone not in BASE:
raise ValueError(f"unknown zone: {zone!r}")
if not 0 < weight_kg <= MAX_WEIGHT_KG:
raise ValueError(f"weight must be more than 0 and at most 30 kg, got {weight_kg}")
extra_kg = math.ceil(weight_kg) - 1
return BASE[zone] + extra_kg * PER_EXTRA_KG[zone]math.ceilは次の整数に切り上げます — math.ceil(1.2)は2になります — これはまさに「端数は1 kgに切り上げ」そのものです。
ケース表。 重量のクラスは次の通りです:無効(0以下)、基本重量(0より大きく1以下)、追加重量(1より大きく30以下)、無効(30超)。ゾーンは"local"、"national"、およびそれ以外のすべてです。すべての期待値は、コードからではなく契約から手計算で導き出します:
| ケース | 入力 | 期待値 | 理由 | | --- | --- | --- | --- | | 軽い小包 | (0.5, "local") | 60 | 基本重量の範囲内 | | ちょうど1 kg | (1, "local") | 60 | 基本重量の上限境界:1 kgを含む | | 1 kgを少し超える | (1.2, "local") | 80 | 端数は切り上げ:60 + 1 × 20 | | 整数のキロ数(市内) | (3, "local") | 100 | 60 + 2 × 20 | | 整数のキロ数(全国) | (3, "national") | 180 | 120 + 2 × 30 — もう一方のゾーン | | 上限値 | (30, "national") | 990 | 120 + 29 × 30。30は許可される | | ゼロ | (0, "local") | ValueError | 下限境界:0は拒否される | | 負の値 | (-2, "local") | ValueError | 無効な入力 | | 上限超過 | (30.5, "local") | ValueError | 30の直後 | | 不正なゾーン | (2, "Local") | ValueError | 厳密な"local"ではない |
意図的に表に含めていないもの:Noneや"2"という重量です。契約では重量は数値であると定められているため、これらは契約外です — これらを0と比較したときにPython自体がTypeErrorを送出しますが、それ以上の約束はしていません。math.ceilも同様です。それはPythonの責任であり、私たちの責任ではありません。また、(3, "local")の隣に(2, "local")を置くこともしていません。同じクラスであり、同じ行のコードで処理され、新しく検知できるものがないからです。
チェックコードであるcheck_shipping.pyです — 表の行を1行ずつコードにしたものです:
from shipping import shipping_cost
def raises_value_error(weight_kg, zone):
"""True if shipping_cost raises ValueError for these arguments."""
try:
shipping_cost(weight_kg, zone)
except ValueError:
return True
return False
# Valid weights: one case per class, and both sides of every edge.
assert shipping_cost(0.5, "local") == 60
assert shipping_cost(1, "local") == 60
assert shipping_cost(1.2, "local") == 80
assert shipping_cost(3, "local") == 100
assert shipping_cost(3, "national") == 180
assert shipping_cost(30, "national") == 990
# Invalid input: the contract promises a ValueError for each.
assert raises_value_error(0, "local")
assert raises_value_error(-2, "local")
assert raises_value_error(30.5, "local")
assert raises_value_error(2, "Local")
print("all 10 checks passed")$ python check_shipping.py
all 10 checks passedでは、壊してみましょう。 数ヶ月後、誰かがshipping.pyを整理しようとして、import mathを削除し、math.ceil(weight_kg)の代わりにint(weight_kg)と書いたとします。コードはすっきりとして見え、エラーもなく実行されます。チェックを再実行してみましょう:
$ python check_shipping.py
Traceback (most recent call last):
File "/home/you/shop/check_shipping.py", line 14, in <module>
assert shipping_cost(0.5, "local") == 60
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionError計画はその役割を果たしました。チェックを実行した瞬間に変更が検知されたのです。intは切り上げるのではなく小数を切り捨てるため、0.5 kgはint(0.5) - 1、すなわち追加重量-1 kgになってしまいました。
しかし、その理由をあらかじめ知らなかったかのようにこのレポートを読んでみてください。書かれている情報がいかに少ないかに気づくはずです:
- 実際の値は何だったのか? レポートには行が表示されるだけで、数値は表示されません。それが
40だったことを知るには、自分でshipping_cost(0.5, "local")を実行してみる必要があります。 - 他にはどこが壊れているのか? スクリプトは最初の失敗で停止してしまいました。1.2 kgのケースも間違っており —
80ではなく60を返すようになっています — しかしそのチェックは実行すらされませんでした。 - どの約束は守られていたのか? エラーのケースが依然としてパスしているかどうかは何も教えてくれません。
- そしてエラーのチェックには、「これはエラーを送出すべきである」と表現するためだけに
try/exceptを使ったヘルパー関数が必要でした。
一番難しかった「考える作業」はすでに完了しています。契約、表、オラクルです。足りないのは、より優れたテストランナーです — チェックを自動で見つけ出し、1つが失敗してもすべてのチェックを実行し、期待値の隣に実際の値を表示し、エラーをわずか1行でチェックできるようなランナーです。それこそがpytestであり、それをインストールするのが次の章です。
問題が起きたとき
これらはPythonのエラーというよりも計画上のミスであるため、その症状はより静かに現れます。通常は、失敗すべきチェックがパスしてしまうという形です。
期待値がコード自身の計算式を繰り返している 関数が行っているのと同じ計算をしてチェックを記述してしまうケースです。ここでは、関数に先ほどのintのバグがあり、チェック側にも同じバグが存在しています:
def shipping_cost(weight_kg, zone):
# Local zone only, to keep the example short. Bug: int() instead of math.ceil().
return 60 + (int(weight_kg) - 1) * 20
# The expected value repeats the code's own sum, so it repeats the code's own bug.
assert shipping_cost(1.2, "local") == 60 + (int(1.2) - 1) * 20
print("passed")
# Worked out by hand from the contract: 1.2 kg is charged as 2 kg, so 60 + 20.
assert shipping_cost(1.2, "local") == 80passed
Traceback (most recent call last):
File "/home/you/shop/rederive.py", line 11, in <module>
assert shipping_cost(1.2, "local") == 80
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionErrorこれは「実装をテストする」ことの典型例でもあります。チェックが、コードが何を約束しているかを宣言するのではなく、コードがどのように動作しているかをなぞってしまっているのです。期待値は契約から導き出した単なる数値として記述してください。
期待値が出力からコピーされている 関数を実行して20.0が表示されたのを見て、それをチェックに貼り付けた場合です。チェックはパスします — そしてもし20.0が間違っていた場合、今やバグを保護することになってしまいます。コードを実行する前に、すべての期待値を手計算で求めておきましょう。もし自分の計算した数値とコードの数値が一致しない場合は、書き留める前にどちらが間違っているかを突き止めてください。
計画に境界値のケースがない すべてのチェックがパスするのに、バグが境界に潜んでいる場合です:5時ちょうど、ぴったり1 kg、12歳など。「代表的な」行しか載っていない表が通常の原因です。契約内のすべての<、<=、「まで」、「から」、「以上」について、その両側にケースを用意する必要があります。
関数をチェックから呼び出すことができない EOFError: EOF when reading a lineは関数がキーボード入力を待っていることを意味し、returned: Noneは結果を返す代わりに出力してしまっていることを意味し、朝はパスするのに夕方に失敗するチェックは時計を読み取っていることを意味します。3つともすべて設計の問題であり、第2セクションで説明した「純粋なコアと薄いシェル」への再構築によって解決できます。
SyntaxWarning: assertion is always true, perhaps remove parentheses? assertとそのメッセージを丸括弧で囲むと、それらが1つのタプルになってしまい、空でないタプルは常に真と評価されます:
def drink_price(price, hour):
if 17 <= hour < 19:
return round(price * 0.8, 2)
return price
assert (drink_price(10, 17) == 999, "happy hour price")
print("passed?!")/home/you/cafe/tuple.py:7: SyntaxWarning: assertion is always true, perhaps remove parentheses?
assert (drink_price(10, 17) == 999, "happy hour price")
passed?!999は明らかに間違っているのに、チェックはパスしてしまいました。括弧を使わずにassert drink_price(10, 17) == 999, "happy hour price"と書けば、期待通りに失敗します。ここではPythonが警告を出してくれています。警告を読み流さずに目を通すようにしましょう。
小数の結果を==で比較するチェックが、正しい答えに対して失敗する Pythonでは0.1 + 0.2 == 0.3はFalseになります。なぜなら0.1 + 0.2は0.30000000000000004だからです。計画段階での教訓は、member_discountやdrink_priceで行ったように、結果をどのように丸めるかを契約で明記することです。浮動小数点数を比較するためのpytestツールは第5章で扱います。
ステップ 4 / 6 — 予想する
理解度をチェック
要件:「500以上の注文は配送料無料。」このスクリプトを実行すると何が起きますか?
def free_shipping(total):
return total > 500
assert free_shipping(800) is True
assert free_shipping(100) is False
print("checks passed")
assert free_shipping(500) is True
print("all done")- A`checks passed`が出力された後、最後のアサートで`AssertionError`が発生する
- B最初のアサートで`AssertionError`が発生し、何も出力されない
- C何も起きない — スクリプト内ではアサートは無視される
- D`checks passed`が出力され、その後に`all done`が出力される
1行のassertを使って、そのまま確実にテストできる関数はどれですか?
- Adef is_weekend(weekday): return weekday >= 5
- Bdef greet(): print('Hi', input())
- Cdef log_visit(): VISITS.append(1)
- Ddef is_weekend(): return datetime.now().weekday() >= 5
このチェックは「1.2 kgは2 kgとして計算される」というルールを保護するためのものです。どこに問題がありますか?
from shipping import shipping_cost
expected = shipping_cost(1.2, "local")
assert shipping_cost(1.2, "local") == expected- A`==`の代わりに`is`を使うべきである
- B関数を`try`/`except`の中で呼び出す必要がある
- C期待値がテスト対象のコード自身から取得されているため、チェックが失敗することがない
- D`1.2`は境界値ではないため、テストする価値がない
回答にはアカウントが必要です
ログインして答えを確認
問題は上にあります。頭の中で答えを考えることがいちばん大切です。ログインすると、正解・解説・3 段階のヒントを確認できます。
やってみよう
映画館から次のような要件が送られてきました:
「子供は安く、年金受給者は割引、赤ちゃんは無料。大人は通常料金の300。」
1つのチェックも書く前に、この章で行ったすべてのことを順番に実行してください:
- 質問を投げかける。 この文があいまいなまま残している点を少なくとも5つ書き出してください — 「子供」の意味、「年金受給者」が何歳からか、境界値が含まれるかどうか、料金はいくらか、あり得ない年齢が渡されたらどうするか。次に、映画館側の立場で答えを決定します:3歳未満は無料、3〜12歳は150、13〜64歳は300、65歳以上は200、年齢は0〜120歳とし、それ以外の値は
ValueErrorを送出するようにします。 - 契約を書く:
cinema.py内の関数ticket_price(age)のdocstringとして記述します。 - テスト可能にする:引数を受け取り、戻り値を返し、
input()もprint()も使わないようにします。 - ケース表を書く:ケース、入力、期待値、理由。まず同値クラスを見つけ、次にあらゆる境界の両側にケースを配置します。すべての期待値は契約から導き出します。
- テストしないものを決定する:そしてその理由を説明します。
check_cinema.pyを書く:表の1行につき1つの素のassertを使って記述し、実行します。
次に、わざと関数を壊してみましょう:子供のルールを「12歳以下」から「12歳未満」へ — <=から<へ変更します。実行する前に、どのチェックが失敗するかを予測してください。実行して結果を比較してみましょう。
解答例
1. 質問と、決定された回答:
| 質問 | 回答 | | --- | --- | | 赤ちゃんは何歳まで無料か? | 0歳、1歳、2歳。3歳からは有料。 | | 「子供」とは何歳か? | 3〜12歳(両端を含む)。料金は150。 | | 13歳は子供か? | いいえ — 13歳は大人料金の300を支払う。 | | 年金受給者は何歳からか?65歳そのものは含まれるか? | 65歳以上。料金は200。 | | あり得ない年齢とは何か? | 0歳未満または120歳超:ValueError。 | | 何が返されるか? | 料金の整数値。画面出力は行わない。 |
2および3. 契約と関数であるcinema.pyです。境界値には名前付き定数を使用しているため、コードは表と同じように読み取れます:
FREE_UNDER = 3 # ages 0-2 go free
CHILD_UP_TO = 12 # ages 3-12 pay the child price
SENIOR_FROM = 65 # ages 65 and over pay the senior price
MAX_AGE = 120
def ticket_price(age):
"""Return the price of one cinema ticket, as a whole number.
- age: a whole number of years, 0 to 120. Outside that range: ValueError.
- 0-2: 0 (free), 3-12: 150, 13-64: 300, 65 and over: 200.
- No side effects: reads nothing, prints nothing, changes nothing.
"""
if not 0 <= age <= MAX_AGE:
raise ValueError(f"age must be 0-{MAX_AGE}, got {age}")
if age < FREE_UNDER:
return 0
if age <= CHILD_UP_TO:
return 150
if age < SENIOR_FROM:
return 300
return 2004. ケース表。 クラスは次の通りです:無効(0未満)、無料(0〜2)、子供(3〜12)、大人(13〜64)、シニア(65〜120)、無効(120超)。これにより、4つの代表ケース、8つの境界値ケース、そして2つの無効ケースが得られます:
| ケース | 入力 | 期待値 | 理由 | | --- | --- | --- | --- | | 代表的な赤ちゃん | 1 | 0 | 無料クラス | | 代表的な子供 | 8 | 150 | 子供クラス | | 代表的な大人 | 30 | 300 | 大人クラス | | 代表的なシニア | 80 | 200 | シニアクラス | | 新生児 | 0 | 0 | 有効な最小の年齢 | | 無料の最後の年齢 | 2 | 0 | 無料/子供の境界の直前 | | 子供の最初の年齢 | 3 | 150 | 無料/子供の境界上 | | 子供の最後の年齢 | 12 | 150 | 「12歳以下」 — 境界そのもの | | 大人の最初の年齢 | 13 | 300 | 子供の境界の直後 | | 大人の最後の年齢 | 64 | 300 | シニアの境界の直前 | | シニアの最初の年齢 | 65 | 200 | 「65歳以上」 — 境界そのもの | | 有効な最大の年齢 | 120 | 200 | 有効範囲の上限 | | 範囲未満 | -1 | ValueError | 範囲の直前(下限側) | | 範囲超過 | 121 | ValueError | 範囲の直後(上限側) |
5. テストしないもの: 年齢としての文字列やNone — 契約には整数と書かれており、それ以上の約束はしていません。12.5のような小数の年齢も同様です。他の映画館の料金や、Pythonの<= — 私たちがテストすべきものではありません。また、8の隣に9を置くような同じクラス内の2つ目の代表ケースもテストしません — 新しく検知できるものがないからです。
6. チェックコードであるcheck_cinema.py:
from cinema import ticket_price
def raises_value_error(age):
"""True if ticket_price raises ValueError for this age."""
try:
ticket_price(age)
except ValueError:
return True
return False
# One typical age from each class.
assert ticket_price(1) == 0
assert ticket_price(8) == 150
assert ticket_price(30) == 300
assert ticket_price(80) == 200
# Both sides of every edge between classes.
assert ticket_price(0) == 0
assert ticket_price(2) == 0
assert ticket_price(3) == 150
assert ticket_price(12) == 150
assert ticket_price(13) == 300
assert ticket_price(64) == 300
assert ticket_price(65) == 200
assert ticket_price(120) == 200
# Invalid input, just outside the valid range on each side.
assert raises_value_error(-1)
assert raises_value_error(121)
print("all 14 checks passed")$ python check_cinema.py
all 14 checks passed意図的なバグ: if age <= CHILD_UP_TO:をif age < CHILD_UP_TO:に変更します。クラスが変わるのは12歳だけであり — 大人料金へとフォールスルーします — したがって12のチェックだけが失敗するはずです:
$ python check_cinema.py
Traceback (most recent call last):
File "/home/you/cinema/check_cinema.py", line 23, in <module>
assert ticket_price(12) == 150
^^^^^^^^^^^^^^^^^^^^^^^
AssertionError予測通り、23行目のassert ticket_price(12) == 150が失敗しました。
なぜこのように進めるのか:
- まず質問から始めたこと。 「子供」や「年金受給者」は数値ではありません。最初の表のすべての回答が、言葉をテスト可能な境界へと変換しました。それがなければ、
12や65は単なる当て推量になっていたでしょう。 - 関数が純粋であること。 年齢を入力し、料金を出力し、それ以外のことは何もしません — そのため、すべてのチェックは1行で済み、どのマシンのどの時間帯に実行しても同じ結果が得られます。
- 境界値に名前付き定数を使用したこと。
CHILD_UP_TO = 12によって契約の内容が1箇所に記述され、チェック側でも同じ数値を指定しています。 - 境界値の行が真価を発揮したこと。 4つの代表的な年齢 — 1、8、30、80 — は、バグが存在していてもすべてパスしてしまいます。境界そのものである
12の行だけが、それを検知できました。これこそが、一度の実行で示された境界値の存在意義のすべてです。 - すべての期待値が契約から導き出されたこと。
ticket_priceを実行した結果からコピーしたものは1つもありません。そのため、コードの間違った答えがチェックに紛れ込むことはありません。 - 最初の失敗で停止し、行番号しか教えてくれなかったこと。 予測していたからこそどのチェックが失敗したのか分かりましたが、12歳に対する実際の料金が300だったことは教えてくれませんでした。これこそが、次の章で埋めることになるギャップです。