pytest parametrizeの使い方|ids・pytest.param・indirectでテストデータを整理する方法

pytest parametrizeは、同じテストを異なるデータで繰り返し実行するための仕組みです。基本の書き方はすぐ覚えられる一方で、「失敗したテストの名前が読みにくい」「特定のケースだけスキップしたい」「fixtureにデータを渡したい」といった場面で手が止まりがちです。この記事では、ids、pytest.param、indirect、複数の重ね掛け、Playwrightでの使い方、外部データの読み込みを、実務で起きやすい失敗例と合わせて整理します。

最大のポイントは「parametrizeは1ケースが1テストになる。だから、名前と管理のしやすさが保守性を左右する」という点です。データを並べるだけで終わらせず、idsとpytest.paramで1件ずつ扱えるようにしておくと、失敗時の調査が楽になります。

📌 この記事はこんな方におすすめ

  • @pytest.mark.parametrizeの基本は使えるが、idsやpytest.paramまでは使いこなせていない方
  • テスト内でforループを回して、複数のデータを検証している方
  • ログインのロールや画面サイズなど、組み合わせテストを整理したいPlaywright(Python)ユーザー
  • テストデータをCSVやJSONに外出ししたいが、読み込み方に迷っている方

✅ この記事を読むと得られること

  • forループとparametrizeの違いと、使い分けの考え方
  • idsで失敗時のテスト名を読みやすくする方法
  • pytest.paramで、ケースごとにxfailやskipを付ける方法
  • indirect=Trueでfixtureにデータを渡す方法

👤 この記事を書いた人

QAエンジニア・テスト自動化エンジニアとして15年以上の実務経験を持つ Yoshi が執筆。データ駆動テストの設計や、pytestとPlaywrightを組み合わせたテスト基盤の整備を実務で担当してきました。

📌 結論

  • pytest parametrizeは、1ケースが1テストとして実行・報告されるため、forループより失敗の切り分けがしやすい
  • idsとpytest.paramでケースに名前と属性を付けると、失敗時の調査やスキップ管理が楽になる
  • 組み合わせは掛け算で増える。実行時間とメンテナンスの負担を見ながら、必要な範囲に絞る

parametrizeを使い始めると、次のような場面に遭遇しやすくなります。

  • 失敗したテストの名前がtest_login[\u30e1\u30fc...]のようになり、どのケースか分からない
  • 「このケースだけ既知の不具合なので、一時的に外したい」が、全体を止めずにできない
  • 乱数を使ったデータで、pytest-xdistの並列実行が収集エラーになる
  • 組み合わせを足していくうちに、テスト数が100件を超えて実行が遅くなる

これらは、parametrizeの周辺機能を押さえると整理しやすくなります。

pytest parametrizeとは

@pytest.mark.parametrizeは、1つのテスト関数に複数の入力データを渡し、データの数だけテストを生成する仕組みです。テストのロジックは1つのまま、入力と期待値だけを変えて検証できます。

import pytest
from playwright.sync_api import Page, expect

@pytest.mark.parametrize(
    "email, password, message",
    [
        ("", "Passw0rd!", "メールアドレスを入力してください"),
        ("user@example.com", "", "パスワードを入力してください"),
        ("invalid", "Passw0rd!", "メールアドレスの形式が正しくありません"),
    ],
)
def test_login_validation(page: Page, email: str, password: str, message: str) -> None:
    page.goto("https://example.com/login")  # 実際のURLに置き換える
    page.get_by_label("メールアドレス").fill(email)
    page.get_by_label("パスワード").fill(password)
    page.get_by_role("button", name="ログイン").click()

    expect(page.get_by_text(message)).to_be_visible()

forループとparametrizeの違い

方法メリットデメリット
テスト内のforループセットアップが1回で済み、実行が軽いループ内でassertが失敗すると、通常はその時点でテスト関数が終了し、残りのケースは検証されない。どの入力で落ちたかも見えにくい
parametrizeケースごとに独立したテスト結果が記録され、失敗した入力を切り分けやすい。個別に実行やスキップができるケースごとにfixtureが実行されるため、fixtureのスコープや構成によっては、ページ生成などのコストが増える

