←記事一覧

テストフレームワークの裏側 — ゼロから理解するテストランナーの設計と実装

pytest や unittest などのテストフレームワークは、テストを見つけてから結果を報告するまでに何をしているのか。小さなテストランナーを Python で実装しながら、ディスカバリー・収集・ライフサイクル・アサーション・モック・レポートの仕組みを説明します。

TestingPythonSoftware EngineeringArchitectureInteractive

pytest と打つだけで、テストファイルが見つかり、テスト関数が順に実行され、失敗した比較の値まで表示されます。

テストフレームワーク(pytest, unittest, Jest, Go の testing パッケージなど)は、テストの発見、整理、実行、結果の報告を自動で行います。この記事では、これらの処理を担う小さなテストランナーを Python で実装し、各処理がどう設計されているかを説明します。

テストフレームワークの全体像

テストフレームワークの処理は、次のパイプラインに分けられます。

次のインタラクティブデモでは、テストランナーが各フェーズで何をしているかをステップごとに追えます。

テストランナーの内部動作

2 つのテストファイルを例に、ディスカバリーからレポートまでの処理を順に表示します

テストディスカバリーglob パターンでテストファイルを探索中...

テストレジストリー

(まだ空)

内部ログ

> rglob("test_*.py")
📁 test_math.py を発見
📁 test_string.py を発見
1 / 10

Phase 1: テストディスカバリー — ファイルを見つける

テストフレームワークは、まずどのファイルがテストなのかを見つけます。

規約ベースのディスカバリー

ほとんどのフレームワークは、ファイル名の規約でテストファイルを識別します。

pytest:
  test_*.py
  *_test.py
 
unittest:
  test*.py(discover のデフォルト)
 
Jest / Vitest:
  **/*.test.{ts,js}
  **/*.spec.{ts,js}
 
Go:
  *_test.go

pytest の規約で実装します。pathlib.Path.rglob() を使うと、ディレクトリーを再帰的にたどり、パターンにマッチするファイルをまとめて取得できます。

# mini_test/discovery.py
from pathlib import Path
from fnmatch import fnmatch
 
DEFAULT_PATTERNS = ["test_*.py", "*_test.py"]
DEFAULT_IGNORE = ["__pycache__", ".venv", "venv", ".git"]
 
 
def discover_test_files(
    root_dir: str = ".",
    patterns: list[str] | None = None,
    ignore: list[str] | None = None,
) -> list[Path]:
    """glob パターンでテストファイルを再帰探索する"""
    patterns = patterns or DEFAULT_PATTERNS
    ignore = ignore or DEFAULT_IGNORE
    root = Path(root_dir)
 
    files: list[Path] = []
    for path in root.rglob("*.py"):
        # 無視すべきディレクトリをスキップ
        if any(part in ignore for part in path.parts):
            continue
        # ファイル名がいずれかのパターンにマッチするか
        if any(fnmatch(path.name, pat) for pat in patterns):
            files.append(path)
 
    return sorted(files)

返り値を sorted() するのは、テストの実行順序を決定的(deterministic)にするためです。ファイルシステムが返す順序は OS やファイルシステムによって異なりますが、ソートすれば環境によらず同じ順序で実行されます。__pycache__ や .venv を無視リストに入れているのは、その中の .py ファイルをテストとして拾わないためです。

なぜ glob なのか?

glob パターンは宣言的です。テストファイルの配置ルールはユーザーが設定ファイルに書き、フレームワークはそのパターンに従ってファイルシステムを走査するだけで済みます。pytest では次の設定がこれに当たります。

# pyproject.toml: testpaths で探索先を、python_* で名前のパターンを指定する
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py", "*_test.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]

Phase 2: テスト収集 — モジュールからテスト関数を集める

テストファイルが見つかったら、その中からテストケースを収集します。

pytest の収集戦略: 関数名の規約

pytest は describe/it のような DSL を使いません。標準的な関数テストは、関数名の規約(test_ で始まる関数)で識別されます。実際の pytest は、Test* クラス、unittest.TestCase、設定による python_files / python_classes / python_functions の変更もサポートします。名前だけで判定できるので、モジュールを読み込んだ後、Python の inspect モジュールで関数を列挙し、名前を調べればテストを集められます。

以下の実装では、importlib.util でテストファイルをモジュールとして動的にインポートします。手順は 3 つです。spec_from_file_location() でファイルパスからモジュール仕様を作り、module_from_spec() でモジュールオブジェクトを生成し、exec_module() でコードを実行します。この方法なら、sys.path を変更せずに任意の .py ファイルをインポートできます。

# mini_test/collector.py
import importlib.util
import inspect
from dataclasses import dataclass, field
from typing import Callable
from pathlib import Path
 
 
@dataclass
class TestCase:
    name: str
    fn: Callable[[], None]
    module_name: str
 
 
