テスト設計とTDD — 良いテストの書き方
テストを書く前の計画、Arrange-Act-Assert、振る舞いをテストすること、red-green-refactor、Hypothesisによるプロパティベーステスト、フレーキーテストの原因と対策、バグごとのリグレッションテスト。
どうやって学びますか?
解決する問題
ここまでで、このコースで学んだツールはすべて身についたはずです:assert、raises、parametrize、フィクスチャ、モック、マーカー、設定、カバレッジ、プラグイン。ところが、そのすべてを使いこなしても、テストが役に立つどころか害になってしまうことは十分にあり得ます。
ここに、2つのテストがパスする小さなショッピングカートがあります:
class Cart:
def __init__(self):
self._items = []
def add(self, name, price, quantity=1):
self._items.append((name, price, quantity))
def total(self):
return sum(price * quantity for _, price, quantity in self._items)from cart import Cart
def test_add():
cart = Cart()
cart.add("pen", 15.0, 2)
assert cart._items == [("pen", 15.0, 2)]
def test_total():
cart = Cart()
cart.add("pen", 15.0, 2)
cart.add("bag", 850.0)
assert cart.total() == 880.0.. [100%]
2 passed in 0.01sここで誰かがカートを改善します。同じ商品を2回追加したら1行にまとめるべきなので、リストを商品名をキーにした辞書に変えます。カートは相変わらず商品を追加でき、合計も正しく出ます。お客さんが気づける変化は何もありません:
class Cart:
def __init__(self):
self._lines = {}
def add(self, name, price, quantity=1):
_, already = self._lines.get(name, (price, 0))
self._lines[name] = (price, already + quantity)
def total(self):
return sum(price * quantity for price, quantity in self._lines.values())F. [100%]
=================================== FAILURES ===================================
___________________________________ test_add ___________________________________
def test_add():
cart = Cart()
cart.add("pen", 15.0, 2)
> assert cart._items == [("pen", 15.0, 2)]
^^^^^^^^^^^
E AttributeError: 'Cart' object has no attribute '_items'
test_cart.py:7: AssertionError
=========================== short test summary info ============================
FAILED test_cart.py::test_add - AttributeError: 'Cart' object has no attribut...
1 failed, 1 passed in 0.01sテストは赤くなりましたが、バグはありません。このテストはカートが何をするかを確認していたのではなく、カートがどう作られているかを確認していたのです。こうしたテストは、コードを改善すると失敗し、本当に壊れても黙っています。テストの仕事とまったく逆です。
この最後の章は、新しいツールの話ではありません。判断力の話です。良いテストとはどういうものか、テストにコードを導かせる方法、コンピュータにテストケースを考えさせる方法、そしてテストがランダムに失敗しないようにする方法を扱います。
この章を終える頃には
- テストを書く前に計画を立てられる:契約、ケース、そして何を書かないか
- テストをArrange、Act、Assertの3段階で組み立て、文として読める名前を付けられる
- 振る舞いのテストと実装のテストを見分け、後者を前者に書き直せる
- 失敗(Red)を必ず自分の目で確かめながら、red-green-refactorのサイクルで作業できる
- Hypothesisでプロパティベーステストを書き、縮小された失敗例を読める
- フレーキーテストの一般的な4つの原因を見つけて直せる
- バグ報告をすべてリグレッションテストに変えられる
前提条件: asyncテスト、フック、プラグイン。
テストを書く前に
悪いテストの多くは、書き方が下手なのではありません。計画が下手なのです。つまり、コードが何を約束するのかを誰も決めないうちに書かれています。そこで、テストコードを書く前に4つの質問に答えます。
この章では、テストを先に書きながら小さな関数を1つ作っていきます。slugifyです。投稿のタイトル(例:"Café au lait")を、URLの一部である"cafe-au-lait"に変換する関数です。
1. 契約は何か? 呼び出し側から見た形で、1〜2文で言います。タイトル(`str`)を受け取り、スラッグを返す。スラッグは小文字のASCII英字と数字だけからなり、単語は1つのハイフンでつなぎ、両端にハイフンは付かない。アクセント付きの文字は、そのアクセントなしの文字になる。 副作用はありません。ファイルにも、時計にも、ネットワークにも触れません。契約が何も言っていない点に注目してください。正規表現、Unicodeの正規化、補助関数などです。それらは実装の方法であり、自由に変えてかまいません。
2. 何を用意しておく必要があるか? ツールをインストールした仮想環境と、テストからコードをインポートできる状態です:
$ python -m venv .venv
$ source .venv/bin/activate
$ pip install pytest hypothesis次に、slugs.pyとtest_slugs.pyの2つのファイルを同じフォルダに置き、そのフォルダでpytestを実行します。これでfrom slugs import slugifyが動きます。このテーマにはフィクスチャも一時ファイルも環境変数も必要ありません。これも覚えておく価値があります。純粋関数は、テストするのがいちばん簡単なものです。だからこそ、ロジックは純粋関数に寄せるのがよい理由になります。
3. どのケースを試すか? まず正常系、次に端のケース、続いて不正な入力、最後に境界値の順に考えます:
| ケース | 入力 | 期待 | | --- | --- | --- | | 普通の単語 | "Hello World" | "hello-world" | | 句読点 | "Hello, World!" | "hello-world" | | アクセント付きの文字 | "Café au lait" | "cafe-au-lait" | | 両端の余分な空白 | " Hello World " | "hello-world" | | 数字は残す | "Top 10 Tips" | "top-10-tips" | | 使える文字がない | "!!!" | ? |
この疑問符が、表の中でいちばん役に立つマスです。計画を書いたことで、まだ誰も決めていない判断が見えてきました。タイトルに英字がまったく含まれていないとき、どうなるべきかです。ここではあえて未決のままにして、この章の後半で、未決の問いがどんなコストになるかを見ていきます。
4. 何をテストしないか? Pythonのreモジュール、unicodedata、str.lower()には、すでに独自のテストがあります。それらをもう一度テストしても、時間の無駄になるだけです。プライベートな補助関数(_で始まるもの)は、直接ではなく`slugify`を通してテストします。そして、すべてのUnicode文字を手作業で列挙しようとはしません。それはこの章の後半で扱う、プロパティベースのテストの仕事です。
この表がそのままテストになります。以下のすべてが、この計画を実装していきます。
Arrange, Act, Assert
良いテストには、必ず同じ3つの部分があり、その順番も同じです:
- Arrange — テストに必要な状況を組み立てる
- Act — テストの対象となるひとつの操作を行う
- Assert — 結果を確認する
カートのテストをもう一度見てみましょう。今度は、内部でどう保存しているかではなく、カートが約束していることを通して確認します:
from cart import Cart
def test_an_empty_cart_totals_zero():
cart = Cart()
assert cart.total() == 0
def test_total_is_price_times_quantity_summed_over_items():
# Arrange
cart = Cart()
cart.add("pen", 15.0, 2)
cart.add("bag", 850.0)
# Act
total = cart.total()
# Assert
assert total == 880.0
def test_adding_the_same_item_twice_adds_up_the_quantity():
cart = Cart()
cart.add("pen", 15.0, 2)
cart.add("pen", 15.0, 1)
assert cart.total() == 45.0リストベースのカートに対して、そして辞書ベースのカートに対して、もう一度実行した結果です:
... [100%]
3 passed in 0.01s
... [100%]
3 passed in 0.01s同じテストを、2つの実装に対して実行しました。どちらも全部グリーンです。これこそ望ましい性質です。振る舞いを変えないリファクタリングなら、テストはグリーンのままであるべきです。真ん中のテストのコメントは形を示すためだけのものです。多くの人は、コメントを消して、3つの部分を空行で区切ります。3つ目のテストのように。
なぜActを1ステップにすることが大切なのでしょうか? テストが失敗したとき、どれが壊れたのかを知りたいからです。Actが5回の呼び出しだったら、失敗しても容疑者が5つあることになります。
実装ではなく振る舞いをテストする
経験則はこうです。テストが使ってよいのは、呼び出し側が使ってよいものだけです。 カートならCart()、.add()、.total()です。_itemsや_linesは使いません。先頭のアンダースコアは、「これは内部のものなので、変えるかもしれない」というPythonの合図です。
間違ったやり方は、契約には含まれていなかった決定、つまり「商品をリストで保持している」という決定にテストを結びつけてしまいます。正しいやり方は、呼び出し側が尋ねるであろう質問をすることです。これらを追加したら、合計はいくらになる?
テストが実装に結びついている兆候は2つあります:
_で始まる属性を読んでいる、またはコードが内部で呼んでいる関数をモックしている- ユーザーが気づけないリファクタリングで、赤くなる
ここでモックについても触れておきます。第11章のモックは、境界、つまりネットワーク、時計、決済サービスで使うのが適切な道具です。自分のコードの内部で使うと(「_calculateがこの引数で呼ばれたことをアサートする」など)、名前を変えただけの実装テストになってしまいます。
文として読める名前と、失敗する理由は1つ
3つのカートのテストを-vで実行します:
test_cart_behaviour.py::test_an_empty_cart_totals_zero PASSED [ 33%]
test_cart_behaviour.py::test_total_is_price_times_quantity_summed_over_items PASSED [ 66%]
test_cart_behaviour.py::test_adding_the_same_item_twice_adds_up_the_quantity PASSED [100%]名前だけを読んでも、カートの仕様がわかります。test_addやtest_totalは、どのメソッドが触られたかしか教えてくれません。良い名前は状況と期待する結果を言います。空のカートの合計は0になるのように。長くても構いません。名前を自分で打ち込むことはありませんし、失敗したときには毎回読むことになるのですから。
ルールのもう半分は失敗する理由は1つです。その逆の例を見てみましょう。すべてを確認する1つのテストを、バグのあるカート(quantityを無視する)に対して実行します:
from cart import Cart
def test_cart():
cart = Cart()
assert cart.total() == 0
cart.add("pen", 15.0, 2)
cart.add("bag", 850.0)
assert cart.total() == 880.0
cart.add("pen", 15.0, 1)
assert cart.total() == 895.0$ pytest -q --tb=no
F [100%]
=========================== short test summary info ============================
FAILED test_one_big.py::test_cart - assert 865.0 == 880.0
1 failed in 0.01s(--tb=noはトレースバックを隠し、要約だけを残します。最初に読むべき部分です。)test_cartは失敗しましたが、それだけでは何もわかりません。865.0 == 880.0という数字を前に、何がおかしいのかを自分で考え込まなければなりません。しかも3つ目のアサーションは実行されなかったので、それが通ったかどうかもわかりません。同じバグを、焦点を絞った3つのテストに対して実行すると:
$ pytest -q --tb=no
.FF [100%]
=========================== short test summary info ============================
FAILED test_cart_behaviour.py::test_total_is_price_times_quantity_summed_over_items
FAILED test_cart_behaviour.py::test_adding_the_same_item_twice_adds_up_the_quantity
2 failed, 1 passed in 0.01sトレースバックを1つも開く前に、要約がすでに診断結果のように読めます。空のカートは問題ないが、数量の計算がおかしい、と。「失敗する理由は1つ」は、assertの行が1つだという意味ではありません。同じ結果の2つの側面を確認する2つのアサーションなら問題ありません。テストごとに振る舞いが1つだという意味です。
テストピラミッド
テストには大きさがあります:
- ユニットテストは、周囲に実物を置かずに、1つの関数やクラスを確認します。1件ごとにミリ秒単位です。数千件あるのが普通です。
- 結合テストは、部品がきちんと組み合わさることを確認します。実際のデータベース、実際のファイルシステム、テストクライアント経由の実際のHTTPアプリに対してコードを動かします。遅くなります。数十から数百件です。
- E2Eテストは、ユーザーと同じようにシステム全体を動かします。ブラウザや、デプロイ済みのAPIを使います。遅く、壊れやすいものです。収益につながる経路だけを対象に、数件程度にとどめます。
件数で描くと、ピラミッドの形になります。底は広いユニットテスト、頂点は細いE2Eテストです。理由はコストです。ユニットテストが失敗すると、指し示すのは数行です。E2Eテストが失敗すると、指し示すのはシステム全体です。それぞれの確認は、ピラミッドのできるだけ下の層に置き、頂点は、頂点からしか見えないものだけに使いましょう。
Red, green, refactor
テスト駆動開発(TDD)は、短いループで進みます:
- Red — まだ存在しない振る舞いについて、小さなテストを1つ書き、それが失敗するのを見る
- Green — テストを通す最小限のコードを書く
- Refactor — テストをずっとグリーンに保ったまま、コードを整理する
なぜ失敗するのを見るのでしょうか? 一度も失敗するのを見たことのないテストは、何も確認していないかもしれないからです。Redのステップは、そのテストが機能の欠如を検出できることを証明します。
表の計画を、1行ずつ取り上げていきます。
Red. slugs.pyがまだ存在しない状態で、表の1行目を書きます:
from slugs import slugify
def test_lowercases_and_joins_words_with_hyphens():
assert slugify("Hello World") == "hello-world"==================================== ERRORS ====================================
________________________ ERROR collecting test_slugs.py ________________________
ImportError while importing test module '/home/you/blog/test_slugs.py'.
Hint: make sure your test modules/packages have valid Python names.
Traceback:
/usr/lib/python3.12/importlib/__init__.py:90: in import_module
return _bootstrap._gcd_import(name[level:], package, level)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
test_slugs.py:1: in <module>
from slugs import slugify
E ModuleNotFoundError: No module named 'slugs'
=========================== short test summary info ============================
ERROR test_slugs.py
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.01sこれは、まったく問題のないRedです。テストは、まだ存在しないものを要求しているのです。
Green. パスさせる最小限のコード。本当に最小限です:
def slugify(title):
return title.lower().replace(" ", "-"). [100%]
1 passed in 0.01sずるをしているように感じるかもしれません。でもこれは意図的です。コードは、これまでにテストが求めたことだけをします。それ以上書けば、どのテストも確認していないコードになってしまいます。
Red. 表の2行目です:
def test_drops_punctuation():
assert slugify("Hello, World!") == "hello-world".F [100%]
=================================== FAILURES ===================================
____________________________ test_drops_punctuation ____________________________
def test_drops_punctuation():
> assert slugify("Hello, World!") == "hello-world"
E AssertionError: assert 'hello,-world!' == 'hello-world'
E
E - hello-world
E + hello,-world!
E ? + +
test_slugs.py:9: AssertionError
=========================== short test summary info ============================
FAILED test_slugs.py::test_drops_punctuation - AssertionError: assert 'hello,...
1 failed, 1 passed in 0.01s?の行が、そこにあってはいけない2文字をぴったり示しています。
Green. 空白を1つ置き換えるだけでは、もう足りません。「英字でも数字でもないもの」の連続を、すべてハイフン1つに置き換え、そのあと両端のハイフンを取り除きます:
import re
def slugify(title):
return re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-").. [100%]
2 passed in 0.01s余分な空白と数字の行は、このコードですでにパスします。それでも、契約の一部なので追加しておきましょう。ただし、最初の実行でグリーンになったテストは、今書いたコードについて何も証明していないことを覚えておいてください。証明できるのは、Redを経てからGreenになったテストだけです。
Red. アクセント付きの文字:
def test_turns_accented_letters_into_plain_ones():
assert slugify("Café au lait") == "cafe-au-lait"..F [100%]
=================================== FAILURES ===================================
_________________ test_turns_accented_letters_into_plain_ones __________________
def test_turns_accented_letters_into_plain_ones():
> assert slugify("Café au lait") == "cafe-au-lait"
E AssertionError: assert 'caf-au-lait' == 'cafe-au-lait'
E
E - cafe-au-lait
E ? -
E + caf-au-lait
test_slugs.py:13: AssertionError
=========================== short test summary info ============================
FAILED test_slugs.py::test_turns_accented_letters_into_plain_ones - Assertion...
1 failed, 2 passed in 0.01séはa-zに含まれないので、句読点と一緒に捨てられてしまいました。
Green. Unicodeの「NFKD」正規化は、éをeと独立したアクセント記号に分解します。そのあと"ignore"を指定してASCIIにエンコードすると、アクセント記号が落ち、eだけが残ります:
import re
import unicodedata
def slugify(title):
plain = unicodedata.normalize("NFKD", title).encode("ascii", "ignore").decode()
return re.sub(r"[^a-z0-9]+", "-", plain.lower()).strip("-")... [100%]
3 passed in 0.01sRefactor. これで動きますが、本体が1行にぎゅっと詰まっています。各ステップに名前を付け、パターンは一度だけコンパイルし、型ヒントを追加します。振る舞いは一切変えずに:
import re
import unicodedata
NOT_ALLOWED = re.compile(r"[^a-z0-9]+")
def _to_ascii(text: str) -> str:
"""Split accented letters into letter + accent, then drop the accents."""
decomposed = unicodedata.normalize("NFKD", text)
return decomposed.encode("ascii", "ignore").decode("ascii")
def slugify(title: str) -> str:
words = NOT_ALLOWED.sub("-", _to_ascii(title).lower())
return words.strip("-")test_slugs.py::test_lowercases_and_joins_words_with_hyphens PASSED [ 33%]
test_slugs.py::test_drops_punctuation PASSED [ 66%]
test_slugs.py::test_turns_accented_letters_into_plain_ones PASSED [100%]
============================== 3 passed in 0.01s ===============================ここで、TDDの価値が出てきます。テストが振る舞いを確認しているからこそ、自由に構造を変えられるのです。もしテストが_to_asciiや正規表現に手を伸ばしていたら、このリファクタリングでテストは壊れていたでしょう。
Hypothesisによるプロパティベーステスト
これまでのテストは、どれも作った例を1つだけ使っていました。"Hello World"や"Café au lait"です。例の質は、あなたの想像力の範囲を超えられません。そして想像力は、今書いたコードと同じ死角を持っています。
プロパティとは、どんな入力に対しても成り立つ主張のことです。Hypothesisが入力を生成します。デフォルトではテストごとに100件です。そして、その主張を崩す入力を一生懸命探します。@givenは入力がどこから来るかを指定し、strategies(いつもstとしてインポートします)は入力の形を記述します:
from hypothesis import given
from hypothesis import strategies as st
@given(
prices=st.lists(st.integers(min_value=0, max_value=10_000), max_size=20),
discount=st.integers(min_value=0, max_value=100),
)
def test_a_discount_never_raises_the_total(prices, discount):
total = sum(prices)
discounted = total * (100 - discount) // 100
assert 0 <= discounted <= total. [100%]
1 passed in 0.01sドットは1つですが、中では価格のリストと割引率が100通りずつ流れていました。ほかによく使う戦略は、st.text()、st.floats()、st.booleans()、st.sampled_from([...])、st.dictionaries(...)、st.builds(...)です。
slugifyにはどんなプロパティがあるでしょうか? ランダムな文字列のスラッグが何かは言えません。でも、スラッグがどんな形でなければならないかは言えます。そして、スラッグをさらにスラッグ化しても何も変わらないことも言えます:
import re
from hypothesis import given
from hypothesis import strategies as st
from slugs import slugify
@given(st.text())
def test_a_slug_only_holds_lowercase_letters_digits_and_inner_hyphens(title):
slug = slugify(title)
assert re.fullmatch(r"[a-z0-9]+(-[a-z0-9]+)*|", slug)
@given(st.text())
def test_slugifying_a_slug_changes_nothing(title):
once = slugify(title)
assert slugify(once) == once.. [100%]
2 passed in 0.01sst.text()は、絵文字、中国語、制御文字、空文字列などを生成します。計画表に書こうとは決して思わなかったような入力です。
ラウンドトリップが本物のバグを見つける
最も効果的なプロパティはラウンドトリップです。何かをエンコードしてから再びデコードできるなら、エンコードしたものをデコードすると元に戻らなければなりません。ここにランレングス符号化のエンコーダがあります。"aaab"は"3a1b"になります。パスする例のテストが2つ付いています:
import re
def encode(text: str) -> str:
"""'aaab' -> '3a1b': each run of a character becomes count + character."""
out = []
for match in re.finditer(r"(.)\1*", text, flags=re.DOTALL):
run = match.group(0)
out.append(f"{len(run)}{run[0]}")
return "".join(out)
def decode(encoded: str) -> str:
return "".join(char * int(count) for count, char in re.findall(r"(\d+)(\D)", encoded))from hypothesis import given
from hypothesis import strategies as st
from rle import decode, encode
def test_encode_counts_each_run():
assert encode("aaab") == "3a1b"
def test_decode_expands_each_run():
assert decode("3a1b") == "aaab"
@given(st.text())
def test_decoding_an_encoding_gives_back_the_original(text):
assert decode(encode(text)) == text..F [100%]
=================================== FAILURES ===================================
______________ test_decoding_an_encoding_gives_back_the_original _______________
@given(st.text())
> def test_decoding_an_encoding_gives_back_the_original(text):
^^^
test_rle.py:16:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
text = '0'
@given(st.text())
def test_decoding_an_encoding_gives_back_the_original(text):
> assert decode(encode(text)) == text
E AssertionError: assert '' == '0'
E
E - 0
E Failing test case: test_decoding_an_encoding_gives_back_the_original(
E text='0',
E )
test_rle.py:17: AssertionError
=========================== short test summary info ============================
FAILED test_rle.py::test_decoding_an_encoding_gives_back_the_original - Asser...
1 failed, 2 passed in 0.01sテキスト"0"は"10"にエンコードされます。「0が1つ」という意味です。デコーダーは10を、後ろに文字のない件数として読んでしまいます。数字を含むテキストは、すべて壊れます。2つの例のテストはどちらも英字しか使っていなかったので、このバグは決して見えなかったのです。
縮小(シュリンク)
Hypothesisは最初に"0"に行き当たったわけではありません。長くて見栄えの悪い文字列を見つけ、それから縮小しました。より小さく、より単純な入力を試し、まだ失敗するものだけを残し、もうそれ以上単純にしても失敗しなくなるまで続けます。失敗した入力を記録すると、その過程を見ることができます:
from hypothesis import given, seed, settings
from hypothesis import strategies as st
from rle import decode, encode
failing = []
@seed(2026)
@settings(database=None)
@given(st.text())
def round_trip(text):
if decode(encode(text)) != text:
if text not in failing:
failing.append(text)
raise AssertionError
try:
round_trip()
except AssertionError:
pass
for text in failing:
print(repr(text))'wfê\x9f\x0c\U000b6082Ñ/\x879\x9dÂ\n'
'\x03\U000a1acb\U0003c4fc1'
'´\U000cd3dc\U00081acfÔ÷\U0007122f\x89ê½5'
'l𨺮\nÝÛ\x894\x8b'
'\U00083189𤾨«ñï\xa0^ó8A\x96\x88=\x05'
'0000'
'000'
'00'
'0'(@seedは乱数の選択を固定するので、この実行は毎回同じ結果になります。database=Noneは、覚えておいた失敗をHypothesisが再生しないようにします。)最初の失敗例は、中に9が隠れた13文字のノイズです。これを何分も見つめることになったでしょう。縮小された例は、1文字で全体を語っています。数字が壊しているのです。
Hypothesisは失敗した例を.hypothesis/フォルダにも保存するので、次の実行では最初に"0"が試されます。このフォルダは.gitignoreに追加しておきましょう。
修正は、件数と文字の間に区切りを入れることです。これで、数字が件数の一部と誤認されることはなくなります。また、Hypothesisが見つけた例は@exampleで固定し、どのマシンでも毎回実行されるようにします:
import re
def encode(text: str) -> str:
"""'aaab' -> '3:a1:b': each run becomes count, a colon, then the character."""
out = []
for match in re.finditer(r"(.)\1*", text, flags=re.DOTALL):
run = match.group(0)
out.append(f"{len(run)}:{run[0]}")
return "".join(out)
def decode(encoded: str) -> str:
pairs = re.findall(r"(\d+):(.)", encoded, flags=re.DOTALL)
return "".join(char * int(count) for count, char in pairs)2つの例のテストは新しい形式("3:a1:b")に変わり、プロパティには1行が加わります:
from hypothesis import example, given
from hypothesis import strategies as st
from rle import decode, encode
@given(st.text())
@example("0") # the input Hypothesis found; now it is checked on every run
def test_decoding_an_encoding_gives_back_the_original(text):
assert decode(encode(text)) == text... [100%]
3 passed in 0.01sプロパティは例に取って代わるものではありません。2つの例のテストは、読む人が一目で分かる形でフォーマットを説明します。プロパティは、誰も思いつかなかった隅々を守ります。両方使いましょう。
フレーキーテスト
フレーキーなテストとは、同じコードに対してパスしたり失敗したりするテストのことです。テストがないよりも悪い存在です。人は「再実行」ボタンを押すことを覚え、赤が出ても信用しなくなります。ほぼすべてのフレーキーテストは、4つの原因のどれかから来ています。そして、それぞれに、原因を隠すのではなく取り除く修正方法があります。
共有状態とテストの順序
_users = set()
def register(name):
_users.add(name)
def count():
return len(_users)from registry import count, register
def test_registering_a_user_counts_them():
register("rahim")
assert count() == 1
def test_a_new_registry_is_empty():
assert count() == 0$ pytest -q --tb=no
.F [100%]
=========================== short test summary info ============================
FAILED test_registry.py::test_a_new_registry_is_empty - assert 1 == 0
1 failed, 1 passed in 0.01s
$ pytest -q "test_registry.py::test_a_new_registry_is_empty"
. [100%]
1 passed in 0.01sスイート全体では失敗し、単独では通ります。モジュールレベルのセットは、あるテストから次のテストへと残り続けるので、2つ目のテストは1つ目が残したものを見てしまいます。順序を変えたり、pytest-xdistでテストを並列実行したりすると、結果も変わります。間違った修正は、テストの順番を並べ替えることです。正しい修正は、共有状態を取り除くことです。レジストリをオブジェクトにし、フィクスチャを通して各テストに新しいものを1つずつ渡します:
class Registry:
def __init__(self):
self._users = set()
def register(self, name):
self._users.add(name)
def count(self):
return len(self._users)import pytest
from registry import Registry
@pytest.fixture
def registry():
return Registry() # a fresh one for every test
def test_registering_a_user_counts_them(registry):
registry.register("rahim")
assert registry.count() == 1
def test_a_new_registry_is_empty(registry):
assert registry.count() == 0.. [100%]
2 passed in 0.01sコードを変えられない場合は、各テストの前後で状態をリセットするautouseフィクスチャが次善の策です。どのテストも、単独で、どんな順番でも通るべきです。
時間
from datetime import datetime
def greeting(now=None):
now = now or datetime.now()
return "Good morning" if now.hour < 12 else "Good afternoon"from greeting import greeting
def test_greets_the_morning():
assert greeting() == "Good morning" # true only before noon同じコードを、同じ瞬間に、タイムゾーンの違う2台のマシンで実行した結果です:
$ TZ=Europe/London pytest -q
. [100%]
1 passed in 0.01s
$ TZ=Asia/Dhaka pytest -q --tb=no
F [100%]
=========================== short test summary info ============================
FAILED test_greeting.py::test_greets_the_morning - AssertionError: assert 'Go...
1 failed in 0.01s修正方法は、関数のシグネチャにすでに用意されています。nowを外から渡せるのです。時計を自分で制御するテストは、境界の両側を確認でき、何時に実行しても同じ答えを返します:
from datetime import datetime
from greeting import greeting
def test_before_noon_it_says_good_morning():
assert greeting(now=datetime(2026, 1, 5, 9, 30)) == "Good morning"
def test_from_noon_on_it_says_good_afternoon():
assert greeting(now=datetime(2026, 1, 5, 12, 0)) == "Good afternoon".. [100%]
2 passed in 0.01s時刻を引数で渡すのは、どんなモックよりも簡単です。シグネチャを変えられない場合は、第10章のmonkeypatchで時計を差し替えます。
ランダム性
import random
def pick_winner(names, rng=random):
return rng.choice(names)from raffle import pick_winner
def test_picks_a_winner():
assert pick_winner(["rahim", "karim", "salma"]) == "rahim"何も変えずに6回実行した結果です:
$ for i in 1 2 3 4 5 6; do pytest -q | tail -1; done
1 failed in 0.01s
1 failed in 0.01s
1 failed in 0.01s
1 passed in 0.01s
1 passed in 0.01s
1 passed in 0.01s問いの種類に応じて、修正は2通りあります。どの結果でも成り立つことをアサートする。当選者は参加者のうちの1人である、というように。または、シード付きの乱数生成器を渡して、ランダム性を自分の管理下に置く:
import random
from raffle import pick_winner
def test_the_winner_is_one_of_the_entrants():
names = ["rahim", "karim", "salma"]
assert pick_winner(names) in names
def test_the_same_seed_picks_the_same_winner():
names = ["rahim", "karim", "salma"]
first = pick_winner(names, rng=random.Random(42))
second = pick_winner(names, rng=random.Random(42))
assert first == second$ for i in 1 2 3; do pytest -q | tail -1; done
2 passed in 0.01s
2 passed in 0.01s
2 passed in 0.01s外部の世界
4つ目の原因は、自分で管理していないものすべてです。本物のネットワーク呼び出し、本物のサーバー、「これで足りるはず」というsleep(0.1)などです。修正方法は前の章で見たものです。境界でモックし、共有フォルダの代わりにtmp_pathを使い、固定時間を待つ代わりに条件を待ちます。4つの原因はすべて、同じ1つの原則で片付きます。テストが依存するものは、テスト自身が作るか、管理すべきです。
バグごとにリグレッションテストを書く
計画表の疑問符に戻りましょう。誰もこの問いに答えていませんでした。そして、バグ報告が届きます。タイトルが`"!!!"`の投稿が`/posts/`に保存され、インデックスページを上書きしてしまった。
slugifyに手をつける前に、その報告を再現するテストを書き、失敗するのを見ます。それによって、そのテストがこのバグを捕まえられることが証明されます:
import pytest
from slugs import slugify
def test_a_title_with_no_letters_or_digits_is_rejected():
# Bug: slugify("!!!") returned "", and the post was saved at /posts/
with pytest.raises(ValueError, match="no letters or digits"):
slugify("!!!")$ pytest -q test_slugs_regressions.py
F [100%]
=================================== FAILURES ===================================
______________ test_a_title_with_no_letters_or_digits_is_rejected ______________
def test_a_title_with_no_letters_or_digits_is_rejected():
# Bug: slugify("!!!") returned "", and the post was saved at /posts/
> with pytest.raises(ValueError, match="no letters or digits"):
E Failed: DID NOT RAISE ValueError
test_slugs_regressions.py:8: Failed
=========================== short test summary info ============================
FAILED test_slugs_regressions.py::test_a_title_with_no_letters_or_digits_is_rejected
1 failed in 0.01sそして修正します。slugifyの末尾は次のようになります:
def slugify(title: str) -> str:
slug = NOT_ALLOWED.sub("-", _to_ascii(title).lower()).strip("-")
if not slug:
raise ValueError(f"title has no letters or digits: {title!r}")
return slug.... [100%]
4 passed in 0.01sリグレッションテストと、3つの例のテストはパスします。ところが、スイート全体を実行すると、プロパティのテストが異議を唱えます:
FAILED test_slugs_properties.py::test_a_slug_only_holds_lowercase_letters_digits_and_inner_hyphens
FAILED test_slugs_properties.py::test_slugifying_a_slug_changes_nothing - Val...
2 failed, 4 passed in 0.01sHypothesisは空文字列を試し、slugify("")が例外を投げるようになりました。これは新しいバグではありません。契約を意図的に変えたことで、プロパティがそれに気づいたのです。良いことです。プロパティを新しい契約に合わせて書き直します。少なくとも1つの英字か数字を含むタイトル、という契約です。
import re
import string
from hypothesis import given
from hypothesis import strategies as st
from slugs import slugify
# Any text, with at least one plain letter or digit somewhere inside it.
titles = st.builds(
lambda before, word, after: before + word + after,
st.text(),
st.text(alphabet=string.ascii_letters + string.digits, min_size=1),
st.text(),
)
@given(titles)
def test_a_slug_only_holds_lowercase_letters_digits_and_inner_hyphens(title):
slug = slugify(title)
assert re.fullmatch(r"[a-z0-9]+(-[a-z0-9]+)*", slug)test_slugifying_a_slug_changes_nothingも同じように@given(titles)に切り替えます。
...... [100%]
6 passed in 0.01sなぜバグごとにテストを書くのでしょうか? 一度起きたバグは、それが簡単に作り込まれることを証明しています。そして、そのコードに次に触る人も、同じバグを作り込みやすいからです。何が問題だったかを書いたコメントは、テストの一部です。1年後には、この一見おかしなケースがなぜここにあるのかを示す唯一の記録になっているはずです。
完全な例
この章のすべてを使って作る、パスワード強度のチェッカーです。まずは計画から:
| ケース | 入力 | 期待 | | --- | --- | --- | | 空 | "" | weak | | 4種類すべて使うが短すぎる | "Ab1!xyz" (7) | weak | | 十分な長さ、1種類 | "abcdefgh" | weak | | 十分な長さ、2種類 | "abcdefg1" | medium | | 12文字、3種類 | "abcdefghij1!" | strong | | 12文字、2種類 | "abcdefghijk1" | medium |
passwords.py:
def _kinds(password: str) -> int:
"""How many of the four kinds of character the password uses."""
return sum([
any(c.islower() for c in password),
any(c.isupper() for c in password),
any(c.isdigit() for c in password),
any(not c.isalnum() for c in password),
])
def strength(password: str) -> str:
"""Rate a password as 'weak', 'medium' or 'strong'."""
if len(password) < 8:
return "weak"
kinds = _kinds(password)
if len(password) >= 12 and kinds >= 3:
return "strong"
if kinds >= 2:
return "medium"
return "weak"test_passwords.py:
import pytest
from hypothesis import given
from hypothesis import strategies as st
from passwords import strength
RANK = {"weak": 0, "medium": 1, "strong": 2}
@pytest.mark.parametrize(
"password, expected",
[
("", "weak"), # nothing at all
("Ab1!xyz", "weak"), # every kind, but only 7 long
("abcdefgh", "weak"), # 8 long, one kind
("abcdefg1", "medium"), # 8 long, two kinds
("abcdefghij1!", "strong"), # 12 long, three kinds
("abcdefghijk1", "medium"), # 12 long, only two kinds
],
)
def test_strength_follows_the_length_and_variety_rules(password, expected):
assert strength(password) == expected
@given(st.text(), st.text())
def test_adding_characters_never_makes_a_password_weaker(password, extra):
before = strength(password)
after = strength(password + extra)
assert RANK[after] >= RANK[before]
def test_a_long_password_of_one_kind_is_still_weak():
# Bug: "aaaaaaaaaaaaaaaaaaaa" (20 letters) was rated "medium"
assert strength("a" * 20) == "weak"$ pytest -v
collected 8 items
test_passwords.py::test_strength_follows_the_length_and_variety_rules[-weak] PASSED [ 12%]
test_passwords.py::test_strength_follows_the_length_and_variety_rules[Ab1!xyz-weak] PASSED [ 25%]
test_passwords.py::test_strength_follows_the_length_and_variety_rules[abcdefgh-weak] PASSED [ 37%]
test_passwords.py::test_strength_follows_the_length_and_variety_rules[abcdefg1-medium] PASSED [ 50%]
test_passwords.py::test_strength_follows_the_length_and_variety_rules[abcdefghij1!-strong] PASSED [ 62%]
test_passwords.py::test_strength_follows_the_length_and_variety_rules[abcdefghijk1-medium] PASSED [ 75%]
test_passwords.py::test_adding_characters_never_makes_a_password_weaker PASSED [ 87%]
test_passwords.py::test_a_long_password_of_one_kind_is_still_weak PASSED [100%]
============================== 8 passed in 0.01s ===============================この3つの判断は注目に値します。
表の行は境界の上に並んでいます。7文字と8文字、11文字と12文字、2種類と3種類です。バグは境界に潜みます。<が本当は<=であるべき場合、範囲の真ん中にいるとそのミスは見えません。
このプロパティは、どの例も言えないことを言っています。文字を足しても、パスワードが弱くなることは決してない。 すべてのパスワードについて、この内容のテストを手で書く人はいません。Hypothesisは、大文字でも小文字でもないUnicodeの文字を含め、何百もの例でこれを確かめます。
そして、どのテストも_kindsには触れていません。strengthを通して使われるだけで、明日には消えているかもしれないからです。
困ったとき
リファクタリング後のAttributeError: 'Cart' object has no attribute '_items' テストがオブジェクトの内部を読んでいました。公開メソッド(呼び出し側が見られるもの)を通して確認するように書き直せば、次のリファクタリングも乗り越えられます。
単独では通るが、全体の実行では失敗する(またはその逆) 共有状態が原因です。モジュールレベルのリスト、辞書、キャッシュ。固定の場所にあるファイル。設定したきり解除されない環境変数。テストの順番を入れ替えるのではなく、各テストが必要なものを自分で作るようにします(フィクスチャ、tmp_path、monkeypatchなど)。
hypothesis.errors.FailedHealthCheck: It looks like this test is filtering out a lot of inputs. 0 inputs were generated successfully, while 50 inputs were filtered out. .filter()(またはassume())が、Hypothesisの生成した値のほとんどを捨ててしまっています。例えばst.integers().filter(lambda n: n % 1000 == 7)です。代わりに、欲しい値を直接作りましょう:st.integers().map(lambda n: n * 1000 + 7)。
hypothesis.errors.FlakyFailure: Hypothesis test_depends_on_earlier_runs(n=-25617) produces unreliable results: Failed on the first call but did not on a subsequent one 同じ入力に対して、テストが違う答えを返しました。生成された引数の外側にある何かが、呼び出しの間に変わったのです。カウンタ、モジュールレベルのリスト、時計などです。Hypothesisは失敗を再生して確認するので、隠れた状態を持つテストは縮小できません。
hypothesis.errors.DeadlineExceeded: Test took 300.07ms, which exceeds the deadline of 200.00ms. デフォルトでは、生成された例ひとつひとつが200ミリ秒以内に終わらなければなりません。遅い例が100個あれば、スイート全体が遅くなるからです。テストを速くするか、本当に遅いものなら@settings(deadline=...)で上限を上げます。
新しいTDDのテストを初めて実行したら、そのままパスした それは、これから書こうとしているコードについて何も証明していません。振る舞いがすでに存在しているか(それならOK。テストは文書として残しておきましょう)、テストがあなたの思っていることを確認していないかのどちらかです。少しの間わざとコードを壊して、テストが赤くなることを確かめましょう。
ステップ 4 / 6 — 予想する
理解度をチェック
ファイル全体として実行すると、2番目のテストが失敗します。2番目のテストだけを単独で実行したとき、最後の行は何になりますか?
_users = set()
def register(name):
_users.add(name)
def count():
return len(_users)
def test_registering_a_user_counts_them():
register("rahim")
assert count() == 1
def test_a_new_registry_is_empty():
assert count() == 0
# Run only the second test:
# pytest -q "test_registry.py::test_a_new_registry_is_empty"- A1 failed, 1 passed in 0.01s
- B1 failed in 0.01s
- C1 error in 0.01s
- D1 passed in 0.01s
Cartは内部でリストから辞書に切り替わりますが、振る舞いは変わりません。どのテストが赤くなりますか?
def test_total_of_two_pens():
cart = Cart()
cart.add("pen", 15.0, 2)
assert cart.total() == 30.0
def test_empty_cart_totals_zero():
assert Cart().total() == 0
def test_one_line_per_item():
cart = Cart()
cart.add("pen", 15.0)
assert len(cart._items) == 1
def test_adding_twice_adds_up():
cart = Cart()
cart.add("pen", 15.0)
cart.add("pen", 15.0)
assert cart.total() == 30.0- A`test_empty_cart_totals_zero`
- B`test_one_line_per_item`
- C`test_total_of_two_pens`
- D`test_adding_twice_adds_up`
このテストは月曜から金曜まで通りますが、土曜と日曜に失敗します。最善の修正は何ですか?
from datetime import date
def is_weekend(day=None):
day = day or date.today()
return day.weekday() >= 5
def test_weekdays_are_not_weekend():
assert is_weekend() is False- A固定の日付 — `is_weekend(date(2026, 10, 7))` — を渡し、土曜日用のテストを別に追加する
- B`is False`の代わりに`== False`と書く
- C失敗したら自動的にテストを再実行する
- D土曜と日曜はテストをスキップする
回答にはアカウントが必要です
ログインして答えを確認
問題は上にあります。頭の中で答えを考えることがいちばん大切です。ログインすると、正解・解説・3 段階のヒントを確認できます。
やってみよう
durations.pyに、テストを先に書きながら関数を2つ作ってください:
parse_duration(text)は"1h30m"を5400(秒)に変換します。単位はh、m、sで、この順番で、それぞれ省略可能です。"45s"、"2h"、"1h0m5s"はすべて有効です。それ以外(""、"h"、"1x"、"30m1h"、"1.5h")は、メッセージにnot a durationを含むValueErrorを発生させます。format_duration(seconds)は逆方向です。5400は"1h30m"に、3605は"1h5s"に、0は"0s"になります。負の数はValueErrorを発生させます。
次の順番で進めてください:
- まず計画表を書きます。ケース、入力、期待する結果を並べ、不正な入力と境界値(例えば
59秒と60秒)も含めます。 - 表を1行ずつ、red-greenのサイクルで進めます。変更するたびに
pytestを実行し、Greenにする前に必ずRedを確認します。 - Hypothesisでラウンドトリップのプロパティを追加します。
0以上の任意の整数秒について、parse_duration(format_duration(n)) == nが成り立つこと。 - ユーザーから、「
"90m"が拒否された。みんな普通に入力するのに」という報告が届きました。何が問題だったかを書いたコメント付きで、リグレッションテストを追加してください。
終わったら、一度リファクタリングして、スイート全体がグリーンのままであることを確認してください。
解答例
durations.py:
import re
UNITS = {"h": 3600, "m": 60, "s": 1}
PATTERN = re.compile(r"(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?")
def parse_duration(text: str) -> int:
"""'1h30m' -> 5400. Units must appear in the order h, m, s."""
match = PATTERN.fullmatch(text)
if not text or match is None:
raise ValueError(f"not a duration: {text!r}")
hours, minutes, seconds = (int(part or 0) for part in match.groups())
return hours * 3600 + minutes * 60 + seconds
def format_duration(seconds: int) -> str:
"""5400 -> '1h30m'. Zero is '0s'."""
if seconds < 0:
raise ValueError(f"duration cannot be negative: {seconds}")
if seconds == 0:
return "0s"
parts = []
for unit, size in UNITS.items():
amount, seconds = divmod(seconds, size)
if amount:
parts.append(f"{amount}{unit}")
return "".join(parts)test_durations.py:
import pytest
from hypothesis import given
from hypothesis import strategies as st
from durations import format_duration, parse_duration
@pytest.mark.parametrize(
"text, seconds",
[
("45s", 45),
("2h", 7200),
("1h30m", 5400),
("1h0m5s", 3605),
("0s", 0),
],
)
def test_parse_reads_hours_minutes_and_seconds(text, seconds):
assert parse_duration(text) == seconds
@pytest.mark.parametrize("text", ["", "h", "1x", "30m1h", "1.5h"])
def test_parse_rejects_text_that_is_not_a_duration(text):
with pytest.raises(ValueError, match="not a duration"):
parse_duration(text)
@pytest.mark.parametrize(
"seconds, text",
[(0, "0s"), (59, "59s"), (60, "1m"), (3605, "1h5s"), (5400, "1h30m")],
)
def test_format_writes_only_the_units_it_needs(seconds, text):
assert format_duration(seconds) == text
def test_format_rejects_a_negative_duration():
with pytest.raises(ValueError, match="negative"):
format_duration(-1)
@given(st.integers(min_value=0, max_value=10**7))
def test_parsing_a_formatted_duration_gives_back_the_seconds(seconds):
assert parse_duration(format_duration(seconds)) == seconds
def test_minutes_over_sixty_are_accepted():
# Bug: "90m" was rejected, though people type it all the time
assert parse_duration("90m") == 5400$ pytest -q
.................. [100%]
18 passed in 0.01sこの作りにした理由は次のとおりです:
- 計画が
parametrizeの表になりました。 計画表の1行がparametrizeの1行になるので、後からケースを追加するのは1行で済みます。-vの出力のテスト名を見れば、どの行が失敗したかがわかります。 - 有効な入力と不正な入力は、別々のテストにしています。 別々の振る舞いで、失敗する理由も別々だからです。不正な入力のリストには、ミスの種類ごとに1つずつ例を入れています。空、数字のない単位、知らない単位、順番の誤り、小数です。
match="not a duration"は型だけでなくメッセージも確認します。そのため、別の場所で偶然発生したValueError(例えばint(""))では、テストが通ってしまうことはありません。if not textがあるのは、すべての部分が省略可能なパターンは空文字列にもマッチしてしまうからです。これこそ、計画表が正規表現に任せる前に考えさせてくれるタイプのケースです。59と60が境界です。 秒が分に繰り上がる場所だからです。範囲の真ん中のテストでは、ここでの1ずれは決して見つかりません。- ラウンドトリップのプロパティは、このファイルで最も強力なテストです。期待値は1つも知らないのに、1000万通りの所要時間について、2つの関数の結果を突き合わせます。2つの関数の食い違いは必ず表に出て、それを引き起こす最小の数まで縮小されます。
- リグレッションテストは、この実装ではすでにパスします。その役目は、将来「整理」と称して分を
0-59に制限する変更が入り、バグが戻ってくるのを防ぐことです。そのコメントに理由が書いてあります。 PATTERNもUNITSも直接テストしていません。 どちらも実装の詳細であり、明日、正規表現を手書きのパーサーに置き換えても構いません。
次に進むために
これでこのコースは終わりです。テストを書き、その失敗を読み、構造を整え、切り離し、計測し、pytest自体を拡張できるようになりました。そして、この章を経て、そもそも何をテストする価値があるかを決められるようになりました。ここから先の方向性をいくつか挙げます:
- 実際のコードで習慣にしましょう。 手元のプロジェクトを1つ選び、いちばん変更が怖い部分にテストを追加してください。まずは計画表を書きます。テストが増えるにつれて、怖さは減っていきます。
- Hypothesisをさらに深めましょう。 そのドキュメントには、ステートフルテスト(
RuleBasedStateMachine)が載っています。これは、オブジェクトに対する操作の並び全体を生成し、どんな単一の入力でも見つけられないバグを見つけます。 - ミューテーションテスト。
mutmutのようなツールは、コードに小さな変更を加えます。<を<=に、+を-に、といった具合です。そして、どこかのテストが失敗することを確認します。生き残った変異は、カバレッジの数字がどれだけ高くても、テストが本当には確認していない行だということです。 - 第14章のCI設定を育てましょう。 サポートしているすべてのPythonバージョンでスイートを実行し、失敗したテストがマージを止めるようにします。誰にもグリーンを保つことを強制しないスイートは、やがてグリーンではなくなっていきます。
- 良いテストスイートを読みましょう。
pytest、requests、attrs、そしてhypothesis自身のテストは、オープンソースです。経験豊富な人たちがテストをどう名付け、どう構造化し、どう切り離しているかを読むことは、どんなルール一覧よりも多くを教えてくれます。
次に何を書くにしても、この章の冒頭の問いから始めてください。誰かが明日、コードの振る舞いを変えずに改善したら、私のテストはグリーンのままか?そして、誰かがそれを壊したら、テストは赤くなるか?