mcpField
T
testing-python-libraries

testing-python-libraries

github.com/wdm0006テスト・品質109127 回インストール2 日前に更新
リポジトリ

testing-python-libraries について

Python ライブラリー作者向けのテストスキルです。pytest の設定やテストパターンに加え、偽陽性のない成功を見分ける方法を扱い、ミューテーションテスト、実データに基づくフィクスチャ、実質的な分岐の分析も説明します。

主な機能

  1. 01pytest とカバレッジの設定
  2. 02パラメーター化、フィクスチャ、モック
  3. 03インストール済みパッケージのインポート設定
  4. 04偽の成功につながるテストの例
  5. 05ミューテーションテストと分岐分析

活用シーン

  1. 01Python ライブラリーのテスト設定
  2. 02テストが回帰を検出するか確認
  3. 03生き残ったミューテーションの調査
  4. 04手書きの想定だけを検証する状況の回避

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/wdm0006/python-skills --skill testing-python-libraries

インストール後、Claude Code で/testing-python-librariesを入力して読み込まれたか確認します。

同梱ファイルFIXTURES.mdHYPOTHESIS.md

詳細

Python ライブラリのテスト

クイックスタート

uv run pytest                       # Run tests
uv run pytest --cov=my_library      # With coverage
uv run pytest -x                    # Stop on first failure
uv run pytest -k "test_encode"      # Run matching tests

Pytest の設定

# pyproject.toml
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-ra -q --cov=my_library --cov-fail-under=85"

[tool.coverage.run]
branch = true
source = ["src/my_library"]
ソースツリーではなく、インストール済みパッケージをテストする

pytest のデフォルトの prepend インポートモードでは、__init__.py のない最初のディレクトリが sys.path に追加されます。そのため、コンパイル済みまたは生成された部分と並んでパッケージディレクトリがあるレイアウト(ネイティブ拡張機能用の python-source レイアウトや、tests/ の隣にパッケージがあるリポジトリルートなど)では、import my_library がインストール済みの wheel ではなく、生のソースツリーを参照します。その結果、誤った ModuleNotFoundError(コンパイル済みのサブモジュールが wheel にしか存在しない場合)が発生するか、さらに悪いことに、配布されるコードとは異なるコードに対してテストがすべて成功します。

個別のワークフローを修正するのではなく、設定で対処して、ローカル実行、CI、チームメイトのチェックアウトで同じ動作になるようにします。

[tool.pytest.ini_options]
addopts = "-ra -q --import-mode=importlib"

importlib モードでは pytest が sys.path を変更しないため、インポートは環境にインストールされたディストリビューション経由でのみ解決されます。ビルド手順自体をテストしたい場合は、CI で編集可能でないインストールと組み合わせてください。

テスト構成

tests/
├── conftest.py           # Shared fixtures
├── test_encoding.py
└── test_decoding.py

基本的なパターン

基本的なテスト:

def test_encode_valid_input():
    result = encode(37.7749, -122.4194)
    assert isinstance(result, str)
    assert len(result) == 12

パラメーター化:

@pytest.mark.parametrize("lat,lon,expected", [
    (37.7749, -122.4194, "9q8yy"),
    (40.7128, -74.0060, "dr5ru"),
])
def test_known_values(lat, lon, expected):
    assert encode(lat, lon, precision=5) == expected

フィクスチャ:

@pytest.fixture
def sample_data():
    return [(37.7749, -122.4194), (40.7128, -74.0060)]

def test_batch(sample_data):
    results = batch_encode(sample_data)
    assert len(results) == 2

モック:

def test_api_call(mocker):
    mocker.patch("my_lib.client.fetch", return_value={"data": []})
    result = my_lib.get_data()
    assert result == []

例外のテスト:

def test_invalid_raises():
    with pytest.raises(ValueError, match="latitude"):
        encode(91.0, 0.0)

詳しいパターンについては、以下を参照してください。

テストの原則

原則意味
独立性テスト間で状態を共有しない
決定性実行するたびに同じ結果になる
高速性単体テストはそれぞれ 100ms 未満
集中性実装ではなく動作をテストする

嘘をつくテスト:誤った成功を避ける

コードに誤りがあってもテストが失敗しないなら、テストスイートに価値はありません。最も損失の大きいバグは、CI が成功している状態でリリースされます。以下のアンチパターンを確認してください。いずれも、「すべてのテストが成功」という状態で不具合を見逃す原因になります。

条件付きアサーションが常に成功する。 セットアップが気付かれずに失敗すると、条件付きのアサーションは実行されず、テストは成功したままになります。

# BAD — if the Sphinx build fails, no index.html, so nothing is asserted.
def test_build_includes_css():
    if index_html.exists():            # build broke? test passes anyway.
        assert "theme.css" in index_html.read_text()

# GOOD — assert the precondition, then the behavior.
def test_build_includes_css():
    assert index_html.exists(), "build produced no index.html"
    assert "theme.css" in index_html.read_text()

許容範囲が広すぎるアサーション。 必ず成功する or や、誤った出力まで受け入れてしまう緩すぎる部分文字列の照合です。

assert result.returncode == 0 or "html_static_path" in result.stderr  # masks real failures
assert "ui" in todo.tags                                              # also matches "build"