@dataclass
class TestSuite:
    name: str
    tests: list[TestCase] = field(default_factory=list)
    setup_module: Callable[[], None] | None = None
    teardown_module: Callable[[], None] | None = None
    setup_function: Callable[[], None] | None = None
    teardown_function: Callable[[], None] | None = None
 
 
def collect_from_file(path: Path) -> TestSuite:
    """テストファイルを読み込み、test_ 関数を収集する"""
    # ファイルをモジュールとして動的にインポート
    spec = importlib.util.spec_from_file_location(path.stem, path)
    if spec is None or spec.loader is None:
        raise ImportError(f"Cannot load {path}")
 
    module = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(module)  # ← モジュールのトップレベルが実行される
 
    suite = TestSuite(name=path.stem)
 
    # ライフサイクルフックの収集
    suite.setup_module = getattr(module, "setup_module", None)
    suite.teardown_module = getattr(module, "teardown_module", None)
    suite.setup_function = getattr(module, "setup_function", None)
    suite.teardown_function = getattr(module, "teardown_function", None)
 
    # test_ で始まる関数を収集
    for name, obj in inspect.getmembers(module, inspect.isfunction):
        if name.startswith("test_"):
            suite.tests.append(
                TestCase(name=name, fn=obj, module_name=path.stem)
            )
 
    return suite

収集と実行の分離

exec_module() を呼ぶと、モジュールのトップレベルのコードが実行されます。テスト関数自体はこの時点では実行されず、inspect.getmembers() で名前をもとに集められるだけです。

# test_math.py — このファイルが exec_module() される
 
import math
 
print("この行は収集フェーズで実行される")  # モジュールレベル
 
def test_addition():
    # ← この関数は収集されるだけ。実行は後
    assert 1 + 1 == 2
 
def test_sqrt():
    # ← これも収集されるだけ
    assert math.sqrt(4) == 2.0
 
def helper_function():
    # ← test_ で始まらないので収集されない
    pass

モジュールをインポートしてテストを集め、テスト関数の実行は後に回します。この分離が pytest 系フレームワークの設計の土台です。分離することで、次のことができます。

  1. 実行前にテストの総数が分かる(プログレスバーの表示に使える)
  2. 実行前にテストを絞り込んだり(-k オプション)、並べ替えたりできる
  3. ライフサイクルフック(setup/teardown)を正しいタイミングで差し込める

ライフサイクルフックの登録

pytest は、setup_module などの決まった名前のモジュールレベル関数を、xunit 形式のライフサイクルフックとして認識します(unittest では setUpModule や TestCase.setUp などを使います)。フックがモジュール単位の setup_module/teardown_module と、テスト関数単位の setup_function/teardown_function に分かれているので、処理ごとに実行回数を選べます。データベース接続のように生成コストが高いリソースはモジュール単位で一度だけ作成・破棄し、テーブルのクリアのようにテスト間の独立性に関わる処理はテストごとに毎回実行します。

# test_database.py — ライフサイクルフックの例
 
db = None
 
def setup_module():
    """モジュール内の全テストの前に1回だけ実行"""
    global db
    db = create_test_database()
 
def teardown_module():
    """モジュール内の全テストの後に1回だけ実行"""
    db.drop()
 
def setup_function():
    """各テスト関数の前に毎回実行"""
    db.clear_tables()
 
def teardown_function():
    """各テスト関数の後に毎回実行"""
    db.rollback()
 
def test_insert():
    db.insert({"key": "value"})
    assert db.get("key") == "value"
 
def test_delete():
    db.insert({"key": "value"})
    db.delete("key")
    assert db.get("key") is None

Phase 3: テスト実行エンジン — 正しい順序で走らせる

収集が終わったら、テストを実行します。テストランナーは、ライフサイクルフックを正しい順序で挟みながら、各テストを他のテストから隔離して実行します。

実行順序

1 つのスイートは、setup_module →(各テストの setup_function → テスト本体 → teardown_function)× n → teardown_module という入れ子の順序で実行されます。

Suite "test_math" の実行フロー:
  ┌─ setup_module()         ← モジュール全体で1回
  │
  │  ┌─ setup_function()    ← テストごとに1回
  │  │  test_addition() 実行
  │  └─ teardown_function()
  │
  │  ┌─ setup_function()
  │  │  test_sqrt() 実行
  │  └─ teardown_function()
  │
  └─ teardown_module()      ← モジュール全体で1回

ランナーの実装

実行時間の計測には time.perf_counter() を使います。time.time() はシステムクロックに基づくため、NTP による同期や手動の時刻変更の影響を受けます。perf_counter() はモノトニッククロック(単調増加時計)なので、経過時間の計測に向いています。

# mini_test/runner.py
import time
from dataclasses import dataclass
 
 
@dataclass
class TestResult:
    name: str
    suite_name: str
    status: str  # "passed" or "failed"
    duration: float
    error: Exception | None = None
 
 
