Markdownで書くE2Eテスト:自然言語シナリオをPlaywrightで自動実行する方法

PSSLの佐々木です。

E2Eテストは重要だとわかっていても、Playwrightのコードを書くのが面倒で後回しにしていませんか?

本記事では、Markdownファイルに日本語で操作手順を書くだけで、Playwrightが自動実行してくれるE2Eテストフレームワークの作り方を、実際のプロダクション事例をもとに解説します。

この記事でわかること

  • Markdownシナリオ駆動のE2Eテストの全体アーキテクチャ
  • 自然言語ステップをPlaywrightアクションに変換する仕組み
  • フォーム入力の多段フォールバック戦略
  • 動画記録・スクリーンショットによるデバッグ支援
  • pre-commitフックとの連携による開発フロー統合

なぜMarkdownでE2Eテストを書くのか

従来のE2Eテストには3つの問題がありました。

  1. テストコードが仕様と乖離する — Playwrightのコードを読んでも、何のシナリオをテストしているのかひと目でわからない
  2. 非エンジニアがレビューできない — PMやデザイナーがテストケースを確認・追加できない
  3. メンテナンスコストが高い — セレクタの変更ひとつで大量のテストが壊れる

Markdownシナリオ駆動テストなら、こう書けます:

## シナリオ: 管理者ログイン成功
1. <http://localhost:8055/login/> にアクセスする
2. メールアドレスに「admin@example.com」を入力する
3. パスワードに「admin123」を入力する
4. 「ログイン」ボタンをクリックする
5. 「ダッシュボード」というテキストが画面に表示されていることを確認する
6. URLに「/admin/dashboard」が含まれることを確認する

誰が読んでも何をテストしているかわかります。

全体アーキテクチャ

フレームワークは4つのコンポーネントで構成されます。

Markdownシナリオファイル (.md)
         |
    [Parser] Markdownを構造化データに変換
         |
    [Step Mapper] 自然言語 → Playwrightアクション
         |
    [Runner] ブラウザ操作の実行・動画記録・レポート

各コンポーネントのコード量は驚くほど小さく、Parser約90行、Step Mapper約280行、Runner約280行で実現できます。

それぞれ解説していきます。

Step 1: Markdownシナリオのフォーマットを定義する

まず、テストシナリオを記述するMarkdownのフォーマットを決めます。

# 認証機能テスト

## 前提条件
- テスト用管理者が存在する(email: admin@example.com, password: admin123)
- テスト用代理店ユーザーが存在する(email: user@example.com, password: user123)

## シナリオ: 管理者ログイン成功 → ログアウト
1. <http://localhost:8055/login/> にアクセスする
2. メールアドレスに「admin@example.com」を入力する
3. パスワードに「admin123」を入力する
4. 「ログイン」ボタンをクリックする
5. 「ダッシュボード」というテキストが画面に表示されていることを確認する
6. URLに「/admin/dashboard」が含まれることを確認する
7. ログアウトする

## シナリオ: パスワード間違い
1. <http://localhost:8055/login/> にアクセスする
2. メールアドレスに「admin@example.com」を入力する
3. パスワードに「wrongpassword」を入力する
4. 「ログイン」ボタンをクリックする
5. 「メールアドレスまたはパスワードが正しくありません」というテキストが表示されることを確認する

ルールはシンプルです:

要素記法
テストファイルのタイトル# タイトル# 認証機能テスト
前提条件## 前提条件 + 箇条書き- テスト用ユーザーが存在する
シナリオ## シナリオ: 名前 + 番号リスト## シナリオ: ログイン成功
ステップ1. 操作内容1. 「ログイン」ボタンをクリックする

1ファイルに複数シナリオを書けます。前提条件セクションはドキュメントとして機能し、シードデータの仕様を明示する役割を果たします。

Step 2: Markdownパーサーを実装する

Markdownファイルを解析して構造化データに変換するパーサーを作ります。

# parser.py
import re
from dataclasses import dataclass, field
from pathlib import Path

@dataclass
class Scenario:
    name: str
    steps: list[str] = field(default_factory=list)

@dataclass
class ScenarioFile:
    path: Path
    title: str
    preconditions: list[str] = field(default_factory=list)
    scenarios: list[Scenario] = field(default_factory=list)

