はじめに

AIに「この関数のテスト書いて」と頼むと、pytestベースのコードが返ってきます。実行するとグリーン。「動いた!」——そのままコミット。fixtureもparametrizeも、なんとなく良さそうだからOK。しばらくはそれで回っていました。

ただ、ふとした瞬間に不安がよぎります。「このテスト、本当に意味あるのか?」 レビューで聞かれても、自分の言葉で説明できない。

そこで今回は、pytestについて学びながら、AI生成pytestコードをレビューする際に「ここを疑え」と感じたポイントをfixture、parametrize、mock、テスト構造の4つに絞って整理しました。

1. AIが書くfixtureの「ここを疑え」

@pytest.fixture はテストの「前提条件」を準備する仕組みで、引数にfixture名を書くだけで、pytestが自動的に注入してくれます。

@pytest.fixture
def sample_user():
    return {"name": "Alice", "email": "alice@example.com"}

def test_user_has_name(sample_user):
    # ↑ 引数名とfixture名が一致 → 自動注入
    assert sample_user["name"] == "Alice"

疑いポイント①:scopeが適切か

fixtureの scope は「いつ作り直すか」の設定です。デフォルトは function(毎テスト)。module でファイルごと、session で全体通して1回になります。

# ⚠ 危険パターン:データ変更するfixtureにsessionスコープ
@pytest.fixture(scope="session")
def test_db():
    db = create_test_database()
    db.insert({"id": 1, "name": "Alice"})  # データ挿入している!
    yield db
    db.drop()

全テストが同じDBインスタンスを共有するので、テストAがデータを書き換えるとテストBに影響してしまいます。自分も以前、これでテストの実行順序によって結果が変わるバグに悩まされました。状態変更するfixtureは function スコープにしておくのが安全だと感じています。

疑いポイント②:yieldで後片付けしているか

# ⚠ リソースリーク
@pytest.fixture
def db_connection():
    conn = create_connection()
    return conn  # 接続を閉じていない

# ✅ yieldで後片付け
@pytest.fixture
def db_connection():
    conn = create_connection()
    yield conn      # テストに渡す
    conn.close()    # テスト後に必ず実行

DB接続やファイルハンドルなど「閉じるべきリソース」が return で返されていたら、yield + クリーンアップに直しておきたいところです。

疑いポイント③:conftest.pyに何でも詰め込んでいないか

conftest.py にfixtureを置くと、同ディレクトリ以下の全テストから使えるようになります。便利なのですが、AIは1ファイルでしか使わないfixtureまで全部ここに詰め込みがちです。自分の中では、複数ファイルで使う → conftest.py、1ファイルだけ → そのファイル内、という基準で判断するようにしています。

2. AIが書くparametrizeの「ここを疑え」

@pytest.mark.parametrize は、同じテストロジックを異なる入力値で繰り返し実行できる仕組みです。テストケースをデータとして外出しにできます。

@pytest.mark.parametrize("input_val, expected", [
    (1, 2),
    (0, 1),
    (-1, 0),
])
def test_increment(input_val, expected):
    assert increment(input_val) == expected

この例では1つのテスト関数から3つの独立したテストが生成されます。pytestの出力には test_increment[1-2]test_increment[0-1] のようにパラメータ値がテストIDとして表示されるので、どのケースが失敗したかもすぐわかります。

疑いポイント①:テストケースが足りているか

# ⚠ AIが生成しがちな不十分なケース
# is_adult: 18歳以上ならTrue を返す関数
@pytest.mark.parametrize("age, expected", [
    (20, True),
    (30, True),
    (15, False),
])
def test_is_adult(age, expected):
    assert is_adult(age) == expected

18歳(境界値)がありません。負の値は? None は? AIは「動くテスト」を書くのは得意ですが、「壊れるべきときに壊れるテスト」はあまり得意ではない印象があります。境界値、異常系、エッジケースは自分の目で確認しておきたいところです。

疑いポイント②:何でもparametrizeにまとめていないか

# ⚠ 意図が異なるケースを1つにまとめすぎ
@pytest.mark.parametrize("input_val, expected, should_raise", [
    ("alice@example.com", True, False),
    ("invalid-email", False, False),
    (None, None, True),
])
def test_validate_email(input_val, expected, should_raise):
    if should_raise:
        with pytest.raises(TypeError):
            validate_email(input_val)
    else:
        assert validate_email(input_val) == expected

テスト関数に if/else が出てきたら、ちょっと立ち止まりたいポイントです。正常系と例外テストは意図が違うので、関数を分けたほうが後から読みやすいと感じています。

3. AIが書くmockの「ここを疑え」

外部APIやDBなど、テスト中に本物を使いたくないものを偽物に差し替える仕組みです。pytestでは monkeypatch fixtureで属性や関数を差し替えるのがシンプルなやり方になります。

def test_fetch_user(monkeypatch):
    # requests.get を偽物に差し替える
    def mock_get(*args, **kwargs):
        # 本物のレスポンスオブジェクトと同じインターフェースを持つ偽物
        class FakeResponse:
            status_code = 200
            def json(self):
                return {"name": "Alice", "age": 30}
        return FakeResponse()

    monkeypatch.setattr("requests.get", mock_get)
    user = fetch_user(user_id=1)
    assert user["name"] == "Alice"

疑いポイント①:テスト対象そのものをモックしていないか

意外と人間もやってしまいがちな、一番怖いパターンだと思っています。

# ⚠ テスト対象の内部メソッドまでモック
def test_process_order(monkeypatch):
    monkeypatch.setattr(OrderService, "_validate", lambda self, x: True)
    monkeypatch.setattr(OrderService, "_calculate_tax", lambda self, x: 100)
    monkeypatch.setattr(OrderService, "_save_to_db", lambda self, x: None)

    result = OrderService().process_order(order_data)
    assert result.status == "completed"