def run_suite(suite: TestSuite) -> list[TestResult]:
    results: list[TestResult] = []
 
    # setup_module — モジュール全体の初期化
    if suite.setup_module:
        suite.setup_module()
 
    for test_case in suite.tests:
        # setup_function — 各テスト前の初期化
        if suite.setup_function:
            suite.setup_function()
 
        start = time.perf_counter()
        try:
            # テスト本体の実行
            test_case.fn()
            results.append(TestResult(
                name=test_case.name,
                suite_name=suite.name,
                status="passed",
                duration=time.perf_counter() - start,
            ))
        except Exception as exc:
            results.append(TestResult(
                name=test_case.name,
                suite_name=suite.name,
                status="failed",
                duration=time.perf_counter() - start,
                error=exc,
            ))
 
        # teardown_function — 各テスト後のクリーンアップ
        if suite.teardown_function:
            suite.teardown_function()
 
    # teardown_module — モジュール全体のクリーンアップ
    if suite.teardown_module:
        suite.teardown_module()
 
    return results

try-except によるテスト隔離

テストランナーの基本的な設計原則は、あるテストの失敗を他のテストに波及させないことです。

# 各テストは独立した try-except で囲まれる
try:
    test_case.fn()
    # → テストが例外を投げなければ PASS
except Exception as exc:
    # → テストが例外を投げたら FAIL(他のテストには影響しない)

この仕組みが成り立つのは、アサーションが例外を投げるだけだからです。assert 1 == 2 も、内部では raise AssertionError を実行します。テストは、何も投げずに終われば PASS、例外が投げられれば FAIL になります。

except Exception は、アサーションの失敗に加えて、TypeError や KeyError などの予期しない通常の例外も捕捉します。テスト中にこうした例外が起きた場合も、ランナーはそのテストを FAIL として記録し、残りのテストを続行します。ただし KeyboardInterrupt や SystemExit は Exception を継承していないので、この except では捕捉されず、実行全体が止まります。pytest や unittest は、Ctrl+C による KeyboardInterrupt では実行を中断しますが、テスト中に送出された SystemExit は、ほかの例外と同じくそのテストの失敗として記録します。


Phase 4: アサーションエンジン — assert の内側

アサーションエンジンは、テストを書く人が直接使う部分です。

Python の assert 文

Python のテストでは、組み込みの assert 文をそのまま使えます。assert は、条件が偽のときに AssertionError を raise するシンタックスシュガーです。

# assert 文の本質
assert 1 + 1 == 2
# ↓ 以下と等価
if not (1 + 1 == 2):
    raise AssertionError

ただし、素の assert は失敗時の情報がほとんどありません。たとえば assert result == "Hello, world!" が失敗しても、素の Python では AssertionError とだけ表示され、result に何が入っていたのかが分かりません。pytest は、この問題を assert のリライティングで解決しています。

pytest の assert リライティング

pytest は、インポート時に assert 文の AST(抽象構文木)を書き換え、失敗時に比較した値などの詳細を表示します。

# ユーザーが書くコード
def test_greeting():
    result = greet("world")
    assert result == "Hello, world!"
 
# pytest が内部で書き換えたコード(概念)
def test_greeting():
    result = greet("world")
    _left = result
    _right = "Hello, world!"
    if not (_left == _right):
        raise AssertionError(
            f"assert {_left!r} == {_right!r}\n"
            f"  where {_left!r} = greet('world')"
        )

この仕組みを簡易的に実装します。比較演算子が 1 つの assert だけを対象に、左辺と右辺を一時変数に保存し、比較が偽なら両辺の値を含むメッセージで AssertionError を投げるコードに置き換えます。