存在確認のアサーションでは重複を検出できません。 assert X in output で確認できるのは、X が 少なくとも 1 つ 存在することだけです。2 つ存在するか、コンシューマーがどちらを採用するかは分かりません。これは、フレームワークの基本テンプレートと独自のオーバーライドがそれぞれ 1 つずつ出力し、内容が食い違うような、生成出力(HTML タグ、設定キー、出力されるヘッダーなど)で発生するバグそのものです。出現するすべての箇所を抽出し、リスト全体を比較してください。

# BAD — passes with one correct tag, and equally with two conflicting ones.
assert '<link rel="canonical"' in html

# GOOD — pins both the count and the values.
canonicals = re.findall(r'<link rel="canonical" href="([^"]*)"', html)
assert canonicals == ["https://example.com/guide/"]

2 つの出力元が「同じ」タグを出力しても、書式が異なることがよくあります(片方は自己終了形式で、もう片方はそうでないなど)。そのため、確認したい属性を正規表現の基準にし、タグの閉じ位置より前で一致を止めてください。そうしないと、検出したい重複が正規表現をすり抜け、テスト上は問題がないように見えてしまいます。同じルールは、数えられるものすべてに当てはまります。assert rows ではなく、len(rows) == 2 と各行の値をアサートしてください。

テスト対象そのものをモックする。 _run_command をパッチして argv のトークンだけをアサートすると、実在しないコマンドをテストに固定してしまう可能性があります。上流でサブコマンドやフラグが変更または削除されても、テストは成功したままです。境界(サブプロセスや HTTP 呼び出し)をモックし、渡した引数ではなく、解析された結果をアサートしてください。

テスト対象のパラメーターを無視するフェイク。 list_items が after とページネーションを無視し、1 回の呼び出しですべての固定行を返すフェイククライアントでは、それらのパラメーターが制御するページネーションや差分同期のロジックをテストできず、未検証のままです。テストの要点となるパラメーターをフェイクが適切に処理するようにしてください。 誤ったものをインポートするスモークテスト。 python -c "import server" は、実際の server.py より優先される空の server/ パッケージを解決し、壊れた wheel でも「インポート」に成功したように見えることがあります。インポート名が解決されるだけでなく、実際のシンボルにアクセスできることを検証してください(from server import main; main)。

モックの設定漏れによる、気づかれない実ネットワーク呼び出し。 httpx_mock フィクスチャを使い忘れたテストは、稼働中の API にアクセスします。実行が遅く、不安定で、レート制限を受けるうえ、決定的でない処理を気づかないまま実行します。--disable-socket(pytest-socket)を追加し、モックされていないネットワーク呼び出しが「成功」するのではなく、明確に失敗するようにしてください。

何もしない CI ゲート。 ゲートが実際にテストを実行していることを確認してください。

  • テストファイルがゼロの状態で go test ./... / pytest を実行しても、何もせず成功します。
  • --ignore や pytest.mark.skip で「不安定だから」と除外されたファイルは、実際には 決定的に 失敗することがよくあります。除外しても不安定さではなく、実際の不具合が隠れるだけです。
  • マーカーフィルター(-m "not integration")によって、意味のあるテストがすべて除外されることがあります。成功を信頼する前に、CI とまったく同じマーカー式をローカルで再現してください。

評価対象が空でも「問題なし」と見なしてはいけません。 監査ツール、ポリシーエンジン、検証パイプラインは、有効なルールからスコアを算出することがよくあります。カテゴリーフィルターによって、誤ってすべてのルールが無効になることがあります。スコア計算コードが results == [] を score = 100 に変換すると、サポート対象外のリクエストが気づかれないまま完全合格になります。フィルター適用後のルールセットが空の場合は、設定エラーとして扱うか、すべてのカテゴリーの評価を担う集約ルールを有効にしておいてください。

enabled = [rule for rule in rules if rule.supports(requested_categories)]
if not enabled:
    raise NoApplicableRulesError(requested_categories)
results = evaluate(enabled, subject)

リグレッションテストでは、最終スコア以外も検証する必要があります。サポート対象の各カテゴリー(および代表的な組み合わせ)をリクエストし、少なくとも1つのルールが実行されたことを検証してください。また、何もしない経路が問題なしに見えないよう、失敗することが分かっている対象を使ってください。

result = audit(known_noncompliant_subject, categories=["bias"])
assert result.rules_evaluated > 0
assert result.issues                 # proves filtering did not bypass evaluation
assert result.overall_score < 100

バグを前提に書かれたテスト。 呼び出しを try/except RuinError で囲んでテストを通すと、そのバグを許容できるものとして記録してしまいます。正しい動作を検証し、バグが修正されるまではテストが失敗するようにしてください(CI を赤くせずに追跡するには xfail(strict=True) を使います)。

テストが失敗することを証明する:修正をミューテーションする

すべての回帰テストは、暗黙の主張をしています。このテストならバグを検出できたはずだ、という主張です。 その主張を確かめる唯一の方法は、バグを元に戻してテストが失敗するのを見ることです。 修正の内容をまだよく覚えているうちに一度やってみてください。1分で済みます。これが回帰テストと飾りを分けるものです。