def parse_scenario_file(filepath: Path) -> ScenarioFile:
    """Markdownシナリオファイルを解析する"""
    text = filepath.read_text(encoding="utf-8")
    lines = text.splitlines()

    title = ""
    preconditions: list[str] = []
    scenarios: list[Scenario] = []
    current_section = None
    current_scenario: Scenario | None = None

    for raw_line in lines:
        line = raw_line.strip()

        # トップレベルタイトル
        if line.startswith("# ") and not line.startswith("## "):
            title = line[2:].strip()
            continue

        # セクションヘッダー
        if line.startswith("## "):
            header = line[3:].strip()

            if "前提条件" in header:
                current_section = "preconditions"
                current_scenario = None
                continue

            # シナリオ検出
            m = re.match(r"^シナリオ[::]\\s*(.+)", header)
            if m:
                current_scenario = Scenario(name=m.group(1).strip())
                scenarios.append(current_scenario)
                current_section = "scenario"
                continue

        # 箇条書き(前提条件)
        m_bullet = re.match(r"^[-*]\\s+(.+)", line)
        if m_bullet:
            content = m_bullet.group(1).strip()
            if current_section == "preconditions":
                preconditions.append(content)
            elif current_section == "scenario" and current_scenario:
                current_scenario.steps.append(content)
            continue

        # 番号付きリスト(シナリオステップ)
        m_num = re.match(r"^\\d+\\.\\s+(.+)", line)
        if m_num and current_section == "scenario" and current_scenario:
            current_scenario.steps.append(m_num.group(1).strip())

    return ScenarioFile(
        path=filepath,
        title=title or filepath.stem,
        preconditions=preconditions,
        scenarios=scenarios,
    )

ポイントは番号付きリストから番号プレフィックスを除去してステップ文字列だけを抽出していることです。1. 「ログイン」ボタンをクリックする「ログイン」ボタンをクリックする

Step 3: ステップマッパーを実装する(コア部分)

ここがこのフレームワークの心臓部です。自然言語のステップを正規表現でパターンマッチし、対応するPlaywrightアクションを実行します。

基本構造

# step_mapper.py
import re
from playwright.sync_api import Page, expect

def execute_step(page: Page, step: str, base_url: str, timeout: int = 30000):
    """自然言語ステップを解釈してPlaywrightアクションを実行する"""

    # --- ナビゲーション ---
    m = re.search(r"(https?://\\S+)\\s*(?:に|へ)アクセスする", step)
    if m:
        page.goto(m.group(1), timeout=timeout)
        return

    m = re.search(r"(/.+?)\\s*(?:に|へ)(?:アクセス|遷移|移動)する", step)
    if m:
        page.goto(base_url + m.group(1), timeout=timeout)
        return

    # ... 他のパターンが続く

対応するステップパターン一覧

フレームワークが認識するステップパターンを紹介します。

ナビゲーション

# 絶対URL
# 例: "<http://localhost:8055/login/> にアクセスする"
m = re.search(r"(https?://\\S+)\\s*(?:に|へ)アクセスする", step)
if m:
    page.goto(m.group(1), timeout=timeout)
    return

# 相対パス
# 例: "/agency/dashboard にアクセスする"
m = re.search(r"(/.+?)\\s*(?:に|へ)(?:アクセス|遷移|移動)する", step)
if m:
    page.goto(base_url + m.group(1), timeout=timeout)
    return

リンク・ボタンのクリック

# リンクをクリック
# 例: 「物件管理」リンクをクリックする
m = re.search(r"[「「](.+?)[」」](?:リンク|メニュー)をクリックする", step)
if m:
    text = m.group(1)
    page.get_by_role("link", name=text).first.click(timeout=timeout)
    page.wait_for_load_state("networkidle")
    return

# ボタンをクリック
# 例: 「ログイン」ボタンをクリックする
m = re.search(r"[「「](.+?)[」」]ボタンを(?:クリック|押)する", step)
if m:
    text = m.group(1)
    # submit ボタンを優先的に探す
    submit_buttons = page.locator("button[type='submit']:visible")
    if submit_buttons.count() > 0:
        for i in range(submit_buttons.count()):
            btn = submit_buttons.nth(i)
            if text in (btn.inner_text() or ""):
                btn.click(timeout=timeout)
                page.wait_for_load_state("networkidle")
                return
        # テキスト完全一致がなければ最初のsubmitボタン
        submit_buttons.first.click(timeout=timeout)
    else:
        page.get_by_role("button", name=text).first.click(timeout=timeout)
    page.wait_for_load_state("networkidle")
    return

