コンテンツにスキップ

テスト Tier 監査ツール (scripts/test_tier_audit.py)

テストポリシー (docs/deep-dives/contributing/testing.ja.md) に基づく 8 つのルールに対してテストファイルをチェックする AST ベースのリンター。ポリシーを「一度読む文書」から「PR ごとに機械的に適用される制約」に変えます。

なぜこのツールが必要か

以前は、テストポリシーへの準拠はコードレビュー中にテスト diff を読んで確認していました。多くの違反はレビュアーが捕捉できていましたが、プロセスは手動で、レビュアーによってムラがあり、PR ループにレイテンシを加えていました。ポリシーを十分に習得していない新規コントリビューターは、レビューコメントが来るまで誰も気づかないまま MagicMock ベースのテストや Tier docstring の欠如を混入させることがありました。

test_tier_audit.py は最も頻繁な 8 種類の違反を数秒で検出可能にします。レビュアーがコードを見る前に、ローカルと CI の両方で実行できます。

セットアップ

追加インストール不要 — スクリプトは標準ライブラリの ast モジュールを使用します:

python scripts/test_tier_audit.py [files or dirs]

検出ルール

リンターは 8 つのルールをチェックします。各ルールには深刻度と、テストポリシーに基づいた根拠があります。

ルール 1 — Tier docstring の欠如 (ERROR)

すべてのテスト関数は、docstring の最初の行に Tier を宣言しなければなりません:

def test_something():
    """Tier 3a: プランニングメッセージに対してルーターが正しいスキルを選択する。"""
    ...

Tier ラベル (Tier 1、Tier 2、Tier 3a、Tier 3b) は docstring 1 行目の先頭に記載する必要があります。docstring のない関数、または Tier ラベルで始まらない docstring はこのエラーをトリガーします。

理由: Tier ラベルがなければ、そのテストが存在すべきかどうか (Tier 4 = 書いてはいけない) や、どのコントラクトを assert しているかがわかりません。

ルール 2 — フォーマットピンニング (Tier 4 ERROR)

len(...) [<>=] N 形式の式は、文字列・リスト・出力の正確な長さをピン留めします。長さのピン留めは Tier 4 違反です。テストされているコントラクトとは無関係な正当な理由で変わり得るアルゴリズムレベルの挙動をエンコードします。

# 違反
assert len(result) == 5
assert len(output.splitlines()) < 100

# 許容
assert len(result) > 0      # 構造的: 空でないことの確認

理由: フォーマットピンニングは、空白の変更・出力の再フォーマット・有効なアルゴリズム改善で失敗する脆弱なテストを生みます。実際のコントラクトは何も違反していないのに。

ルール 3 — プライベート状態の assertion (ERROR)

プライベート属性 (obj._something) への assertion は、クラスの公開コントラクトが公開していない実装の詳細に踏み込みます。

# 違反
assert tracker._daily_tokens == 100
assert mgr._timers["c1"] is not None

理由: プライベート状態は公開 API の一部ではありません。それに assertion することは、挙動が変わっていなくても内部表現が変わるたびにテストが壊れることを意味します。代わりに公開サーフェスまたは snapshot() スタイルの読み取りメソッドを使用してください。

ルール 4 — MagicMock / AsyncMock / patch の使用 (ERROR)

unittest.mock.MagicMock、AsyncMock、patch は禁止です。代わりに実際のインスタンスまたは LLMReplay フェイクを使用してください。

# 違反
from unittest.mock import MagicMock, AsyncMock, patch

llm = MagicMock()
with patch("reyn.router.some_fn") as mock_fn:
    ...

理由: モックは実際の API コントラクトをバイパスします。任意の呼び出しを受け入れるモックは、実際のコラボレーターがその呼び出しを受け入れるかどうかを教えてくれません。モックはシステムの進化に伴って静かに腐敗します。実際のインターフェースが互換性のない形で変更されても通過し続けるためです。

ルール 5 — 通常ディレクトリ内の有限ライフタイムテスト (WARNING)

docstring やコメントに triggered_by、removed_by、scaffold_only などのフレーズを含むテストは、有限の期待寿命があることを示しており、通常のテストディレクトリではなく tests/scaffold/ に置く必要があります。

理由: tests/scaffold/ は、特定のリファクタリング中に特定のリグレッションを捕捉するために存在し、リファクタリングが完了したら削除されるテストのための指定場所です。通常のスイートに混在させると、永続的なテストと一時的なテストの区別が見えにくくなります。