# 1. revert the fix (git stash, or hand-edit the guard back to its broken form)
# 2. run ONLY the new test — it must FAIL, and for the right reason
uv run pytest tests/test_paths.py::test_symlink_escape_rejected -q
# 3. restore the fix — it must pass

複合条件の各部分を個別にミューテーションします。 入力を検証し、解決後の結果も再確認する修正は、各部分を個別に元に戻すまでは冗長に見えます。

def _resolve_baseline(name: str) -> Path:
    if ".." in name or not NAME_RE.fullmatch(name):   # mutation A
        raise ValueError(name)
    path = (BASE_DIR / f"{name}.json").resolve()
    if not path.is_relative_to(BASE_DIR.resolve()):   # mutation B
        raise ValueError(name)
    return path

A を元に戻すと、../../etc/passwd のテストで検出されます。B を元に戻した場合に検出できるのは、承認済みディレクトリ内に配置され、外部を指すシンボリックリンクを使ったテストだけです。多くのテストスイートにはないテストです。片方を元に戻してもテストスイートが成功したままなら、それは冗長なチェックではなくテストの抜けです。それを固定するテストを書いてください。

元のバグと、もっともらしい誤った修正をテストします。 古いコードに戻せば、テストスイートがその実装を検出することは証明できます。しかし、その修正が近接する失敗ケースにも対処できることまでは証明できません。各候補と意図した動作を区別できる、個別のフィクスチャを選んでください。

繰り返し出現するラベルに一意のキーを割り当てる関数では、元のラベルをキーにするロジックに戻すと、同じラベルを2つ使ったテストで検出できます。もっともらしい誤った修正として、出現回数のカウンターを追加しても、生成されたサフィックスをすでに含むリテラルラベルと衝突します。

# Original bug: repeated labels overwrite each other.
assert unique_keys(["Notes", "Notes"]) == ["Notes", "Notes (2)"]

# Wrong fix: a blind counter generates an already-used literal key.
assert unique_keys(["Notes", "Notes (2)", "Notes"]) == [
    "Notes", "Notes (2)", "Notes (3)",
]

2つ目のフィクスチャでは、ベースラベルの出現回数を数えるだけでなく、生成する候補をすべて、すでに割り当てたキーと照合する必要があります。最初のフィクスチャも残してください。それぞれのテストが、異なる回帰を説明しています。元の実装とカウンターだけの実装を個別にミューテーションし、それぞれどのテストが失敗するかを記録してから、正しいコードに戻して両方を再実行してください。現実的なミスを模したミューテーションを選びましょう。無作為に壊しても、有用なテスト網羅性の証拠にはなりません。