テスト対象の内部を3つもモックしています。こうなると「モックの設定が正しいか」をテストしているだけになってしまいます。モックは外部境界(API、DB、時刻、環境変数)だけに留めて、テスト対象自体はそのまま動かしたいところです。

疑いポイント②:モックの戻り値が現実と乖離していないか

# 本物のAPIは {"user": {"name": ...}} というネスト構造を返すのに…
def mock_get(*args, **kwargs):
    class FakeResponse:
        def json(self):
            return {"name": "Alice"}  # ⚠ ネスト構造が再現されていない
    return FakeResponse()

AIは都合のいいデータ構造を作りがちなので、実際のAPIレスポンスと構造が一致しているかは意識して確認するようにしています。

疑いポイント③:「呼ばれたか」だけで検証が終わっていないか

呼び出し回数や引数まで検証したい場合は、unittest.mock(標準ライブラリ)や pytest-mock(そのラッパー)を使うことが多いです。ただ、ここにも落とし穴があります。

# ⚠ 「呼ばれたこと」しか検証していない
from unittest.mock import patch

def test_send_email():
    with patch("myapp.email.send") as mock_send:
        process_order(order)
        mock_send.assert_called_once()  # 呼ばれた? → はい
        # でも正しい宛先に正しい内容で送られた? → 不明

assert_called_once() は「関数が1回呼ばれた」ことしか保証しません。引数まで検証するなら assert_called_once_with(to="alice@example.com", subject="注文確認") のように書けます。場合によっては、ビジネスロジックの結果を直接assertするほうが堅牢なこともあります。

4. AIが書くテスト構造の「ここを疑え」

ここまでは「部品」の話でした。最後にテスト全体の構造についてです。

既にきれいに書かれたテストがプロジェクトにある場合、AIはその書き方を踏襲してくれるので、構造の問題は起きにくいです。危ないのは完全新規でテストを生成させるときで、参考にできる既存コードがないと、AIは「とりあえず動くもの」を優先して構造が崩れやすくなります。

疑いポイント①:Arrange-Act-Assertの型が崩れている

良いテストにはArrange(準備)→ Act(実行)→ Assert(検証)の型があります。

# ✅ AAAの型
def test_register_creates_active_user():
    # Arrange: 前提条件を準備する
    user_data = {"name": "Alice", "email": "alice@example.com"}
    service = UserService(db=mock_db)

    # Act: テスト対象の操作を1つだけ実行する
    user = service.register(user_data)

    # Assert: 結果を検証する
    assert user.name == "Alice"
    assert user.is_active is True

AIが生成するテストでは、この型が崩れていることがあります。典型的なのがActの複数化です。

# ⚠ 1テストに複数の操作が詰め込まれている
def test_user_workflow():
    service = UserService(db=mock_db)

    user = service.register(user_data)       # Act 1
    assert user.is_active is True

    service.deactivate(user.id)              # Act 2
    assert user.is_active is False

    service.reactivate(user.id)             # Act 3
    assert user.is_active is True

最初のassertで落ちたら、deactivateとreactivateは検証されません。こういうときは1テスト1操作に分けておきたいところです。

疑いポイント②:assert文がない

# ⚠ 実行しただけで何も検証していない
def test_process_order():
    order = create_order(item="Widget", qty=3)
    process_order(order)
    # ...assertがない

意外と見落としがちです。「このテストが通ったとき、何が保証されるんだっけ?」と自分に問いかけるようにしています。

疑いポイント③:テスト名が何も語っていない

# ❌ 曖昧
def test_register(): ...
def test_error(): ...

# ✅ テスト名が仕様書
def test_register_user_with_valid_email_creates_active_user(): ...
def test_register_user_with_duplicate_email_raises_conflict_error(): ...

test_<対象>_<条件>_<期待結果> の形式が理想だと考えています。AIに生成を頼むとき、この命名規則を指定するだけで出力の質がだいぶ変わりました。

まとめ

ここまでの内容を、レビュー時にすぐ使えるチェックリストとしてまとめました。

■ Fixture

  • ☐ 状態変更するfixtureが session / module スコープになっていないか
  • ☐ DB接続やファイルを yield で後片付けしているか
  • ☐ conftest.py に1ファイル専用のfixtureが混ざっていないか

■ Parametrize

  • ☐ 境界値・異常系は含まれているか
  • ☐ テスト関数内に if/else がないか

■ Mock

  • ☐ テスト対象の内部メソッドをモックしていないか
  • ☐ モックの戻り値は実際のレスポンスと一致しているか
  • ☐ 「呼ばれたか」だけでなく引数や結果まで検証しているか

■ テスト構造

  • ☐ Actが1つだけか(複数あれば分割)
  • ☐ assert文が存在するか
  • ☐ テスト名から何をテストしているかわかるか

AIはテストを一瞬で書いてくれます。だからこそ「動いた」で満足せず、このチェックリストで5分だけ振り返る習慣をつけたいと思っています。その5分が、本番障害を防いでくれるはずです。

そしてその5分は、ただの防御策ではありません。「なぜこのscopeなのか」「なぜこのモック範囲なのか」を自分に問いかけることで、pytestへの理解が少しずつ積み上がっていきます。AIを「理解をスキップする道具」ではなく「理解を深める道具」にしていく。自分もまだ道半ばですが、この記事がその一歩になれば幸いです。

内容について、もしお気づきの点がありましたらコメントでお教えいただけますと助かります。

次回は「AIにテストを任せる前に、人間がやるべきこと」として、テスト設計の方針決めやAIへの指示の出し方あたりも整理できたらと思っています。