# テキスト要素をクリック(テーブル行など)
# 例: 「SKR-001」テキストをクリックする
m = re.search(r"[「「](.+?)[」」](?:テキスト|文字|項目|行)をクリックする", step)
if m:
    page.get_by_text(m.group(1), exact=False).first.click(timeout=timeout)
    return

フォーム入力

# テキスト入力
# 例: メールアドレスに「admin@example.com」を入力する
m = re.search(
    r"[「「]?(.+?)[」」]?(?:欄|フィールド)?に\\s*[「「](.+?)[」」]\\s*(?:を入力|と入力)する",
    step
)
if m:
    label, value = m.group(1), m.group(2)
    _fill_by_label(page, label, value, timeout)
    return

# セレクトボックス
# 例: 「ステータス」で「通電中」を選択する
m = re.search(r"[「「](.+?)[」」](?:で|から)\\s*[「「](.+?)[」」]\\s*を選択する", step)
if m:
    label, value = m.group(1), m.group(2)
    page.get_by_label(label).select_option(label=value, timeout=timeout)
    return

# 日付入力
# 例: 希望日に「2026-12-01」を入力する
m = re.search(
    r"[「「](.+?)[」」](?:欄|フィールド)?に\\s*(\\d{4}[-/]\\d{1,2}[-/]\\d{1,2})\\s*を(?:入力|設定)する",
    step
)
if m:
    label = m.group(1)
    date_str = m.group(2).replace("/", "-")
    page.get_by_label(label).fill(date_str, timeout=timeout)
    return

アサーション(検証)

# テキストが表示されていることを確認
# 例: 「ダッシュボード」というテキストが画面に表示されていることを確認する
m = re.search(
    r"[「「](.+?)[」」].*(?:表示されている|表示される|見える|確認する|含まれる|ある)",
    step
)
if m:
    text = m.group(1)
    locator = page.locator(
        f":not(option):not(select):visible:has-text('{text}')"
    ).first
    expect(locator).to_be_visible(timeout=timeout)
    return

# URLの確認
# 例: URLに「/admin/dashboard」が含まれることを確認する
m = re.search(r"URLに\\s*[「「](.+?)[」」]\\s*が含まれる", step)
if m:
    expect(page).to_have_url(re.compile(re.escape(m.group(1))), timeout=timeout)
    return

# テキストが表示されていないことを確認
# 例: 「エラー」というテキストが表示されていないことを確認する
m = re.search(r"[「「](.+?)[」」].*(?:表示されていない|表示されない|見えない)", step)
if m:
    expect(
        page.get_by_text(m.group(1), exact=False).first
    ).not_to_be_visible(timeout=timeout)
    return

その他

# ログアウト
if re.search(r"ログアウトする", step):
    page.context.clear_cookies()
    page.goto(base_url + "/login/")
    page.wait_for_load_state("networkidle")
    return

# 待機
# 例: 3秒待つ
m = re.search(r"(\\d+)秒(?:待つ|待機する)", step)
if m:
    page.wait_for_timeout(int(m.group(1)) * 1000)
    return

# マッチしなかった場合
raise ValueError(f"未対応のステップ:{step}")

フォーム入力の多段フォールバック戦略

フォーム入力は最もハマりやすいポイントです。実際のHTMLは<label>がない場合、placeholderで代用している場合、CSSフレームワーク特有のマークアップなど、多様です。

そこで、5段階のフォールバック戦略を実装します。