ルール 6 — scaffold 外のスナップショット/ゴールデンテスト (ERROR)

tests/scaffold/ 外でゴールデンファイルを書き込んだり読み込んだりするテスト (パターン: golden、snapshot、.gold、.expected) は、メインスイートにスナップショットテストを置くことを禁止するポリシーに違反します。

理由: メインスイートのスナップショットテストは出力フォーマットを永続的にロックし、メンテナンス負担を生み、出力フォーマットの変更で誤った失敗を引き起こします。有限ライフタイムテストと同じ理由で tests/scaffold/ に属します。

ルール 7 — 実オブジェクトへの偽の属性 (ERROR)

obj.attr = value の形の代入で、attr が本番クラスの宣言していない属性であり、# type: ignore[attr-defined] で抑制されているもの (#4873、#3037 が名付けたパターン——Fake を使う代わりに実インスタンスをその場でモック化する)。この違反は mypy がすでに検出しています——代入に対する [attr-defined] コードはまさに「このクラスにはそのような属性がない」という意味なので、このルールは独自の検出器を必要とせず、その検出を抑制するコメントを禁止するだけです。

代入行のみに範囲を絞っています: すでにフラグの立ったプライベート属性を別の場所で読み取ること (例: host.events.emitted) は範囲が狭い別の問題 (mypy には見えない型の問題であり、存在しない属性の問題ではない) であり対象外です。ルール 1〜6 と異なりファイル全体を走査します——このルールの動機となった実際の違反の多くはモジュールレベルのヘルパー関数 (テストが呼び出すフィクスチャである _noop_handler など) の中にあり、test_* 関数単位の走査では到達できません。

既知のギャップ (開示済み、なかったことにはしていない): 動的な setattr(ns, k, v) はいかなる構文ゲートでも検出できません——このルールはリテラルの obj.attr = value の形のみを捕捉します。

理由: 属性代入の隣にある # type: ignore[attr-defined] は、著者自身が実際の欠陥を意図的に黙らせた痕跡です——本番オブジェクトはこの属性を持っておらず、mypy はすでにそれを知っています。公開 API を使うか、クラスを拡張するか、後付けせずに値を明示的に受け渡してください。

ルール 8 — 同一クラスに公開の代替手段があるプライベート読み取り (ERROR)

