Playwright + pytest テスト構成ベストプラクティス|フォルダ設計・fixture・mark運用を実務レベルで解説

テスト自動化

Playwrightとpytestをただつなぐだけでなく、フォルダ構成・fixture設計・mark運用を最初から正しく設計することで、長期的に保守できるテスト自動化基盤を構築できます。

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

  • PlaywrightとpytestでE2Eテストを書き始めたが、構成が散らかってきた方
  • conftest.py・fixture・markを正しく使いこなしたいQAエンジニア
  • チームで運用できるテスト構成の「型」を身につけたい方
  • 既存のテストコードをリファクタリングしたい方

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

  • 実務で使えるフォルダ構成・conftest.py・pytest.iniの完成形がわかる
  • fixture階層設計とscopeの使い分けを実例で理解できる
  • smoke・regression・e2eのmark運用でテストを賢く管理できる

👤 この記事を書いた人

QAエンジニアとして実務でSelenium・Playwright・pytestを活用したテスト自動化に従事。実際のプロジェクトで試行錯誤してたどり着いた構成をベースに、現場で本当に使える設計パターンだけを解説します。実装コードはGitHubで公開中:github.com/YOSHITSUGU728/automated-testing-portfolio

📌 結論:ベストプラクティスの3本柱

  • フォルダ構成を型化する:pages/・tests/・utils/ の3層に分けてコードの役割を明確にする
  • fixture を階層設計する:session → module → function のスコープを目的に合わせて使い分ける
  • mark で実行を制御する:smoke・regression・e2e のグループ化でCI/CDと賢く連携する

PlaywrightとPythonを組み合わせれば、簡単にE2Eテストを書くことができます。しかし、テストが増えてくると次のような問題に直面することがよくあります。

  • fixtureが増えてどこに書けばいいかわからない
  • セレクタがテストコードのあちこちに散らばる
  • CIでどのテストを実行するか管理できない

この記事では、こうした問題を防ぐためのPlaywright × pytestのベストプラクティス構成を、フォルダ設計からfixture・conftest.py・pytest.iniまで一気通貫で解説します。

① 推奨フォルダ構成

まずは全体のフォルダ構成を確認します。小規模プロジェクト向けのシンプル版と、中〜大規模向けの拡張版の2パターンを紹介します。

シンプル版(小規模・学習用)

my_project/
├── pages/                    # Page Object Model(ページ操作クラス)
│   ├── __init__.py
│   ├── login_page.py
│   └── inventory_page.py
├── tests/                    # テストファイル
│   ├── conftest.py           # 共通fixture
│   ├── test_login.py
│   └── test_inventory.py
├── pytest.ini                # pytest設定
└── requirements.txt

拡張版(中〜大規模・チーム運用向け)

my_project/
├── pages/                    # Page Object Model
│   ├── __init__.py
│   ├── base_page.py          # 全ページ共通の基底クラス
│   ├── login_page.py
│   └── inventory_page.py
├── tests/                    # テストファイル
│   ├── conftest.py           # セッション・ブラウザ系fixture
│   ├── e2e/                  # E2Eテスト(フロー全体)
│   │   ├── conftest.py       # E2E専用fixture
│   │   └── test_purchase_flow.py
│   ├── smoke/                # スモークテスト(最小限の動作確認)
│   │   └── test_login_smoke.py
│   └── regression/           # 回帰テスト
│       └── test_cart.py
├── utils/                    # ヘルパー関数・定数
│   ├── __init__.py
│   └── constants.py          # URL・テストデータ定数
├── reports/                  # テストレポート出力先
├── pytest.ini
└── requirements.txt
💡 設計の原則:「テストコードは何をテストするか」、「pagesコードはどう操作するか」、「utilsはどんな共通処理か」を明確に分離することで、どのファイルに何を書けばいいか迷わなくなります。

② pytest.ini の完成形

プロジェクトルートに置く pytest.ini です。これがあるだけでチーム全員が同じ設定でテストを実行できます。

# pytest.ini
[pytest]
# テスト検索パス
testpaths = tests

# デフォルトオプション
addopts =
    -v
    --tb=short
    --html=reports/report.html
    --self-contained-html

# カスタムマーク登録(未登録だと警告が出る)
markers =
    smoke: スモークテスト(最小限の動作確認・毎回実行)
    regression: 回帰テスト(リリース前に実行)
    e2e: E2Eテスト(フロー全体・時間がかかる)
    slow: 実行時間が長いテスト

# ログ設定
log_cli = true
log_cli_level = INFO
log_format = %(asctime)s %(levelname)s %(message)s
log_date_format = %Y-%m-%d %H:%M:%S

③ conftest.py の階層設計

conftest.pyは「どのスコープのfixtureか」によって置き場所を分けるのがベストプラクティスです。