# mini_test/assertion_rewrite.py
import ast
import textwrap
 
 
class AssertRewriter(ast.NodeTransformer):
    """assert 文を詳細なエラーメッセージ付きに変換する"""
 
    def visit_Assert(self, node: ast.Assert) -> ast.AST:
        # assert a == b の場合のみリライト
        if isinstance(node.test, ast.Compare):
            return self._rewrite_compare(node)
        return node
 
    def _rewrite_compare(self, node: ast.Assert) -> list[ast.stmt]:
        compare = node.test
        if not isinstance(compare, ast.Compare) or len(compare.ops) != 1:
            return [node]
 
        # 左辺と右辺を一時変数に保存
        stmts: list[ast.stmt] = []
 
        # __mt_left = <left expr>
        stmts.append(ast.Assign(
            targets=[ast.Name(id="__mt_left", ctx=ast.Store())],
            value=compare.left,
            lineno=node.lineno,
            col_offset=node.col_offset,
        ))
 
        # __mt_right = <right expr>
        stmts.append(ast.Assign(
            targets=[ast.Name(id="__mt_right", ctx=ast.Store())],
            value=compare.comparators[0],
            lineno=node.lineno,
            col_offset=node.col_offset,
        ))
 
        # if not (__mt_left <op> __mt_right): raise AssertionError(...)
        op_symbols = {
            "Eq": "==", "NotEq": "!=", "Lt": "<", "LtE": "<=",
            "Gt": ">", "GtE": ">=", "Is": "is", "IsNot": "is not",
            "In": "in", "NotIn": "not in",
        }
        op_name = type(compare.ops[0]).__name__
        op_symbol = op_symbols.get(op_name, op_name)
        raise_node = ast.Raise(
            exc=ast.Call(
                func=ast.Name(id="AssertionError", ctx=ast.Load()),
                args=[ast.JoinedStr(values=[
                    ast.Constant(value="assert "),
                    ast.FormattedValue(
                        value=ast.Name(id="__mt_left", ctx=ast.Load()),
                        conversion=ord("r"),
                    ),
                    ast.Constant(value=f" {op_symbol} "),
                    ast.FormattedValue(
                        value=ast.Name(id="__mt_right", ctx=ast.Load()),
                        conversion=ord("r"),
                    ),
                ])],
                keywords=[],
            ),
        )
        stmts.append(ast.If(
            test=ast.UnaryOp(
                op=ast.Not(),
                operand=ast.Compare(
                    left=ast.Name(id="__mt_left", ctx=ast.Load()),
                    ops=compare.ops,
                    comparators=[ast.Name(id="__mt_right", ctx=ast.Load())],
                ),
            ),
            body=[raise_node],
            orelse=[],
        ))
 
        # 全ノードに行番号を付与
        for stmt in stmts:
            ast.fix_missing_locations(stmt)
        return stmts

カスタム assert 関数の実装

AST を書き換えずに、カスタム assert 関数で詳しいメッセージを出す方法もあります。

AST リライトでは、ユーザーは素の assert を書くだけで詳しいメッセージを得られますが、実装が複雑でデバッグも難しくなります。カスタム assert 関数では assert_equal(a, b) のような呼び出しが必要になる代わりに、実装が単純で動作を追いやすくなります。

# mini_test/assertions.py
from typing import Any
 
 
class AssertionError(Exception):
    def __init__(self, message: str, actual: Any = None, expected: Any = None):
        super().__init__(message)
        self.actual = actual
        self.expected = expected
 
 
def assert_equal(actual: Any, expected: Any) -> None:
    """== による等値比較"""
    if actual != expected:
        raise AssertionError(
            f"Expected {expected!r}, got {actual!r}",
            actual=actual,
            expected=expected,
        )
 
 
def assert_is(actual: Any, expected: Any) -> None:
    """is による同一性比較"""
    if actual is not expected:
        raise AssertionError(
            f"Expected {expected!r} (id={id(expected)}), "
            f"got {actual!r} (id={id(actual)})",
            actual=actual,
            expected=expected,
        )
 
 
def assert_true(value: Any) -> None:
    if not value:
        raise AssertionError(f"Expected truthy, got {value!r}")
 
 
def assert_raises(exc_type: type, fn: Any, *args: Any, **kwargs: Any) -> None:
    """関数が特定の例外を投げることを検証"""
    try:
        fn(*args, **kwargs)
    except exc_type:
        return  # 期待通りの例外 → PASS
    except Exception as e:
        raise AssertionError(
            f"Expected {exc_type.__name__}, got {type(e).__name__}: {e}"
        )
    raise AssertionError(
        f"Expected {exc_type.__name__} to be raised, but nothing was raised"
    )
 
 
def assert_in(item: Any, container: Any) -> None:
    if item not in container:
        raise AssertionError(
            f"Expected {item!r} to be in {container!r}"
        )
 
 
def assert_length(obj: Any, length: int) -> None:
    actual = len(obj)
    if actual != length:
        raise AssertionError(
            f"Expected length {length}, got {actual}"
        )

== vs is — 何が違うのか

== と is の違いは、テストを書くときによく混乱する点です。値が等しいかを確かめるなら ==、None のようなシングルトンや同一オブジェクトへの参照かを確かめるなら is を使います。

# == → 値の等値比較(__eq__ メソッド)
assert [1, 2] == [1, 2]     # ✅ 値が同じなら OK
assert {"a": 1} == {"a": 1} # ✅ 構造が同じなら OK
 
# is → オブジェクトの同一性比較(id が同じか)
a = [1, 2]
b = [1, 2]
assert a is a  # ✅ 同じオブジェクト
assert a is b  # ❌ 値は同じだが異なるオブジェクト
 
# 注意: 小さい整数は CPython がキャッシュする
assert 1 is 1        # ✅ CPython の実装詳細(-5〜256はキャッシュ)
assert 1000 is 1000  # ⚠️ CPython ではたまたま True になることもある

not を含む assert

not は == などの比較演算子より優先順位が低いので、assert not 1 == 2 は not (1 == 2) を評価します。