def _fill_by_label(page: Page, label: str, value: str, timeout: int):
    """多段フォールバックでフォームフィールドを特定して入力する"""

    # Level 1: aria-label / <label> による特定
    try:
        loc = page.get_by_label(label, exact=False).locator("visible=true")
        if loc.count() > 0:
            loc.first.fill(value, timeout=timeout)
            return
    except Exception:
        pass

    # Level 2: placeholder による特定
    try:
        loc = page.get_by_placeholder(label, exact=False).locator("visible=true")
        if loc.count() > 0:
            loc.first.fill(value, timeout=timeout)
            return
    except Exception:
        pass

    # Level 3: label要素のDOM構造から辿る
    try:
        labels = page.locator(f"label:visible:has-text('{label}')")
        for i in range(labels.count()):
            label_elem = labels.nth(i)
            parent = label_elem.locator("..")
            inp = parent.locator("input:visible, textarea:visible, select:visible")
            if inp.count() > 0:
                inp.first.fill(value, timeout=timeout)
                return
    except Exception:
        pass

    # Level 4: name属性による特定(日本語ラベル → HTMLのname属性マッピング)
    field_map = {
        "メールアドレス": "email",
        "パスワード": "password",
        "物件名": "name",
        "郵便番号": "postal_code",
        "都道府県": "prefecture",
        "市区町村": "city",
        "町名番地": "address",
        "建物名": "building_name",
        "部屋番号": "room_number",
        "管理コード": "external_key",
        "メモ": "memo",
        # 必要に応じて追加
    }
    name_attr = field_map.get(label)
    if name_attr:
        try:
            loc = page.locator(f"input[name='{name_attr}']:visible, textarea[name='{name_attr}']:visible")
            if loc.count() > 0:
                loc.first.fill(value, timeout=timeout)
                return
        except Exception:
            pass

    # Level 5: type属性による特定
    type_map = {"メールアドレス": "email", "パスワード": "password"}
    type_attr = type_map.get(label)
    if type_attr:
        try:
            loc = page.locator(f"input[type='{type_attr}']:visible")
            if loc.count() > 0:
                loc.first.fill(value, timeout=timeout)
                return
        except Exception:
            pass

    raise ValueError(f"入力フィールドが見つかりません:{label}")

この多段フォールバックにより、ほとんどのHTMLフォームに対応できます。

レベル方法対応するケース
1get_by_label正しく<label>がマークアップされたフォーム
2get_by_placeholderplaceholder属性で入力ヒントを持つフォーム
3DOM構造を辿る<label>がinputと同じ親要素内にあるフォーム
4name属性マッピング<label>がないがname属性は一貫しているフォーム
5type属性email/passwordなど型で一意に特定できるフィールド

Step 4: テストランナーを実装する

シナリオファイルの解析とステップ実行を統合するランナーを作ります。

# runner.py
import signal
import socket
import subprocess
import sys
import time
from dataclasses import dataclass
from pathlib import Path

from playwright.sync_api import sync_playwright

from config import APP_DIR, BASE_URL, BROWSER, HEADLESS, SCENARIOS_DIR, TIMEOUT, VIDEO_DIR
from parser import parse_scenario_file
from step_mapper import execute_step

@dataclass
class TestResult:
    scenario_file: str
    scenario_name: str
    passed: bool
    error: str = ""
    video_path: str = ""
    screenshot_path: str = ""

def start_django_server(port: int = 8055):
    """Djangoサーバーを起動する(既に起動中ならスキップ)"""
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    try:
        sock.connect(("localhost", port))
        sock.close()
        print(f"ポート{port} は既に使用中です。既存のサーバーを使用します。")
        return None
    except ConnectionRefusedError:
        sock.close()

    proc = subprocess.Popen(
        [sys.executable, str(APP_DIR / "manage.py"), "runserver", str(port), "--noreload"],
        stdout=subprocess.DEVNULL,
        stderr=subprocess.DEVNULL,
    )

    # サーバーの起動を待機
    for _ in range(30):
        try:
            s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
            s.connect(("localhost", port))
            s.close()
            print(f"Django開発サーバーがポート{port} で起動しました")
            return proc
        except ConnectionRefusedError:
            time.sleep(1)

    raise RuntimeError("Djangoサーバーの起動がタイムアウトしました")

def stop_django_server(proc):
    """Djangoサーバーを停止する"""
    if proc:
        proc.send_signal(signal.SIGTERM)
        try:
            proc.wait(timeout=5)
        except subprocess.TimeoutExpired:
            proc.kill()

def ensure_seed_data():
    """テスト用シードデータを投入する"""
    subprocess.run(
        [sys.executable, str(APP_DIR / "manage.py"), "seed", "--reset"],
        check=True,
        capture_output=True,
    )
    print("シードデータを投入しました")