選ぶときの目安:ケースごとに結果を確認したい、特定のケースだけ実行やスキップをしたい場合はparametrizeが向いています。セットアップが重く、1回の流れの中で複数の値をまとめて確認したいだけなら、ループのほうが軽く済みます。

💡 補足:実行済みのテスト名は、pytest -vで確認できます。実行せずに一覧だけを見たい場合は、pytest --collect-only -qが便利です。この記事のコードはpytest 7.x以降を想定しています。

① idsでテスト名を読みやすくする

parametrizeで生成されたテストには、パラメータの値からテストIDが自動生成されます。複数の値を組み合わせる場合やオブジェクトを渡す場合は、意図した名前にならないこともあるため、idsで明示すると管理しやすくなります。日本語などASCII以外の値は、エスケープされた表示になることがあり、読みにくくなる場合があります。

@pytest.mark.parametrize(
    "email, password, message",
    [
        ("", "Passw0rd!", "メールアドレスを入力してください"),
        ("user@example.com", "", "パスワードを入力してください"),
        ("invalid", "Passw0rd!", "メールアドレスの形式が正しくありません"),
    ],
    ids=["email_empty", "password_empty", "email_invalid_format"],
)
def test_login_validation(page: Page, email: str, password: str, message: str) -> None:
    ...
# ids を付けると、テスト名は次のようになる
tests/test_login.py::test_login_validation[email_empty]
tests/test_login.py::test_login_validation[password_empty]
tests/test_login.py::test_login_validation[email_invalid_format]

# 特定のケースだけを実行できる
pytest "tests/test_login.py::test_login_validation[email_empty]"
pytest -k "email_invalid_format"
✅ ポイント:IDは、ASCIIの短い英数字で、ケースの意図が分かる名前にしておくと扱いやすくなります。CIのログやコマンド(-kでの絞り込みなど)で、そのまま使えるためです。

データクラスでケースをまとめる

from dataclasses import dataclass

@dataclass(frozen=True)
class LoginCase:
    case_id: str
    email: str
    password: str
    message: str

CASES = [
    LoginCase("email_empty", "", "Passw0rd!", "メールアドレスを入力してください"),
    LoginCase("password_empty", "user@example.com", "", "パスワードを入力してください"),
]

@pytest.mark.parametrize("case", CASES, ids=lambda c: c.case_id)
def test_login_validation(page: Page, case: LoginCase) -> None:
    page.goto("https://example.com/login")  # 実際のURLに置き換える
    page.get_by_label("メールアドレス").fill(case.email)
    page.get_by_label("パスワード").fill(case.password)
    page.get_by_role("button", name="ログイン").click()

    expect(page.get_by_text(case.message)).to_be_visible()
💡 実務Tip:引数が5つ、6つと増えてきたら、データクラスにまとめると読みやすくなります。frozen=Trueにしておくと、属性の再代入による書き換えは防げます。ただし、内部にリストや辞書を持つ場合、その中身の変更までは防げません。

② pytest.paramでケースごとに属性を付ける

import pytest

@pytest.mark.parametrize(
    "email, password, message",
    [
        pytest.param("", "Passw0rd!", "メールアドレスを入力してください", id="email_empty"),
        pytest.param("user@example.com", "", "パスワードを入力してください", id="password_empty"),
        pytest.param(
            "a" * 300 + "@example.com",
            "Passw0rd!",
            "メールアドレスが長すぎます",
            id="email_too_long",
            marks=pytest.mark.xfail(reason="既知の不具合(チケット管理番号を記載)", strict=True),
        ),
    ],
)
def test_login_validation(page: Page, email: str, password: str, message: str) -> None:
    ...
マーク使いどころ
xfail既知の不具合で失敗するケース。修正されて通るようになったら気づける
skip実行しないケース。理由(reason)を必ず書く
skipifOSやブラウザなど、条件によって実行を切り替えるケース
💡 補足:strict=Trueを付けると、想定外にテストが成功した場合(XPASS)を失敗として扱えます。不具合が修正されたのにマークが残り続ける、という見落としを防げます。チームの運用方針に応じて採用を検討してください。
⚠️ 注意:skipやxfailを付けたまま放置すると、「見えないテスト」が増えていきます。reasonに不具合の管理番号を書くだけでなく、修正の期限や、マークを外す条件(例:「修正リリース後に外す」)も決めておき、定期的に見直す運用をおすすめします。