assert 1 == 2       → 1 == 2 が False → AssertionError
assert not 1 == 2   → not (1 == 2) が True → PASS
assert not 1 == 1   → not (1 == 1) が False → AssertionError

Phase 5: モック・パッチ — 関数の振る舞いを差し替える

テストフレームワークは、関数の振る舞いを差し替えたり、呼び出しを記録したりする仕組みも提供します。

テストでは、外部 API の呼び出し、データベース接続、ファイルシステム操作などの外部依存が問題になります。外部依存をそのままテストに含めると、テストが遅く、不安定で、環境に依存したものになります。モックで依存先を偽物に差し替えれば、テスト対象のロジックだけを検証できます。

スパイの実装

スパイは元の関数をラップし、呼び出しの引数や戻り値を記録します。以下の Spy は戻り値や実装も差し替えられるので、モックとしても使えます。

# mini_test/spy.py
from typing import Any, Callable
from dataclasses import dataclass, field
 
_UNSET = object()  # 「未設定」を表すセンチネル
 
 
@dataclass
class CallRecord:
    args: tuple
    kwargs: dict
    return_value: Any = None
    exception: Exception | None = None
 
 
class Spy:
    """関数をラップして呼び出しを記録するスパイ"""
 
    def __init__(self, original: Callable | None = None):
        self._original = original
        self._mock_return: Any = _UNSET
        self._mock_impl: Callable | None = None
        self.calls: list[CallRecord] = []
 
    def __call__(self, *args: Any, **kwargs: Any) -> Any:
        record = CallRecord(args=args, kwargs=kwargs)
        self.calls.append(record)
 
        # モックの戻り値が設定されている場合
        if self._mock_return is not _UNSET:
            record.return_value = self._mock_return
            return self._mock_return
 
        # 差し替えた実装がある場合
        impl = self._mock_impl or self._original
        if impl:
            try:
                result = impl(*args, **kwargs)
                record.return_value = result
                return result
            except Exception as exc:
                record.exception = exc
                raise
 
        return None
 
    @property
    def call_count(self) -> int:
        return len(self.calls)
 
    def mock_return_value(self, value: Any) -> "Spy":
        self._mock_return = value
        return self
 
    def mock_implementation(self, fn: Callable) -> "Spy":
        self._mock_impl = fn
        self._mock_return = _UNSET
        return self
 
    def reset(self) -> None:
        self._mock_impl = None
        self._mock_return = _UNSET
        self.calls.clear()

スパイを使ったテスト

def test_spy_records_calls():
    add = Spy(lambda a, b: a + b)
 
    add(1, 2)
    add(3, 4)
 
    assert add.call_count == 2
    assert add.calls[0].args == (1, 2)
    assert add.calls[0].return_value == 3
 
def test_spy_mock_return():
    fetch_user = Spy()
    fetch_user.mock_return_value({"name": "Alice"})
 
    user = fetch_user()
    assert user["name"] == "Alice"

オブジェクトの属性をパッチする

Python では unittest.mock.patch がよく使われますが、その仕組みは属性の差し替えです。以下の Patch クラスは、with 文で使うコンテキストマネージャーとして実装しています。__enter__ で属性を差し替え、__exit__ で元に戻すので、with ブロックを抜ければ必ず復元されます。復元を忘れると、差し替えた属性が後続のテストに残り、テストの独立性が崩れます。

# mini_test/patch.py
from typing import Any
 
 
class Patch:
    """オブジェクトの属性を一時的に差し替えるコンテキストマネージャ"""
 
    def __init__(self, obj: Any, attr: str, replacement: Any = None):
        self.obj = obj
        self.attr = attr
        self.replacement = replacement or Spy()
        self._original: Any = None
 
    def __enter__(self) -> Any:
        # 元の属性を保存して差し替え
        self._original = getattr(self.obj, self.attr)
        setattr(self.obj, self.attr, self.replacement)
        return self.replacement
 
    def __exit__(self, *exc_info: Any) -> None:
        # 元の属性を復元
        setattr(self.obj, self.attr, self._original)
# 使用例
import os
 
def test_patch_environ():
    with Patch(os, "getcwd", Spy(lambda: "/fake/path")) as mock_cwd:
        result = os.getcwd()
        assert result == "/fake/path"
        assert mock_cwd.call_count == 1
 
    # with ブロックを抜けると自動的に復元
    assert os.getcwd() != "/fake/path"

Phase 6: レポーター — 結果を人間に伝える

レポーターは、テストの実行結果を人が読みやすい形式で出力します。

レポーターインターフェース

レポーターを抽象クラス(インターフェース)として定義するのは、ストラテジーパターンの典型的な適用です。同じ実行結果を、コンソール、JSON ファイル、JUnit XML、HTML などの形式で出力できます。ランナーはレポーターの具体的な実装を知らず、インターフェースだけに依存します。