def run_scenarios(scenario_files: list[Path], base_url: str, timeout: int) -> list[TestResult]:
    """シナリオファイルを実行して結果を返す"""
    results = []

    with sync_playwright() as p:
        browser = getattr(p, BROWSER).launch(headless=HEADLESS)

        for scenario_path in scenario_files:
            sf = parse_scenario_file(scenario_path)
            print(f"\\n{'='*60}")
            print(f"実行:{sf.title} ({scenario_path.name})")
            print(f"{'='*60}")

            # ファイル単位でブラウザコンテキスト(動画記録)を作成
            video_dir = VIDEO_DIR / scenario_path.stem
            video_dir.mkdir(parents=True, exist_ok=True)

            context = browser.new_context(
                record_video_dir=str(video_dir),
                record_video_size={"width": 1280, "height": 720},
                viewport={"width": 1280, "height": 720},
            )
            page = context.new_page()

            for scenario in sf.scenarios:
                print(f"\\n  シナリオ:{scenario.name}")
                passed = True
                error_msg = ""
                screenshot_path = ""

                for i, step_text in enumerate(scenario.steps, 1):
                    try:
                        print(f"    ステップ{i}:{step_text} ... ", end="", flush=True)
                        execute_step(page, step_text, base_url, timeout)
                        print("OK")
                    except Exception as e:
                        print(f"FAILED:{e}")
                        passed = False
                        error_msg = f"ステップ{i}:{step_text} ->{e}"

                        # 失敗時のスクリーンショット
                        ss_path = video_dir / f"{scenario.name}_fail.png"
                        try:
                            page.screenshot(path=str(ss_path))
                            screenshot_path = str(ss_path)
                        except Exception:
                            pass
                        break

                results.append(TestResult(
                    scenario_file=scenario_path.name,
                    scenario_name=scenario.name,
                    passed=passed,
                    error=error_msg,
                    screenshot_path=screenshot_path,
                ))

            # コンテキストを閉じて動画を確定
            video_path_raw = page.video.path if page.video else None
            page.close()
            context.close()

            # 動画をリネーム
            if video_path_raw:
                final_video = video_dir / f"{scenario_path.stem}.webm"
                try:
                    Path(video_path_raw).rename(final_video)
                    for r in results:
                        if r.scenario_file == scenario_path.name:
                            r.video_path = str(final_video)
                except Exception:
                    pass

        browser.close()

    return results

def print_summary(results: list[TestResult]):
    """テスト結果のサマリーを表示する"""
    print(f"\\n{'='*60}")
    print("テスト結果サマリー")
    print(f"{'='*60}")

    for r in results:
        icon = "PASS" if r.passed else "FAIL"
        print(f"  [{icon}]{r.scenario_file} >{r.scenario_name}")
        if r.video_path:
            print(f"         動画:{r.video_path}")
        if r.error:
            print(f"         エラー:{r.error}")
        if r.screenshot_path:
            print(f"         スクリーンショット:{r.screenshot_path}")

    total = len(results)
    passed = sum(1 for r in results if r.passed)
    failed = total - passed
    print(f"\\n合計:{total}  成功:{passed}  失敗:{failed}")

def main():
    import argparse

    parser = argparse.ArgumentParser(description="Markdown E2Eテストランナー")
    parser.add_argument("scenarios", nargs="*", help="実行するシナリオファイル")
    parser.add_argument("--base-url", default=BASE_URL)
    parser.add_argument("--no-headless", action="store_true")
    parser.add_argument("--no-server", action="store_true")
    parser.add_argument("--no-seed", action="store_true")
    parser.add_argument("--timeout", type=int, default=30)
    args = parser.parse_args()

    global HEADLESS
    if args.no_headless:
        HEADLESS = False

    # シナリオファイルを取得
    if args.scenarios:
        files = [Path(s) for s in args.scenarios]
    else:
        files = sorted(SCENARIOS_DIR.glob("*.md"))

    if not files:
        print("シナリオファイルが見つかりません")
        sys.exit(1)

    # テスト実行
    if not args.no_seed:
        ensure_seed_data()

    server_proc = None
    if not args.no_server:
        server_proc = start_django_server()

    try:
        results = run_scenarios(files, args.base_url, args.timeout * 1000)
        print_summary(results)
        sys.exit(0 if all(r.passed for r in results) else 1)
    finally:
        stop_django_server(server_proc)

if __name__ == "__main__":
    main()

動画記録のポイント

Playwrightの動画記録はブラウザコンテキスト単位で行われます。1つのシナリオファイル内の全シナリオが1本の動画にまとまるため、テスト失敗時のデバッグが容易です。