選んだミューテーションで、何が「失敗」に見えるかを把握します。 バグを再導入しても、必ずしも明確なアサーション失敗として表面化するとは限りません。

  • リトライや反復の上限を取り除くと、テストスイートは失敗するのではなく停止しなくなります。外部タイムアウトを使ってミューテーションしたテストスイートを実行してください。出力がない状態での timeout 60 uv run pytest -q も、再現結果です。上限を単に到達不能な値(> 10**9)にするのが、誠実なミューテーションです。不等号を1つずらす(> → >=)方法は、はっきり失敗し、日々の再実行にかかるコストも低くなります。
  • 非同期コードから await や try を削除すると、名前の付いたテストが失敗する代わりに、未処理のリジェクションやワーカークラッシュでランナーが停止することがあります。それでもテストは失敗しています。テストが実行されなかったと結論づける前に、出力を確認してください。
  • ミューテーション後の実行がテスト収集中にエラーになった場合、テストはひとつも実行されておらず、テストについて何も分かっていません。
  • コンパイル言語では、ミューテーションによってテストではなくビルドが失敗することがあります。if false { のようにガードを無効化すると、インポートが未使用になる場合があります。その状態で実行するとコンパイルエラーが出て FAIL 行は0件になるため、grep -c FAIL は 0 を返し、ミューテーションが生き残ったように見えます。依存関係への参照は残し(if false && (cond) {)、出力にコンパイラーエラーではなく失敗したテストの名前が表示されることを確認してください。
  • ミューテーションが実際に適用されたことを確認してください。macOS では、BSD の sed -i 's/a/b/' file は次の引数をバックアップ用の拡張子として扱うため、失敗したり何も変更しなかったりする場合があります。その結果、「ミューテーション済み」のテストスイートがミューテーション前のコードを実行して成功します。両方の OS で同じように使える perl -i -pe を使い、結果を確認する前に git diff --stat に変更が表示されていることを確認してください。

手書きのフィクスチャも、現実に対するミューテーションです。 同じ人がパーサーと、それをテストするためのすべてのフィクスチャを作成すると、どちらにも同じ前提が入り込み、本番環境では決して発動しないガードに対してテストスイートが成功します。トークン間のスペースが1つであることを要求する正規表現は、手入力のスペース1つのフィクスチャにはいつまでも一致しますが、実際のドキュメントでは、それらのトークンがインデントされた複数行にわたって表示されます。実際のソースから少なくとも1つのフィクスチャを取得してコミットし、そのフィクスチャをガードのテストで使ってください。

生き残るミューテーションが常にテスト不足とは限りません

分岐を削除してテストスイートを実行し、すべて成功したら、反射的にそのためのテストを書きたくなります。 まず、その分岐が実際に動作を変えるかどうかを確認してください。等価ミュータントとは、削除しても観測可能な影響がないコードのことで、どのテストでもそれを固定化することはできません(また、固定化すべきでもありません)。

def get_count(self, user, day):
    inner = self.counts.get(user)
    if inner is None:          # looks like a guard; is it?
        return 0
    return inner.get(day, 0)   # `self.counts.get(user, {}).get(day, 0)` is identical

よくあるパターンとしては、後続処理でも同じ値が返る早期リターン(読み取り時に空または存在しないマッピングをインデックス参照するとデフォルト値が返る場合など)、より安全なイディオム(str.endswith)で不要になるlen(x) < n orの短絡評価、ある構築経路でしか意味を持たない防御的な初期化があります。

  • 書く前に分類してください。 その行を削除し、テストスイートが成功したら、結果が異なる入力を作れるか試してください。作れない場合は、機能していません。削除するか残すかは自由ですが、それを守っているかのような名前のテストは書かないでください。分岐を削除しても成功する「ユーザーが見つからない場合は 0 を返す」というテストは、実際には得られていないカバレッジを主張しています。
  • 特定の経路でしか機能しない分岐もあります。 遅延初期化されるネストされたコンテナーは、初期化を削除するとパニックを起こすかもしれませんが、それは素の状態で手作りしたオブジェクトの場合に限られます。ローダーやファクトリーが生成したフィクスチャはすでに正規化されているため、その初期化を実行することはありません。失敗する経路と同じ方法でフィクスチャを作成してください。
  • 書き換えによって見えなくなる理由で、短絡評価が機能していることがあります。 生のスライスname[-11:]では、インデックスエラーを避けるために長さチェックが必要ですが、endswithならチェックなしで同じ答えが返ります(関数全体ではなく)ガードだけをミューテーションすると、どちらの種類かがわかります。境界値の入力(サフィックスと完全に一致する文字列、または 1 文字短い文字列)が、それを証明するフィクスチャになります。
  • PR で明記してください。 「分岐 X は動作に影響しない(ミューテーションを適用しても N 件のテストはすべて成功し、結果を区別できる入力はない)」は、失敗ではなく発見事項です。妥当な回帰を表し、かつ名前の付いたテストを失敗させるミューテーションだけを報告してください。

両方向にミューテーションする:ガードは過剰に発火することもあります

条件を追加する修正(不完全なデータに対する警告、検証チェック、主張を抑制するフラグなど)には、もっともらしい誤実装が 1 つではなく 2 つあります。まったく発火しない(元のバグ)か、常に発火する(適用範囲が広すぎる)かです。自然に書く回帰テストでは、前者しかカバーできません。両方向にミューテーションしてください。

# The fix: warn when a sub-fetch failed, so a partial total isn't read as complete.
if failed_sources:
    log.warning("counts UNDERCOUNT: no data from %s", ", ".join(failed_sources))

# Mutation 1 — the original bug. Delete the tracking so the list is always empty.
# Mutation 2 — over-broad. Drop the condition, or write `if not sources:`.

ミューテーション 2 を検出する制御テストは、一見すると無意味に見えます。 完全に成功した実行では警告が出ないことを確認するテストは、修正前のコードでも当然成功するため、何も証明していないように見え、レビューで削除されがちです。警告が無条件に発生するようになったときに失敗する唯一のテストです。これを残し、カバレッジの半分が削除されないよう、PR の本文で両テストの対応関係を明示してください。

ミューテーション失敗するテスト
トラッキングを削除(警告が出ない)test_failed_source_warns
条件を削除(常に警告する)test_healthy_run_emits_no_warning

真偽値判定がガードの適用範囲を広げます。 過剰に発火するミューテーションは、意図的な変更であることはほとんどありません。「この値は存在しない」という意図に対して、if not x:と書いてしまうことです。Noneは測定不能/取得失敗を意味しますが、[]、0、0.0、""は正常な結果であり、真偽値判定を使うと、これらも気付かないうちに同じ扱いにされます。

# BAD — fires on a resource that genuinely has no referrers, or a real count of 0.
if not referrers:
    warn_incomplete(name)

# GOOD — only the sentinel means "we don't know".
if referrers is None:
    warn_incomplete(name)

間違いに気付く手がかりとして、空のコレクションを渡す既存のフィクスチャで、新しい警告が出始めることがあります。ガードを追加したことで無関係なテストが失敗する場合は、フィクスチャを更新する必要があると考えるのではなく、適用範囲が広すぎるミューテーションが明らかになったと捉えてください。

各ミューテーションが失敗させるテストを 1 つだけ特定してください。 修正に複数の部分がある場合は、1 つずつ元に戻して、それぞれどのテストが失敗するか記録してください。「boolの短絡評価だけを削除すると、ちょうど 1 つのテストが失敗する」は検証済みの記述ですが、「テストスイートがこれをカバーしている」だけでは不十分です。削除してもテストスイートが成功する部分は、カバレッジの不足です。

修正前のコードでも通ってしまうエッジケーステスト

修正で異常な入力(空のクエリ、空白テキスト、長さ 0 のシーケンス)に対する明示的なガードを追加する場合、自然なテストである「空の入力は 0 を返す」は、元のバグのあるコードでも通ってしまうことがよくあります。別のレイヤーがすでに同じ答えを返していることがあるためです。たとえば、list.count("") は 0、文字ベースの推定器は空白に対して 0.0 を返し、sum([]) は 0 です。このようなテストは修正前も修正後も成功するため、ガードをカバーしているように見えても、バグについては何も保証しません。

# Passes on the buggy matcher AND the fixed one — the old path did `tokens.count("")`.
def test_empty_keyword_has_zero_density():
    assert density("some text", "") == 0

# Only these fail on the original: they exercise the behavior the fix changed.
def test_phrase_requires_contiguous_tokens():
    assert density("artificial methods improve intelligence", "artificial intelligence") == 0
  • 新しい各テストを修正前のコードに対して実行します(修正を元に戻すか、ベースコミットを一時ディレクトリに展開します)。そして、どのテストが失敗するかを記録します。回帰テストとなるのは失敗するものだけです。それ以外はエッジ動作の説明なので、そのような名前を付けてください。
  • 明示的なガードは、コアの変更とは別にミューテーションテストします。 if not tokens: return 0 だけを削除する変更を検出できるのは、空入力のテストだけです。ただし、コアの変更だけではその入力に対して 0 が返らなくなっている場合に限ります。
  • バグと、もっともらしい誤った修正には、それぞれ異なるフィクスチャが必要です。 最初のトークンとの一致判定を復活させる変更は、フレーズの最初の単語だけを含むテキストで検出できます。「すべての単語がどこかにあればよい」という書き換えを検出できるのは、両方の単語がテキスト内の離れた位置にある場合だけです。1 つのフィクスチャセットで両方をカバーできることは、ほとんどありません。

バグと修正を区別できるフィクスチャ値を選ぶ

データが 2 つの実装を区別できないために、テストがミューテーション後も成功してしまうことがよくあります。アサーションを書く前に、バグのあるバージョンがフィクスチャからどのような結果を出すかを考えてください。同じ数値になるなら、問題はアサーションではなくフィクスチャにあります。アサーションではなく、フィクスチャを変えてください。

順序によって読み取り値が変わる場合は、両端の値が異なるフィクスチャが必要です。 すでにソートされたフレームから最新の値を出力するサマリーは、降順ソートなら .iloc[0] を、昇順ソートなら .iloc[-1] を読み取ります。両者の差は 1 文字だけで、正しいのは一方だけです。1 行だけのフィクスチャや、すべての値が同じフィクスチャでは、両者は一致してしまいます。

# BAD — every row carries the same total, so first and last agree and the
# assertion holds against either reading.
rows = [{"day": "2024-01-01", "cumulative": 5}]

# GOOD — strictly increasing across three days: 2, then 5, then 9. Only the
# correct reading yields 9.
rows = [
    {"day": "2024-01-01", "cumulative": 2},
    {"day": "2024-01-02", "cumulative": 5},
    {"day": "2024-01-03", "cumulative": 9},
]
assert "Final cumulative stars: 9" in captured.messages

グループごとの派生列では、2 通りの読み取り結果がすべての行で異なる必要があります。 グループごとに計算した gap/delta/rank 列は、グローバルにソートしたテーブル全体で計算した同じ列と、多くの行で一致することがよくあります。2 つのグループを交互に並べるだけでは不十分です。値そのものが異なる必要があります。グループ内の間隔と全体の間隔が異なるように時刻を設定した、各グループ 2 項目のフィクスチャが、これを確実に検証できる最小のフィクスチャです。

groupdatesグループごとの間隔グローバルソートでの間隔
A2024-01-01, 2024-01-1110.05.0, 5.0, 10.0
B2024-01-06, 2024-01-2115.0(空欄が 1 つ)

グループごとに空欄が 2 つあり、間隔は 10.0 と 15.0 です。全体では空欄が 1 つあり、一致する値はありません。正確な数値と空欄の数の両方をアサートしてください。

バグのマジックナンバーを再現するだけのフィクスチャでは、何も証明できません。 不具合が 60 秒の余分な固定待機であり、正しいコードパスでもヘッダーがない場合に 60 秒にフォールバックするとき、ヘッダーを省略したフィクスチャでは両方の実装が通ってしまいます。余分な待機が見えるように、5 秒後にリセットされるヘッダーなど、正しい結果が異なる値になる入力を指定してください。

事実ではなく、呼び出し順を記録してください。 何もしないスタブでは、「スリープしたか」しかわかりません。代わりに引数を収集して呼び出し順全体をアサートしてください。これにより、途中に余分な呼び出しが 1 つ挿入されたことを検出できます。

# BAD — passes whether the code sleeps once or twice.
monkeypatch.setattr("mypkg.client.time.sleep", lambda _s: None)

# GOOD — the sequence is the assertion.
sleeps: list[float] = []
monkeypatch.setattr("mypkg.client.time.sleep", sleeps.append)
...
assert sleeps == [reset_wait, 0.1]   # reset wait, then the post-success pause

既存のテストがすでに検証している動作を変更する

出力を変更する前に、テストスイートがその動作について何を主張しているかを確認してください。グリーンの実行結果からは、次の3つの状況が同じように見えます。

  1. 形式だけのアサーション — isinstance(x, float)、>= 0、result 内に "minutes" があること。動作は未指定です。修正前にもテストは通り、修正後にも通り、ほとんどどんな出力でも通っていたはずです。維持すべきカバレッジではなく、カバレッジなしとして扱ってください。
  2. 正しい値が固定されている。 あなたの「修正」が回帰です。中止してください。
  3. バグのある値が固定されている。 誤った出力に対する確信を持った完全一致アサーションにより、その不具合がテスト済みで意図されたもののように見えます。

グリーンであることは意図の証拠ではなく、2と3のどちらに該当するかをテストから判断することもできません。判断できるのは、ドキュメント、docstring、または課題の履歴だけです。この2つのパターンは、同じファイル内に共存します。リストマーカーを意図的に削除するレンダラーには、マーカーのない出力を固定するテストがあり、その2つ隣の関数で見出しテキストが次のブロックに誤って結合される不具合にも、同じように確信を持って値を固定するテストがあります。意図がどこにも記されていないなら、それを記すことも修正の一部です。

古い値を固定するアサーションをすべて grep してください。 課題では明らかな単一ケースのテストが挙げられていても、同じ誤った出力が、変更対象に見えない test_mixed_content 形式のケース内にある複数行の expected 文字列にも埋め込まれていることがよくあります。

# search for a distinctive slice of the OLD output, not for the test's name
rg -l 'HeadingBody text' tests/

マジック定数より、差分を検証するアサーションを優先してください。 可読性スコアラー、トークナイザー、フォーマッターなど、自分で制御できないサードパーティーの計算から数値を得る場合、浮動小数点数の完全一致を使うと、そのライブラリのバージョンにテストが依存します。依存関係がバージョン指定なしで宣言されている場合("textstat"、バージョン指定子なし)、パッチリリースで値が変わり、コードに変更がなくてもテストが失敗します。代わりに、修正によって成立する関係性を検証してください。

# BAD — version-brittle, and it only says "some number came out".
assert readability(MARKDOWN)["flesch"] == 42.80

# GOOD — the property the fix creates: markup must not affect the score.
assert readability(MARKDOWN)["flesch"] == readability(PLAIN_EQUIVALENT)["flesch"]
assert strip_markup(MARKDOWN) == PLAIN_EQUIVALENT      # exact, no library constant
assert len(strip_markup(MARKDOWN).split()) == 27

差分を使う形式なら、依存関係から得られる定数は一切不要で、除去処理が再び不具合を起こした時点でテストは失敗します。

数値を固定する場合は、推測せずに測定してください。 導出される指標は直感に反するものであり、有意な許容誤差は、もっともらしい推測値が最初の CI 実行で失敗するほど狭いものです。計算してから、その結果を貼り付けてください。

uv run python -c "from mypkg import readability; \
  print(readability(open('tests/data/doc.md').read()))"

許容誤差は自信の度合いではなく、バグによる差に合わせて設定してください。不具合のある経路と修正後の経路の差が約29ポイントなら、pytest.approx(42.80, abs=1.0) は、実際の値を検証しつつ依存関係の更新にも耐えられます。

非ゼロの極小値には相対許容誤差が必要です

固定の絶対許容誤差は、完全なアンダーフローを見逃すことがあります。期待値が 1e-200 で誤った値が 0.0 の場合、その差は 1e-6 より小さくなります。pytest.approx のデフォルトの絶対許容誤差でも、そのスケールではゼロを受け入れてしまいます。非ゼロの結果がその大きさを保つ必要がある場合は、絶対誤差の許容を無効にしてください。

@pytest.mark.parametrize("magnitude", [1e-200, 1e200])
def test_power_norm_preserves_single_value(magnitude):
    # For a one-element vector, the norm is |x| at either exponent.
    for exponent in (3, 4):
        result = power_norm([magnitude], exponent)
        assert math.isfinite(result)
        assert result == pytest.approx(magnitude, rel=1e-12, abs=0.0)

これらのフィクスチャは、スケーリングしていないべき乗和で発生する2種類の失敗を区別します。小さな項はアンダーフローでゼロになり、大きな項は最終的なノルムを表現可能な場合でもオーバーフローします。完全なゼロを検証するケースは別に維持してください。期待値がゼロの場合、相対誤差は定義できません。ゼロ付近で意図した誤差範囲を反映している場合に限り、絶対誤差の許容を使ってください。

この回帰テストを検証するには、一時的にスケーリングされていない累積処理に戻し、名前付きのテストが失敗することを確認してください。また、結果をゼロに置き換えると極小値のアサーションが失敗することも確認してください。そこでアサーションが通るなら、許容誤差によって、テストで保護するはずの動作が失われています。

テストのコメントに「変更前」の数値を記すには、古いモジュールを直接読み込んでください。古いツリーをチェックアウトする必要はありません。

git show main:mypkg/parser.py > /tmp/old_parser.py
spec = importlib.util.spec_from_file_location("old_parser", "/tmp/old_parser.py")
old = importlib.util.module_from_spec(spec)
spec.loader.exec_module(old)
print(readability(old.strip_markup(MARKDOWN)))   # what the buggy path used to yield

出力先の検証:テスト本体内でリダイレクトする

ログや CLI の出力が標準出力ではなく標準エラーに出力されたことを確認するテストでは、StringIO を作成して、フィクスチャ内で sys.stderr を差し替えてから、テスト内でハンドラーを作成することがよくあります。これは分かりにくい形で失敗します。pytest のキャプチャマネージャーはセットアップフェーズと呼び出しフェーズの間に独自のキャプチャオブジェクトを再設定するため、テスト中に作成されたハンドラーは pytest のストリームに紐付けられ、StringIO は空のままになります。そして、「Captured stderr call」セクションには、探していたレコードが表示されます。同じテストが -s を指定すると成功するため、不安定なテストのように見えてしまいます。

import contextlib, io, logging

def test_records_go_to_stderr_not_stdout():
    err, out = io.StringIO(), io.StringIO()
    with contextlib.redirect_stderr(err), contextlib.redirect_stdout(out):
        configure_logging({"level": "INFO"})     # builds the handler *inside*
        logging.getLogger("app").info("hello")
    assert "hello" in err.getvalue()
    assert out.getvalue() == ""                  # protocol stream stays clean
  • リダイレクトもハンドラーの作成もテスト本体内で行ってください。-s の有無にかかわらず、同じように動作します。
  • ログが記録された「内容」を検証する場合(出力先ではなく)は、caplog とロガー名を使用してください。
  • テスト対象のコードが logging.basicConfig(force=True) を呼び出す場合は、フィクスチャのティアダウンでルートロガーのハンドラーとレベルを復元してください。そうしないと、後続のすべてのテストにその設定が引き継がれます。

暗黙の状態:自分のマシンでしか通らないテスト

環境変数、モジュールレベルのキャッシュ、作業ディレクトリ、時刻など、テスト自身が設定していない状態を読み取るテストは、コードだけでなくマシンもテストしています。こうしたテストは、ローカルでは通るのに CI では失敗したり、どのテストが先に実行されたかによって通ったり失敗したりします。コードが読み取る入力はすべて固定してください。

把握している変数だけでなく、検索処理に影響する変数はすべて固定してください。 設定ディレクトリの解決は典型的な落とし穴です。HOME を設定すれば十分に見えますが、Linux では XDG 変数は HOME とは独立して設定されているため、検索処理は一時ディレクトリを無視し、すべてのテストが同じ実際の設定ディレクトリを共有します。あるテストの破損したフィクスチャが次のテストに漏れ、実行順によって失敗するテストが決まります。

# BAD — HOME alone. macOS ignores XDG entirely, so this passes locally forever
# and only ever fails on Linux CI.
monkeypatch.setenv("HOME", str(tmp_path))

# GOOD — pin every input to the resolution.
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config"))

変数を読み取るかどうかがプラットフォームの違いによって決まる場合、不完全なテストは「ほぼ正しい」のではありません。CI が実行されるプラットフォームで、まさにテストされていないのです。

インポート時の設定が壊すのはテストではなく、テストの収集です。 モジュールスコープで作成される設定オブジェクト(settings = Settings())はインポート時に評価されるため、変数がないと、フィクスチャが実行される前の収集時に失敗します。そのため、どのフィクスチャでも修正できません。収集時に参照できる場所(pytest の設定、またはルートの conftest.py)で変数を宣言し、CI で使われる env: ブロックを再現してください。さらに良い方法は、テストがオーバーライドを渡して呼び出せるファクトリーで設定を構築し、モジュールのインポート時には何も起きないようにすることです。

モジュールレベルのグローバル変数は、それを設定したテストが終わっても残ります。 _analyzers = None のようなキャッシュがあると、あるテストのモックが後続のすべてのテストに残ります。前後にリセットしてください。最後にリセットすることで、ファイル内の最後のテストの状態が次のファイルに漏れるのを防げます。

@pytest.fixture(autouse=True)
def reset_analyzer_cache():
    app._analyzers = None
    yield
    app._analyzers = None

モックのリセットにも同じ落とし穴があります。reset_mock() は記録された呼び出しを消去しますが、return_value と side_effect は保持するため、あるテストで設定したスタブが次のテストでも応答します。

m.reset_mock()                                       # calls cleared; stub still returns 42
m.reset_mock(return_value=True, side_effect=True)    # actually resets the stub

作業ディレクトリも入力です。 シェルコマンドを実行するコードは、プロセスの CWD を引き継ぎます。「渡したパスに対して実行される」と検証するテストは、テスト自身の CWD がすでに有効なプロジェクトの場所だと無意味です。パスが実際に引き渡されていなくても成功してしまいます。まず別の場所に移動して、フォールバックが実際に失敗する状態にしてください。

def test_runs_against_given_path(tmp_path, monkeypatch, project):
    monkeypatch.chdir(tmp_path)          # empty: a CWD fallback errors here
    assert not run_tool(project_path=str(project)).startswith("Error")

時刻を固定し、そのまま固定してください。 何かを待つためにテスト中に実際の時刻へ戻すと、その後に実行される日付関連のロジックに、気付かないうちに実時間が使われます。すると営業時間の分岐は、実行環境のローカル時刻に応じて変わり、日中はテストスイートが成功し、夜間は失敗します。時刻を固定したテスト内でスリープするのではなく、保留中の処理を決定的に進めてください。

タイムゾーンを固定し、24 時間ではない日をテストしてください。 ローカルの暦日でグループ化するコード、「今日の午前 0 時」を計算するコード、または 24 * 3600 秒を加算するコードは、ほとんどの日には正しくても、DST の切り替わり日(23 時間または 25 時間の日)には誤った結果になります。時刻を固定しても解決しません。同じ瞬間でも実行環境のタイムゾーンによって別の日になるため、UTC の CI 環境ではある理由で成功し、開発者のノート PC では別の理由で成功します。DST のあるタイムゾーンと切り替わり日を選び、日ごとの値を正確に検証してください。

多くのランタイム(Node、C ライブラリの localtime)では、プロセスのタイムゾーンは起動時に一度だけ読み込まれるため、テスト内で環境変数を変更しても手遅れです。タイムゾーンを設定した子プロセスで、テスト対象のコードを実行してください。

def test_daily_totals_on_spring_forward_day():
    env = {**os.environ, "TZ": "America/New_York"}   # 2025-03-09 has 23 hours
    out = subprocess.run(
        ["node", "summarize.js", "fixtures/dst_week.json"],
        env=env, capture_output=True, text=True, check=True,
    ).stdout
    assert json.loads(out)["2025-03-09"] == 7        # exact, not "> 0"

純粋な Python では、関数に明示的な ZoneInfo を渡す方法を優先してください。暗黙のタイムゾーンを使うコードでは、フィクスチャで TZ を設定して time.tzset()(POSIX のみ)を呼び出し、その後に元に戻してください。また、午前 0 時のケースもテストしてください。現地時刻でちょうど 00:00 のイベントは新しい日に属し、未来のイベントが今日の合計に混入してはいけません。

非同期処理の完了順序を証明する

即座に解決されるモックでは、await の欠落が隠れてしまうことがあります。テストがレスポンスを確認する前に、サイドエフェクトがすでに完了しているためです。呼び出し回数で証明できるのは、処理が開始されたことだけであり、完了したことではありません。処理を保留状態にし、成功がまだ保留されていることを確認してから、処理を解放して結果を確認します。

成功が約束する最後の処理をゲートします。再スケジュール時に古いスケジュールをクリアしてから新しいスケジュールを作成する場合、clear だけをゲートしても、create が await されているかどうかは証明できません。同様に、ストレージへの書き込みが完了しても、その後の通知が配信されたことは証明できません。

# Illustrative contract: handle_save() awaits publish() before returning success.
def test_success_waits_for_publish(monkeypatch):
    import asyncio
    from my_library import service

    async def scenario():
        entered = asyncio.Event()
        release = asyncio.Event()

        async def gated_publish(*args, **kwargs):
            entered.set()
            await release.wait()

        monkeypatch.setattr(service, "publish", gated_publish)
        task = asyncio.create_task(service.handle_save())
        try:
            # Bound the wait so a missing call fails instead of hanging.
            await asyncio.wait_for(entered.wait(), timeout=1)
            await asyncio.sleep(0)  # yield to runnable tasks; no wall-clock delay
            assert not task.done(), "success returned before publish completed"
            release.set()
            assert await asyncio.wait_for(task, timeout=1) == {"success": True}
        finally:
            release.set()
            if not task.done():
                task.cancel()
            await asyncio.gather(task, return_exceptions=True)

    asyncio.run(scenario())

ハンドラーは実際のものを使い、外部との境界だけを置き換えます。完了が永続化された状態を意味する場合は、メモリ内ストアを待機前に変更するのではなく、解放後にゲート付きのフェイクで書き込みを適用します。同じ境界で拒否ケースも追加します。完了に失敗した場合は成功を返してはならず、例外は公開されているエラー契約に従って処理する必要があります。最後の処理を await せずにスケジュールするよう変更すると、順序を検証するテストが失敗することを確認します。前の処理の await を取り消すテストでは、別の主張を検証することになります。

チェックリスト

Testing:
- [ ] Tests exist for public API
- [ ] Edge cases covered (empty, boundary, error)
- [ ] No external service dependencies (mock them)
- [ ] Each regression test verified red against the reverted fix
- [ ] A surviving mutation was classified (inert code vs. real gap) before any test was written
- [ ] New guards mutated both ways (never fires / always fires), each mutation
      attributed to one test; control test for the healthy case kept
- [ ] Absence checked with `is None`, not truthiness (`[]`/`0`/`""` are real values)
- [ ] Fixture values make the buggy and correct readings produce different results
- [ ] Existing assertions on changed output classified (shape-only / pins correct
      value / pins the bug), and every file embedding the old value updated
- [ ] Values from third-party computations asserted differentially, or measured
      and pinned with a tolerance sized to the bug's gap — never estimated
- [ ] No ambient state read unpinned (env vars, module globals, CWD, clock, timezone)
- [ ] Local-day logic tested on a DST-transition day under a pinned `TZ`
- [ ] Coverage > 85%
- [ ] Tests run in CI

詳細情報

このスキルは、Will McGinnis による 高品質な Python ライブラリ開発ガイド の コード品質 のセクションに基づいています。詳しくは、以下の記事をご覧ください。

コメント