③ parametrizeを重ねて組み合わせを作る

# 2つ重ねると、全組み合わせ(直積)がテストになる
@pytest.mark.parametrize("width, height", [(375, 667), (1280, 720)], ids=["mobile", "desktop"])
@pytest.mark.parametrize("language", ["ja", "en", "ko"])
def test_top_page(page: Page, width: int, height: int, language: str) -> None:
    page.set_viewport_size({"width": width, "height": height})
    page.goto(f"https://example.com/{language}/")
    ...
# → 2 × 3 = 6 件のテストが生成される
🔍 実務あるある:重ねるのは手軽ですが、件数は掛け算で増えます。「ブラウザ3種 × ロール4種 × データ6件」で72件になり、Playwrightではケースごとにページを生成するぶん、実行時間にも響きます。全組み合わせが本当に必要かを、先に考えておきましょう。

④ indirect=Trueでfixtureにデータを渡す

通常のparametrizeは、テスト関数の引数に値をそのまま渡します。indirect=Trueを指定すると、同名のfixtureに値が渡され、fixtureの中で加工してからテストに渡せるようになります。ロールごとにログイン状態を作る場面などで便利です。

⚠️ 前提:この例を実行するには、事前に auth/admin.json と auth/viewer.json を作成し、各ロールでログインした状態を保存しておく必要があります(作り方はstorage_stateの記事を参照)。また、この例ではpagefixtureを使わず、fixture内で独自にコンテキストを作成しています。
# conftest.py
from collections.abc import Iterator

import pytest
from playwright.sync_api import Browser, Page

@pytest.fixture
def role_page(request: pytest.FixtureRequest, browser: Browser) -> Iterator[Page]:
    role = request.param  # parametrize の値がここに渡される
    context = browser.new_context(storage_state=f"auth/{role}.json")
    page = context.new_page()
    yield page
    context.close()

# tests/test_admin_menu.py
import pytest
from playwright.sync_api import Page, expect

@pytest.mark.parametrize(
    "role_page, expected_visible",
    [("admin", True), ("viewer", False)],
    indirect=["role_page"],
    ids=["admin_can_see", "viewer_cannot_see"],
)
def test_admin_menu(role_page: Page, expected_visible: bool) -> None:
    role_page.goto("https://example.com/dashboard")
    menu = role_page.get_by_role("link", name="ユーザー管理")
    if expected_visible:
        expect(menu).to_be_visible()
    else:
        expect(menu).to_have_count(0)
💡 補足:indirect=["role_page"]のように名前を指定すると、そのパラメータだけがfixture経由になり、expected_visibleはそのままテストに渡されます。認証状態ファイルにはCookieなどの認証情報が含まれるため、Git管理から除外し、共有範囲にも注意してください。

⑤ JSONファイルからテストデータを読み込む

データファイルは、次のような形式を想定しています。id・email・password・messageのキーが1つでも欠けると、テスト実行前の収集段階でエラーになります。

# tests/data/login_cases.json
[
  {
    "id": "email_empty",
    "email": "",
    "password": "Passw0rd!",
    "message": "メールアドレスを入力してください"
  },
  {
    "id": "password_empty",
    "email": "user@example.com",
    "password": "",
    "message": "パスワードを入力してください"
  }
]
import json
from pathlib import Path
from typing import Any

import pytest
from playwright.sync_api import Page, expect

DATA_FILE = Path(__file__).parent / "data" / "login_cases.json"

def load_cases() -> list[Any]:
    rows = json.loads(DATA_FILE.read_text(encoding="utf-8"))
    return [
        pytest.param(row["email"], row["password"], row["message"], id=row["id"])
        for row in rows
    ]

@pytest.mark.parametrize("email, password, message", load_cases())
def test_login_validation(page: Page, email: str, password: str, message: str) -> None:
    page.goto("https://example.com/login")  # 実際のURLに置き換える
    page.get_by_label("メールアドレス").fill(email)
    page.get_by_label("パスワード").fill(password)
    page.get_by_role("button", name="ログイン").click()

    expect(page.get_by_text(message)).to_be_visible()