# ファイルごとにコンテキストを作成 → 1ファイル = 1動画
context = browser.new_context(
    record_video_dir=str(video_dir),
    record_video_size={"width": 1280, "height": 720},
)

Step 5: シナリオファイルを書く

ここまでのフレームワークを使って、実際のシナリオを書いてみましょう。

ファイル命名規則

e2e/scenarios/
├── 01_authentication.md       # 認証
├── 02_agency_property.md      # 代理店 物件管理
├── 03_agency_request.md       # 代理店 申請フロー
├── 04_admin_dashboard.md      # 管理者 ダッシュボード
├── 05_admin_request.md        # 管理者 申請処理
└── 06_admin_agency.md         # 管理者 代理店管理

番号プレフィックスで実行順序を制御します。認証テストを最初に実行し、前提となる機能を先に検証する構成です。

シナリオの書き方のコツ

1. 1シナリオに詰め込みすぎない

<!-- BAD: 長すぎるシナリオ -->
## シナリオ: ログイン → 物件登録 → 申請 → 承認 → ログアウト
1. ... (50ステップ)

<!-- GOOD: 論理的なまとまりで分割 -->
## シナリオ: 物件登録
1. ... (15ステップ)

## シナリオ: 通電申請
1. ... (12ステップ)

ただし、1ファイル内のシナリオは同じ動画に記録されるため、関連する操作フローは同じファイルにまとめると良いでしょう。

2. セレクタではなくユーザーが見えるテキストを使う

<!-- BAD: 実装依存 -->
1. #login-btn をクリックする

<!-- GOOD: ユーザー視点 -->
1. 「ログイン」ボタンをクリックする

3. アサーションは具体的に

<!-- BAD: 曖昧 -->
1. ページが表示されることを確認する

<!-- GOOD: 具体的 -->
1. 「ダッシュボード」というテキストが画面に表示されていることを確認する
2. URLに「/admin/dashboard」が含まれることを確認する

対応しているステップ表現のリファレンス

カテゴリステップ例
ナビゲーションhttp://... にアクセスする/path にアクセスする
リンククリック「メニュー名」リンクをクリックする
ボタンクリック「送信」ボタンをクリックする
テキストクリック「SKR-001」テキストをクリックする
タブ切替「タブ名」タブをクリックする
テキスト入力項目名に「値」を入力する
セレクト「項目名」で「値」を選択する
日付入力「項目名」に 2026-01-01 を入力する
テキスト表示確認「テキスト」というテキストが表示されていることを確認する
テキスト非表示確認「テキスト」が表示されていないことを確認する
URL確認URLに「/path」が含まれることを確認する
ログアウトログアウトする
待機3秒待つ

Step 6: pre-commitフックで開発に組み込む

変更したファイルに応じて関連するシナリオだけを自動実行するpre-commitフックを設定できます。

#!/bin/bash
# e2e/hooks/pre-commit-e2e.sh

CHANGED_FILES=$(git diff --cached --name-only)
SCENARIOS_TO_RUN=""