tests/conftest.py(プロジェクト全体で共有)

# tests/conftest.py
import pytest
from playwright.sync_api import sync_playwright

# ============================================================
# ブラウザ・ページ系fixture(セッションスコープ)
# ============================================================

@pytest.fixture(scope="session")
def browser_instance():
    """テスト全体で1つのブラウザインスタンスを共有する"""
    with sync_playwright() as p:
        browser = p.chromium.launch(headless=True)
        yield browser
        browser.close()

@pytest.fixture(scope="function")
def page(browser_instance):
    """テストごとに新しいページ(タブ)を作成する"""
    context = browser_instance.new_context(
        viewport={"width": 1280, "height": 720}
    )
    page = context.new_page()
    yield page
    context.close()  # テスト終了後にコンテキストを閉じる

# ============================================================
# 認証系fixture
# ============================================================

@pytest.fixture(scope="session")
def base_url():
    """テスト対象のベースURLを返す"""
    # 実際のコードでは https:// を付けてください(例:"https://www.saucedemo.com")
    return "www.saucedemo.com"

@pytest.fixture
def valid_credentials():
    """正常系テスト用のログイン情報"""
    return {
        "username": "standard_user",
        "password": "secret_sauce"
    }

@pytest.fixture
def locked_out_credentials():
    """ロックアウトユーザーのログイン情報(異常系テスト用)"""
    return {
        "username": "locked_out_user",
        "password": "secret_sauce"
    }

# ============================================================
# ログイン済みページfixture(よく使うのでまとめる)
# ============================================================

@pytest.fixture
def logged_in_page(page, base_url, valid_credentials):
    """ログイン済みの状態でpageを返すfixture"""
    page.goto(f"https://{base_url}/")
    page.fill("#user-name", valid_credentials["username"])
    page.fill("#password", valid_credentials["password"])
    page.click("#login-button")
    page.wait_for_url("**/inventory.html")
    return page

tests/e2e/conftest.py(E2Eテスト専用)

# tests/e2e/conftest.py
import pytest

@pytest.fixture
def cart_items():
    """E2Eテスト用カートアイテムのテストデータ"""
    return ["Sauce Labs Backpack", "Sauce Labs Bike Light"]

@pytest.fixture
def shipping_info():
    """E2Eテスト用配送情報"""
    return {
        "first_name": "テスト",
        "last_name": "太郎",
        "postal_code": "100-0001"
    }
💡 fixture階層の使い分けtests/conftest.py にはプロジェクト全体で使うブラウザ・認証系fixtureを置きます。特定のテストカテゴリにしか使わないfixtureはそのカテゴリの conftest.py に置くことで、不要なfixtureが混在しません。

④ Page Object Model の実装

pytestと組み合わせるPage Object Modelの実装例です。基底クラスに共通処理をまとめるのがポイントです。

pages/base_page.py(基底クラス)

# pages/base_page.py
from playwright.sync_api import Page

class BasePage:
    """全ページ共通の操作をまとめた基底クラス"""

    def __init__(self, page: Page):
        self.page = page

    def navigate(self, url: str):
        """指定URLに遷移する"""
        self.page.goto(url)

    def get_title(self) -> str:
        """ページタイトルを返す"""
        return self.page.title()

    def take_screenshot(self, name: str):
        """スクリーンショットを保存する"""
        self.page.screenshot(path=f"reports/{name}.png")

pages/login_page.py

# pages/login_page.py
from pages.base_page import BasePage

class LoginPage(BasePage):
    """ログインページの操作クラス"""

    # ロケーター定数(セレクタを一箇所で管理)
    USERNAME_INPUT = "#user-name"
    PASSWORD_INPUT = "#password"
    LOGIN_BUTTON   = "#login-button"
    ERROR_MESSAGE  = ".error-message-container h3"

    def login(self, username: str, password: str):
        """ログインを実行する"""
        self.page.fill(self.USERNAME_INPUT, username)
        self.page.fill(self.PASSWORD_INPUT, password)
        self.page.click(self.LOGIN_BUTTON)

    def get_error_message(self) -> str:
        """エラーメッセージのテキストを返す"""
        return self.page.locator(self.ERROR_MESSAGE).text_content()

⑤ テストファイルの実装

fixtureとPage Objectを組み合わせたテストファイルの完成形です。

tests/smoke/test_login_smoke.py

# tests/smoke/test_login_smoke.py
import pytest
from pages.login_page import LoginPage

@pytest.mark.smoke
def test_ログイン_正常系(page, base_url, valid_credentials):
    """スモーク:正常なログインが成功することを確認"""
    login_page = LoginPage(page)
    login_page.navigate(f"https://{base_url}/")
    login_page.login(
        valid_credentials["username"],
        valid_credentials["password"]
    )
    assert page.url.endswith("/inventory.html"), \
        f"ログイン後のURLが期待値と異なります: {page.url}"