💡 補足:pagefixtureを使うには、pytest-playwrightがインストールされている必要があります。
データの置き場所メリットデメリット
コード内に直書きデータとテストが同じ場所にあり、追いやすい件数が増えるとコードが長くなる
JSONなどの外部ファイル非エンジニアもデータを編集・追加しやすい読み込み時のエラーがテスト収集の段階で出る。型がなく、列の追加漏れに気づきにくい
⚠️ 注意:データファイルには、本物のパスワードやAPIキーを書かないでください。認証情報が必要な場合は、.envなどの環境変数から読み込む形にします。

実務でよくある失敗パターン

① 日本語や長い値がそのままIDになり、テスト名が読めない

日本語や長い値がIDになると、表示が読みにくくなったり、-kで指定しにくくなったりします。CIのログやコマンドで扱いやすいよう、ASCIIの短いidsを付けておきます。

② 乱数や現在時刻をパラメータに使い、xdistで収集エラーになる

pytest-xdistでは、各ワーカーがテストを収集し、その内容が一致している必要があります。randomやuuid、現在時刻でデータを作ると、ワーカーごとに結果が変わり、収集の不一致エラーになることがあります。値は固定するか、シードを固定します。

③ リストや辞書をパラメータに入れて、テスト間で状態が漏れる

pytestはパラメータ値を自動コピーしません。リストや辞書をテスト内で変更すると、同じオブジェクトを参照する別のケースに影響することがあります。テストデータは変更しない設計にするか、ケースごとに新しいオブジェクトを生成しましょう。なお、frozen=Trueのデータクラスやタプルでも、内部にあるリストや辞書の変更までは防げません。

④ 組み合わせを足し続けて、実行時間が膨らむ

重ねるたびに件数が掛け算で増えます。重要な組み合わせは毎回、残りは夜間のスケジュール実行に分けるなど、実行の頻度を分ける方法もあります。

⑤ 意図の違うケースを1つのparametrizeに詰め込み、テスト内がif分岐だらけになる

ケースごとに検証の中身が違うなら、テスト関数を分けるほうが読みやすくなります。parametrizeは、同じ手順で、データだけが違う場面に向いています。

FAQ

Q. fixtureのparams=とparametrizeは、どう違いますか?

fixtureのparams=は、そのfixtureを使うすべてのテストに適用されます。parametrizeは、特定のテスト関数にだけ適用されます。「このデータは、このテストだけで使う」ならparametrize、「多くのテストで同じ組み合わせを使い回す」ならfixtureのparams=が向いています。

Q. 一部のケースだけを実行するには?

idsで付けた名前を使い、pytest -k "email_empty"で絞り込めます。ノードIDをそのまま指定して、pytest "tests/test_login.py::test_login_validation[email_empty]"と実行する方法もあります。

Q. 失敗したケースだけを再実行できますか?

pytest --lf(--last-failed)で、前回失敗したテストだけを再実行できます。parametrizeのケースも、1件ずつ対象になります。

Q. 引数が多くなって読みにくいときは?

データクラスにまとめて、1つの引数(例:case)として渡す方法があります。ids=lambda c: c.case_idのようにIDも付けやすくなります。

Q. Seleniumのテストでも同じように使えますか?

はい。parametrizeはpytestの機能なので、SeleniumでもPlaywrightでも使えます。違いは、ケースごとのブラウザの準備にかかるコストです。ブラウザやWebDriverの起動・初期化がケースごとに必要な構成では、実行時間が増えやすいため、組み合わせの数に注意します。

📋 まとめ

  • pytest parametrizeは、1ケースが1テストになり、forループより失敗の切り分けがしやすい
  • idsはASCIIの短い名前にして、失敗時のログと-kでの絞り込みに使えるようにする
  • pytest.paramのmarksで、ケース単位にxfail・skipを付ける。理由は必ず書く
  • indirectを使うと、fixtureを通してデータを加工できる
  • 組み合わせは掛け算で増えるため、必要な範囲に絞る
  • 乱数・可変オブジェクトは、xdistや状態漏れの原因になりやすい

まずは、いまテスト内でforループを回している箇所を1つ選んで、ids付きのparametrizeに置き換えてみてください。失敗したときに、どのケースか一目で分かるようになります。

タイトルとURLをコピーしました