for file in $CHANGED_FILES; do
    case "$file" in
        *auth* | *login* | *middleware*)
            SCENARIOS_TO_RUN="$SCENARIOS_TO_RUN e2e/scenarios/01_authentication.md"
            ;;
        *property*)
            SCENARIOS_TO_RUN="$SCENARIOS_TO_RUN e2e/scenarios/02_agency_property.md"
            ;;
        *request*)
            SCENARIOS_TO_RUN="$SCENARIOS_TO_RUN e2e/scenarios/03_agency_request.md"
            SCENARIOS_TO_RUN="$SCENARIOS_TO_RUN e2e/scenarios/05_admin_request.md"
            ;;
        *agency*)
            SCENARIOS_TO_RUN="$SCENARIOS_TO_RUN e2e/scenarios/06_admin_agency.md"
            ;;
        # コアモデル変更時はフルリグレッション
        *models* | *enums* | *config/*)
            python e2e/runner.py
            exit $?
            ;;
    esac
done

if [ -n "$SCENARIOS_TO_RUN" ]; then
    # 重複除去して実行
    UNIQUE=$(echo "$SCENARIOS_TO_RUN" | tr ' ' '\\n' | sort -u | tr '\\n' ' ')
    python e2e/runner.py $UNIQUE
    exit $?
fi

echo "E2Eテスト対象の変更なし、スキップします"
exit 0

.git/hooks/pre-commit にシンボリックリンクを貼るか、pre-commitフレームワークで管理します。

プロジェクト構成まとめ

e2e/
├── config.py            # 環境設定(URL、タイムアウト、テストアカウント等)
├── parser.py            # Markdownパーサー(~90行)
├── step_mapper.py       # ステップマッパー(~280行)
├── runner.py            # テストランナー(~280行)
├── requirements.txt     # playwright>=1.40.0
├── hooks/
│   └── pre-commit-e2e.sh
├── scenarios/
│   ├── 01_authentication.md
│   ├── 02_agency_property.md
│   └── ...
└── test-results/        # 動画・スクリーンショット出力先

全体で約650行のPythonコードです。

実行方法

# Playwrightのインストール
pip install playwright
playwright install chromium

# 全シナリオ実行
python e2e/runner.py

# 特定シナリオのみ
python e2e/runner.py e2e/scenarios/01_authentication.md

# ブラウザを表示して実行(デバッグ用)
python e2e/runner.py --no-headless

# サーバーが既に起動している場合
python e2e/runner.py --no-server --no-seed

実行結果:

シードデータを投入しました
Django開発サーバーがポート 8055 で起動しました

============================================================
実行: 認証機能テスト (01_authentication.md)
============================================================

  シナリオ: 管理者ログイン成功 → ログアウト → パスワード間違い
    ステップ 1: <http://localhost:8055/login/> にアクセスする ... OK
    ステップ 2: メールアドレスに「admin@example.com」を入力する ... OK
    ステップ 3: パスワードに「admin123」を入力する ... OK
    ステップ 4: 「ログイン」ボタンをクリックする ... OK
    ステップ 5: 「ダッシュボード」というテキストが表示されている ... OK
    ...

============================================================
テスト結果サマリー
============================================================
  [PASS] 01_authentication.md > 管理者ログイン成功
         動画: test-results/01_authentication/01_authentication.webm

合計: 1  成功: 1  失敗: 0

従来のE2Eテストとの比較

項目Playwrightコード直書きMarkdownシナリオ駆動
可読性エンジニアのみ誰でも読める
記述量多い(セレクタ指定等)少ない(自然言語)
メンテナンステストごとに修正Step Mapperの1箇所を修正
柔軟性無制限パターン定義内に限定
デバッグステップ単位で追跡可能同左 + 動画記録
学習コストPlaywright APIの理解が必要日本語テンプレに沿うだけ
CI統合標準的同左

拡張のアイデア

新しいステップパターンの追加

step_mapper.py に正規表現と実行ロジックを追加するだけです。

# 例: チェックボックスの操作
m = re.search(r"[「「](.+?)[」」]チェックボックスをチェックする", step)
if m:
    page.get_by_label(m.group(1)).check()
    return

# 例: ファイルアップロード
m = re.search(r"[「「](.+?)[」」]に\\s*[「「](.+?)[」」]\\s*をアップロードする", step)
if m:
    page.get_by_label(m.group(1)).set_input_files(m.group(2))
    return

多言語対応

パターン定義を外部ファイル(YAML等)に切り出せば、英語版も容易に作れます。

# patterns_en.yaml
navigation:
  - pattern: 'navigate to "(.*)"'
    action: goto
link_click:
  - pattern: 'click "(.*)" link'
    action: click_link

テストデータのパラメータ化

前提条件セクションからテストデータを動的に生成する仕組みを追加することもできます。

まとめ

Markdownシナリオ駆動のE2Eテストは、仕様とテストを一体化させるアプローチです。

  • Markdownで書いた操作手順がそのままテストになる
  • 非エンジニアでもテストケースをレビュー・追加できる
  • Step Mapperの修正1箇所で全テストの挙動を変更できる
  • 動画記録により失敗時のデバッグが直感的
  • 全体650行程度のコードで実現可能

Playwrightの柔軟なロケーター戦略(get_by_role, get_by_label, get_by_text)と正規表現ベースのパターンマッチの組み合わせにより、少ないコード量で実用的なフレームワークを構築できます。

テストが仕様書と乖離する問題に悩んでいるなら、ぜひ試してみてください。

ご覧いただきありがとうございます! この投稿はお役に立ちましたか?

役に立った 役に立たなかった

0人がこの投稿は役に立ったと言っています。
エンジニア募集中!

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です