@pytest.mark.smoke
def test_ログイン_パスワード誤り(page, base_url):
    """スモーク:誤ったパスワードでエラーメッセージが表示されることを確認"""
    login_page = LoginPage(page)
    login_page.navigate(f"https://{base_url}/")
    login_page.login("standard_user", "wrong_password")
    error = login_page.get_error_message()
    assert "Epic sadface" in error

@pytest.mark.smoke
def test_ログイン_ロックアウト(page, base_url, locked_out_credentials):
    """スモーク:ロックアウトユーザーでエラーが表示されることを確認"""
    login_page = LoginPage(page)
    login_page.navigate(f"https://{base_url}/")
    login_page.login(
        locked_out_credentials["username"],
        locked_out_credentials["password"]
    )
    error = login_page.get_error_message()
    assert "locked out" in error

tests/e2e/test_purchase_flow.py

# tests/e2e/test_purchase_flow.py
import pytest

@pytest.mark.e2e
@pytest.mark.slow
def test_購入フロー_正常系(logged_in_page, cart_items, shipping_info):
    """E2E:ログイン〜購入完了までのフローをテスト"""
    page = logged_in_page

    # 商品をカートに追加
    page.click("[data-test='add-to-cart-sauce-labs-backpack']")
    page.click("[data-test='add-to-cart-sauce-labs-bike-light']")

    # カートに移動
    page.click(".shopping_cart_link")
    assert page.url.endswith("/cart.html")

    # チェックアウト開始
    page.click("[data-test='checkout']")
    page.fill("[data-test='firstName']", shipping_info["first_name"])
    page.fill("[data-test='lastName']", shipping_info["last_name"])
    page.fill("[data-test='postalCode']", shipping_info["postal_code"])
    page.click("[data-test='continue']")

    # 注文確認・完了
    page.click("[data-test='finish']")
    success_msg = page.locator(".complete-header").text_content()
    assert "Thank you" in success_msg

⑥ mark を使ったテスト実行制御

markを設定しておくことで、CI/CDのフェーズに応じて実行するテストセットを柔軟に切り替えられます。

mark用途実行タイミング実行時間目安
smoke最小限の動作確認PR・push のたびに実行〜2分
regression既存機能の回帰確認main マージ前〜10分
e2eフロー全体の確認リリース前・夜間スケジュール〜30分
slow時間のかかるテスト夜間スケジュールのみ30分〜
# スモークテストだけ実行(PR・pushのたびに)
pytest -m smoke

# スモーク・回帰テストを実行(mainマージ前)
pytest -m "smoke or regression"

# E2Eを除いた全テスト(高速に済ませたいとき)
pytest -m "not e2e"

# slowを除いた全テスト
pytest -m "not slow"

# 全テスト実行(夜間スケジュール)
pytest

⑦ GitHub Actions との連携

markとGitHub Actionsを組み合わせることで、フェーズごとに実行するテストを自動で切り替えられます。

# .github/workflows/playwright.yml(抜粋)

jobs:
  smoke:
    name: スモークテスト(PR・push)
    runs-on: ubuntu-latest
    steps:
      # ... セットアップ省略 ...
      - name: スモークテスト実行
        run: pytest -m smoke

  regression:
    name: 回帰テスト(mainへのマージ後)
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'
    steps:
      # ... セットアップ省略 ...
      - name: 回帰テスト実行
        run: pytest -m "smoke or regression"

  nightly:
    name: 夜間フルテスト
    runs-on: ubuntu-latest
    if: github.event_name == 'schedule'
    steps:
      # ... セットアップ省略 ...
      - name: 全テスト実行
        run: pytest
💡 実務Tip:PRのたびに全テストを走らせるとCIが遅くなりレビューのリズムが壊れます。スモークテストだけPRに紐づけ、E2E・回帰テストはmainマージ後やスケジュール実行にすることで、速さと品質のバランスが取れます。

⑧ 実行結果サンプル

$ pytest -m smoke -v

========================= test session starts ==========================
platform linux -- Python 3.11.0, pytest-7.4.0
collected 8 items / 5 deselected / 3 selected

tests/smoke/test_login_smoke.py::test_ログイン_正常系       PASSED  [ 33%]
tests/smoke/test_login_smoke.py::test_ログイン_パスワード誤り  PASSED  [ 66%]
tests/smoke/test_login_smoke.py::test_ログイン_ロックアウト  PASSED  [100%]

========================== 3 passed in 5.21s ===========================

FAQ

Q. page fixture は pytest-playwright の内蔵と自作どちらを使うべき?