# mini_test/reporter.py
from abc import ABC, abstractmethod
 
 
class Reporter(ABC):
    @abstractmethod
    def on_suite_start(self, suite: TestSuite) -> None: ...
 
    @abstractmethod
    def on_test_result(self, result: TestResult) -> None: ...
 
    @abstractmethod
    def on_suite_end(self, suite: TestSuite, results: list[TestResult]) -> None: ...
 
    @abstractmethod
    def on_run_end(self, all_results: list[TestResult]) -> None: ...

コンソールレポーターの実装

コンソールレポーターは、ANSI エスケープコード(\033[32m が緑、\033[31m が赤、\033[0m がリセット)でテスト結果を色分けします。成功は緑の ✓、失敗は赤の ✗ で表示するので、多数の結果の中からでも失敗をすぐに見つけられます。

# mini_test/console_reporter.py
import time
 
 
class ConsoleReporter(Reporter):
    def __init__(self):
        self._start_time = 0.0
 
    def on_suite_start(self, suite: TestSuite) -> None:
        print(f"\n  {suite.name}")
        self._start_time = time.perf_counter()
 
    def on_test_result(self, result: TestResult) -> None:
        icon = "✓" if result.status == "passed" else "✗"
        color = "\033[32m" if result.status == "passed" else "\033[31m"
        reset = "\033[0m"
        duration = f"{result.duration * 1000:.1f}"
 
        print(f"    {color}{icon}{reset} {result.name} ({duration}ms)")
 
        if result.error:
            print(f"      {result.error}")
 
    def on_suite_end(self, suite: TestSuite, results: list[TestResult]) -> None:
        pass  # スイート単位のサマリは省略
 
    def on_run_end(self, all_results: list[TestResult]) -> None:
        passed = sum(1 for r in all_results if r.status == "passed")
        failed = sum(1 for r in all_results if r.status == "failed")
        total = len(all_results)
        duration = (time.perf_counter() - self._start_time) * 1000
 
        print("\n" + "─" * 40)
        if failed > 0:
            print(f"\033[31m  Tests: {passed} passed, {failed} failed, {total} total\033[0m")
        else:
            print(f"\033[32m  Tests: {passed} passed, {total} total\033[0m")
        print(f"  Time:  {duration:.0f}ms")

レポーターを組み込んだランナー

# mini_test/runner.py(レポーター統合版)
 
def run(suites: list[TestSuite], reporter: Reporter) -> list[TestResult]:
    all_results: list[TestResult] = []
 
    for suite in suites:
        reporter.on_suite_start(suite)
        results = run_suite(suite)
 
        for result in results:
            reporter.on_test_result(result)
 
        reporter.on_suite_end(suite, results)
        all_results.extend(results)
 
    reporter.on_run_end(all_results)
    return all_results

Phase 7: すべてを統合する — CLI エントリーポイント

最後に、すべてのフェーズをつなぐ CLI エントリーポイントを作ります。テストの成否は終了コードで伝えます。sys.exit(1 if failed else 0) で終了すれば、CI サーバー(GitHub Actions, Jenkins, CircleCI など)は終了コードだけでテストの成否を判定できます。これは「終了コード 0 = 成功、0 以外 = 失敗」という Unix の規約で、pytest、Jest、go test もテストが失敗すると 0 以外で終了します。

# mini_test/cli.py
import sys
from mini_test.discovery import discover_test_files
from mini_test.collector import collect_from_file
from mini_test.runner import run
from mini_test.console_reporter import ConsoleReporter
 
 
def main() -> None:
    # Phase 1: ディスカバリ
    files = discover_test_files()
    print(f"Found {len(files)} test file(s)\n")
 
    # Phase 2: 収集
    suites = [collect_from_file(f) for f in files]
 
    # Phase 3–6: 実行 + レポート
    reporter = ConsoleReporter()
    results = run(suites, reporter)
 
    # 終了コード
    failed = any(r.status == "failed" for r in results)
    sys.exit(1 if failed else 0)
 
 
if __name__ == "__main__":
    main()

出力例

$ python -m mini_test
 
Found 2 test file(s)
 
  test_math
    ✓ test_addition (0.3ms)
    ✓ test_division_by_zero (0.1ms)
 
  test_string
    ✓ test_trim (0.2ms)
    ✗ test_upper (0.4ms)
      AssertionError: Expected 'HELLO', got 'Hello'
 
────────────────────────────────────────
  Tests: 3 passed, 1 failed, 4 total
  Time:  12ms

実際のフレームワークが加える機能

ここまでで、テストフレームワークのコア機能を一通り実装しました。実際のフレームワークは、このコアの上に、実行時間を短くする並列化、出力の回帰を検知するスナップショットテスト、テストされていないコードを見つけるカバレッジ計測、ファイルの変更に合わせて再実行するウォッチモードなどを備えています。

テスト並列化

pytest-xdist は execnet でワーカープロセスを生成・管理し、テストをワーカーに分配して並列実行します。次の概念図と実装では、簡単のためテストファイル単位で分配しています。

並列実行のアーキテクチャ:
  メインプロセス(コーディネーター)
    │
    ├── Worker 1 (subprocess): test_math.py
    ├── Worker 2 (subprocess): test_string.py
    ├── Worker 3 (subprocess): test_api.py
    └── Worker 4 (subprocess): test_ui.py
 
  各ワーカーは独立したプロセスで、
  グローバル状態の干渉がない。
  結果はプロセス間通信でメインプロセスに集約される。
# 並列実行の概念的な実装
from multiprocessing import Pool
from pathlib import Path
 
def run_file(path: str) -> list[TestResult]:
    suite = collect_from_file(Path(path))
    return run_suite(suite)
 
def run_in_parallel(files: list[Path], num_workers: int = 4) -> list[TestResult]:
    with Pool(num_workers) as pool:
        nested = pool.map(run_file, [str(f) for f in files])
    return [r for results in nested for r in results]  # flatten

スナップショットテスト

スナップショットテストは、初回の実行で出力をファイルに保存し、以降の実行で保存した内容と比べて、意図しない回帰を見つける仕組みです(pytest では syrupy などのプラグインが提供します)。UI コンポーネントや API レスポンスのように、出力が複雑で期待値を手で書くのが大変な場合に向いています。

# 概念的な実装
import json
from pathlib import Path
from typing import Any
 
def assert_matches_snapshot(actual: Any, snapshot_path: Path) -> None:
    serialized = json.dumps(actual, indent=2, sort_keys=True, default=str)
 
    if snapshot_path.exists():
        stored = snapshot_path.read_text()
        if serialized != stored:
            raise AssertionError(
                f"Snapshot mismatch. Run with --update to update.\n"
                f"  Expected: {stored[:100]}...\n"
                f"  Got:      {serialized[:100]}..."
            )
    else:
        # 初回実行時: スナップショットを保存
        snapshot_path.parent.mkdir(parents=True, exist_ok=True)
        snapshot_path.write_text(serialized)

コードカバレッジ

Python のカバレッジ計測は、sys.settrace によるトレースフック、または sys.monitoring(Python 3.12+)で実現されます。

coverage.py の仕組み:
  1. sys.settrace() でトレースフックを設定
  2. Python インタプリタが各行の実行時にフックを呼ぶ
  3. フックが実行された行番号を set に記録
  4. テスト終了後、実行された行を実行可能な文(statement)と照合してカバレッジを計算
 
Python 3.12+ の sys.monitoring:
  1. sys.monitoring はよりオーバーヘッドの少ない新しい API
  2. LINE イベントを監視して行の実行を記録
  3. coverage.py 7.x がこの新 API に対応

ウォッチモード

ウォッチモードは、ファイルの変更を検知してテストを自動で再実行します。pytest-watch は、watchdog ライブラリーによるファイルシステム監視でこれを実現しています。

# 概念的な実装
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
 
class TestRerunHandler(FileSystemEventHandler):
    def on_modified(self, event):
        if event.src_path.endswith(".py"):
            print(f"\nFile changed: {event.src_path}")
            run_tests()
 
def watch_mode(root_dir: str) -> None:
    print("Watching for changes...")
    observer = Observer()
    observer.schedule(TestRerunHandler(), root_dir, recursive=True)
    observer.start()
    observer.join()

フレームワーク間の設計比較

pytest vs unittest

pytest:
  - ディスカバリ: ファイル名 + 関数名の規約(test_ プレフィックス)
  - アサーション: 組み込み assert のリライティング(AST 変換)
  - フィクスチャ: デコレータ + 引数名による依存性注入
  - プラグイン: pluggy ベースのプラグインシステム
  - 並列化: pytest-xdist(execnet)
 
unittest:
  - ディスカバリ: TestCase サブクラスの test* メソッド
  - アサーション: assertEqual, assertTrue 等のメソッド群
  - フィクスチャ: setUp / tearDown メソッド
  - プラグイン: TestRunner のカスタマイズ
  - 並列化: 標準では未サポート
 
最大の違い:
  pytest は「assert 文をそのまま使える」仕組みを
  AST リライトで実現した。これにより
  unittest の self.assertEqual(a, b) のような
  冗長なメソッド呼び出しが不要になった。

pytest のフィクスチャ — 引数名による依存性注入

pytest のフィクスチャは、テストに必要なデータや環境を準備する関数です。テスト関数の引数名がフィクスチャ名と照合され、対応するフィクスチャが自動的に注入されます。Web フレームワークの DI(依存性注入)コンテナーに似た考え方を、テスト向けに実装したものです。yield を使うフィクスチャでは、yield より前がセットアップ、後がテアダウンになるため、リソースの生成と破棄を 1 か所にまとめて書けます。

# pytest: フィクスチャは依存性注入
import pytest
 
@pytest.fixture
def database():
    """テスト用DBの作成と破棄"""
    db = create_test_db()
    yield db        # ← テストに db を注入
    db.cleanup()    # ← テスト後のクリーンアップ
 
@pytest.fixture
def user(database):
    """database フィクスチャに依存するフィクスチャ"""
    return database.create_user(name="Alice")
 
def test_insert(database):
    # 引数名 "database" がフィクスチャ名と一致 → 自動注入
    database.insert({"key": "value"})
    assert database.get("key") == "value"
 
def test_user_name(user):
    # user フィクスチャが自動注入 → 内部で database も自動解決
    assert user.name == "Alice"
 
# パラメトリックテスト
@pytest.mark.parametrize("input_val,expected", [
    (1, 1),
    (2, 4),
    (3, 9),
])
def test_square(input_val, expected):
    assert input_val ** 2 == expected
pytest のフィクスチャ解決:
  1. テスト関数の引数名を inspect.signature で取得
  2. 引数名とフィクスチャ名のマッチング
  3. 依存グラフの構築(フィクスチャが他のフィクスチャに依存可能)
  4. トポロジカルソートで実行順序を決定
  5. スコープ(function / class / module / session)に応じたキャッシュ

Go の testing パッケージ

Go は、テストの仕組みを標準ライブラリーの testing パッケージと go test コマンドに備えています。

// Go: テストは関数シグネチャの規約で識別される
func TestAdd(t *testing.T) {
    result := Add(1, 1)
    if result != 2 {
        t.Errorf("Add(1, 1) = %d, want 2", result)
    }
}
 
// サブテスト — describe/it に近い階層化
func TestMath(t *testing.T) {
    t.Run("addition", func(t *testing.T) {
        if Add(1, 1) != 2 {
            t.Fatal("1+1 should be 2")
        }
    })
    t.Run("division by zero", func(t *testing.T) {
        defer func() {
            if r := recover(); r == nil {
                t.Fatal("expected panic")
            }
        }()
        Divide(1, 0)
    })
}
Go testing の特徴:
  - テストディスカバリ: `cmd/go` が `Test*` / `Benchmark*` / `Fuzz*` 関数からテスト用バイナリを生成
  - アサーション: 標準ライブラリにはない(if + t.Error が基本)
  - 並列化: t.Parallel() で明示的にオプトイン
  - ベンチマーク: Benchmark* 関数で統合
  - ファジング: Fuzz* 関数で統合(Go 1.18+)
  - 並行コードのテスト: testing/synctest で仮想時間を使う(Go 1.25+)

最後の testing/synctest は、並行コードのテストで time.Sleep を使って待つと、テストが遅く不安定(flaky)になる問題に対処するパッケージです。synctest.Test はテスト関数を隔離された「バブル」の中で実行し、その中では time パッケージが仮想クロックを使います。バブル内の全 goroutine がブロックすると仮想クロックがすぐに進むため、「1 時間後のタイムアウト」を実時間 0 秒で決定的にテストできます。Go 1.24 で GOEXPERIMENT=synctest として導入され、Go 1.25 で正式に提供されました。

Jest / Vitest

Jest と Vitest は、JavaScript / TypeScript エコシステムの主要なテストフレームワークです。

Jest:
  - ディスカバリ: 既定では `**/__tests__/**/*.[jt]s?(x)` と `**/?(*.)+(spec|test).[tj]s?(x)` に相当するパターン
  - 収集: describe/it のコールバック登録パターン
  - アサーション: expect().toBe() の Fluent API
  - モック: jest.mock() による自動モジュールモック
  - 並列化: child_process(fork)がデフォルト
 
Vitest:
  - Jest 互換の API だが、Vite のエコシステムを活用
  - ESM ネイティブ対応
  - pool 設定に応じて worker threads / child processes などで並列化

まとめ

各フェーズの仕組みと、それぞれに対応する設計パターンは次のとおりです。

フェーズ仕組み設計パターン
ディスカバリーglob パターンでファイル探索設定より規約
収集モジュールインポート + inspectリフレクション
ライフサイクルsetup/teardown フックフック/コールバック
実行try-except でテスト隔離防御的プログラミング
アサーションassert が例外を raiseAST リライティング
モック関数ラップ + 呼び出し記録プロキシーパターン
レポートReporter 抽象クラスストラテジーパターン
並列化execnet(プロセス並列)メッセージパッシング

テストを書くときに裏側の仕組みを知っていれば、フックやフィクスチャをどの単位で使うか、失敗がどの段階で起きたかを判断しやすくなります。次にテストが赤くなったときは、assert 文が raise AssertionError し、ランナーの try-except がそれを捕捉している様子を思い浮かべてみてください。