obj._x の読み取り (#4864——ルール 3 と異なり assert の中に限りません: assert の 1 行前でプライベート読み取りをローカル変数経由にすると、assert 限定の走査からは見えなくなる実際の違反が 126 件在りました) で、obj が型として証拠付けられ、かつ x を同一クラスの @property としても公開しているクラスのインスタンスであるとき。インデックスは実行ごとに 1 回構築され、クラス名でキー付けされます (グローバルな名前一致ではありません)——同じプライベート属性名が複数の無関係なクラスに代入されていても、実際に @property で裏付けているそのクラスでのみこのルールが発火します。

obj は型を推論できるベア名 (パラメータの型注釈、ローカルのファクトリ/フィクスチャ呼び出し、直接のコンストラクタ呼び出しが証拠) か、公開プロパティの連鎖読み取り (w.f._router_host、各リンクが独立に証拠付けられます——#4906、#4911 で解消。#4905 の PR 本文自身が開示し、修正前に architect が実物で再現した偽陰性) のいずれかです。クラス自身のメソッド内での self._x は対象外 (それは正当なアクセスです)、プライベートへの書き込み (obj._x = value) はルール 7 の領域であり、このルールの対象ではありません。

同じ形で解消したもう一つの開示済み偽陰性: タプルアンパックのターゲット (a, b = _make_pair()) は当初 a に型の証拠を一切持たず、a._router_host のような読み取りを見逃していました——位置引数対応のタプル戻り値型インデックスにより、同じ #4911 (#4906) の修正で解消しています。両方の修正とも厳密に証拠のみに基づき、クラス単位に留まっています (#4864 自身の設計が定めたゼロ偽陽性の基準)。どちらの形でも、途中のリンクが解決できなければ結果は「発見なし」のままで、推測はしません。

既知のギャップ (開示済み、なかったことにはしていない): 同じベア名を持つ 2 つのクラスが異なるファイルに存在すると、クラスインデックスで衝突します (最後に走査した方が勝つ)——corpus 全体で未計測、ルール 7 自身の動的 setattr の注意点と同じ族のギャップです。ルール 8 が着地した時点で見つかり修正された 109 件の実サイト (41 ファイルにまたがる) は、その時点での床であって網羅の主張ではありません——今クリーンに通ることは、この形の違反がどこにも存在しないことを意味せず、現時点で判明している全ての形がカバーされているというだけです。

理由: この発見は単なる検出でなく修理義務です——提案される修正は「公開プロパティを使え」ですが、メッセージは同時に、ゲートを黙らせるためだけに同名の @property を作らないよう警告します。テストが実際に求めているものにそれが答えているかを確認せずに作ってはいけません (#4866: まさにこの形が一度「カプセル化の破れを閉じる代わりに批准してしまった」ことがあります)。

フラグ

フラグ 説明
files/dirs 監査するファイルまたはディレクトリパス (位置引数)
--strict 警告をエラーとして扱う。任意の発見で exit 1
--check RULE 指定したルールのみ実行 (繰り返し可。例: --check rule1 --check rule4)
--quiet 発見ごとの詳細を抑制してサマリーのみ出力
--json 発見を JSON で出力 (1 発見 = 1 オブジェクト、改行区切り)

出力例

デフォルト出力

tests/test_router.py:42: [ERROR rule1] Missing Tier docstring: test_router_picks_skill
tests/test_router.py:87: [ERROR rule4] MagicMock usage: MagicMock
tests/test_util.py:12: [ERROR rule2] Format pinning: len(result) == 5

3 errors, 0 warnings

エラーがある場合は exit code 1、クリーンな監査の場合は exit code 0。

対象が 1 件も解決しなかった場合も exit code 1 です (#4577) — path のタイプミス、 他の PR が移動させたファイル、空のシェル変数などが該当します。これは「エラーがある」 でも「クリーンな監査」でもなく、何も監査していない状態であり、合格と同じ色を返さずに そう伝えます。解決された対象はメッセージと併せて出力されます — タイプミスは本来意図した path の隣に並べた方が見つけやすいためです。

--quiet 出力

3 errors, 0 warnings in 2 files

--json 出力

{"file": "tests/test_router.py", "line": 42, "severity": "ERROR", "rule": "rule1", "message": "Missing Tier docstring: test_router_picks_skill"}
{"file": "tests/test_router.py", "line": 87, "severity": "ERROR", "rule": "rule4", "message": "MagicMock usage: MagicMock"}
{"file": "tests/test_util.py", "line": 12, "severity": "ERROR", "rule": "rule2", "message": "Format pinning: len(result) == 5"}

ワークフローとの統合

pre-commit チェックとして

コミット前に変更したテストファイルに対して監査ツールを実行します:

python scripts/test_tier_audit.py tests/

またはコミットしようとしているファイルのみに対して:

git diff --cached --name-only | grep '^tests/.*\.py$' | \
  xargs python scripts/test_tier_audit.py

PR レビューで

PR が新しいテストファイルを追加した場合、レビューの一部としてそのファイルに対して監査ツールを実行します:

python scripts/test_tier_audit.py tests/test_new_feature.py

テストスイート内の既存違反の発見

スイート全体に対して --quiet で実行して件数を取得します:

python scripts/test_tier_audit.py tests/ --quiet

--check rule4 を使って特定のルールに絞り込みます (例: コードベース全体の MagicMock 使用箇所を探す):

python scripts/test_tier_audit.py tests/ --check rule4

ゼロトレランス CI での --strict

python scripts/test_tier_audit.py tests/ --strict

警告 (ルール 5) を含む任意の発見で exit 1 します。スイート全体がクリーンである必要がある CI パイプラインに適しています。

制限事項

監査ツールはヒューリスティックな指標であり、形式的な検証器ではありません:

  • 誤検知は存在し、許容されます。 ルール 2 と 3 の正規表現パターンは、珍しいパターンの有効なコードをフラグする可能性があります (例: 列挙値の数を実際に気にするスキーマ検証テストの len(enum_values) == 3)。各発見を違反として扱う前に検査してください。
  • AST 解析のみ。 ツールはテストを実行せず、インポートを解決しません。間接インポートや動的構築で導入されたモックは検出できません。
  • ファイル横断解析なし。 内部で MagicMock を使用するヘルパーに委譲するテストは、そのヘルパーファイルも監査対象でない限りフラグされません。

関連リソース