小規模プロジェクトや学習目的なら pytest-playwright の内蔵 page fixture を使うのが手軽です。ただし実務ではビューポートサイズ・ネットワーク設定・認証情報の注入など細かい制御が必要になるため、自作 fixture を使うケースが多くなります。この記事のコードは自作 fixture を採用しています。

Q. conftest.py は1つにまとめるべき?分けるべき?

テストが20件以下の小規模なら tests/conftest.py に全部まとめても問題ありません。テストが増えてきたら「セッション・ブラウザ系は tests/conftest.py」「カテゴリ専用は各サブディレクトリの conftest.py」に分割するのがベストプラクティスです。分けることで「このfixtureはどのテストで使えるか」が一目でわかるようになります。

Q. Page Object Model は最初から使うべき?

テストが5〜10件以下の最初期はPOMなしでも管理できます。「同じセレクタを複数のテストファイルに書き始めた」と感じたタイミングが導入のサインです。早めに導入するほどリファクタリングのコストが低くなるので、最初から取り入れることをおすすめします。

Q. browser scope を session にするとテスト間で状態が汚染されませんか?

ブラウザインスタンスはセッションスコープで共有しても問題ありませんが、ページ(タブ)は必ず function スコープにして毎テスト新しく作ることが重要です。この記事のコードではブラウザは session・ページは function スコープに設計しており、テスト間の状態汚染を防いでいます。

⚠️ Playwright + pytest 構成でよくあるはまりポイント7選

構成を正しく組んでいても踏みやすい落とし穴があります。事前に把握しておくことで、余計なデバッグ時間を省けます。

① browser を function スコープにするとテストが遅くなりすぎる

ブラウザ起動は重い処理です。scope="function" にすると毎テストごとにブラウザが起動・終了するため、テストが50件あると起動だけで数分かかります。ブラウザは scope="session"・ページは scope="function" の組み合わせが最も効率的です。

② context を閉じ忘れてメモリリークが発生する

browser.new_context() で作成したコンテキストは、テスト終了後に必ず context.close() する必要があります。fixture の yield の後に context.close() を書き忘れると、テストを重ねるごとにメモリ使用量が増え続けます。

③ logged_in_page fixture を session スコープにするとテストが干渉する

「毎テストでログインするのが遅い」という理由で logged_in_pagescope="session" にすると、あるテストが行ったカート操作・ページ遷移が次のテストに引き継がれて予期しない失敗が起きます。認証状態を共有したい場合は storage_state を使ってセッション情報をファイル保存するアプローチが安全です。

④ セレクタをテストコードに直書きするとPOMの意味がなくなる

Page Object を作っても、テストコードの中で page.click("#login-button") のようにセレクタを直書きしてしまうと、セレクタ変更時にテストコード全体を修正する羽目になります。セレクタはPage Objectクラスの定数として定義し、テストコードからは必ずPage Objectのメソッドを通じて操作するのが原則です。

⑤ mark を pytest.ini に登録せずに -m smoke を実行してもフィルタされない

カスタムmarkは @pytest.mark.smoke とデコレータを付けるだけでは不十分で、pytest.ini[pytest] markers = に登録しないと「PytestUnknownMarkWarning」が出ます。さらに未登録のmarkで pytest -m smoke を実行すると、マークが認識されずに全テストが実行されてしまうことがあります。

⑥ pages/ フォルダに __init__.py がないと import エラーになる

Pythonのパッケージとして認識させるために、pages/ フォルダには空の __init__.py ファイルが必要です。これがないと from pages.login_page import LoginPageModuleNotFoundError になります。utils/ フォルダも同様です。

⑦ プロジェクトルート以外から実行して ModuleNotFoundError が出る

pytestは常にプロジェクトルート(pytest.ini があるディレクトリ)から実行してください。tests/ フォルダの中に cd してから実行すると pages モジュールが見つからなくなります。

# ❌ NG
cd tests
pytest

# ✅ 推奨
cd my_project    # pytest.ini のある場所
pytest

📋 この記事のまとめ

  • フォルダ構成は pages/・tests/・utils/ の3層に分けることで役割が明確になり保守しやすくなる
  • conftest.py はスコープごとに階層化し、ブラウザはsession・ページはfunctionスコープが基本
  • Page Object Model のセレクタは定数化してテストコードから直書きしない
  • mark でsmoke・regression・e2eを分類するとCI/CDのフェーズに応じた実行制御ができる
  • pytest.ini でmarkers・addopts・testpathsを定義するとチーム全員が同じ設定で実行できる

最初から完璧な構成を目指す必要はありません。まずはシンプル版のフォルダ構成からスタートし、テストが増えてきたタイミングで少しずつ拡張版に近づけていくのが実務的なアプローチです。

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