PSSLの佐々木です。 E2Eテストは重要だとわかっていても、Playwrightのコードを書くのが面倒で後回しにしていませんか? 本記事では、 Markdownファイルに日本語で操作手順を書くだけで、Playwrightが自動実行してくれるE2Eテストフレームワーク の作り方を、実際のプロダクション事例をもとに解説します。 この記事でわかること Markdownシナリオ駆動のE2Eテストの全体アーキテクチャ 自然言語ステップをPlaywrightアクションに変換する仕組み フォーム入力の多段フォールバック戦略 動画記録・スクリーンショットによるデバッグ支援 pre-commitフックとの連携による開発フロー統合 なぜMarkdownでE2Eテストを書くのか 従来のE2Eテストには3つの問題がありました。 テストコードが仕様と乖離する — Playwrightのコードを読んでも、何のシナリオをテストしているのかひと目でわからない 非エンジニアがレビューできない — PMやデザイナーがテストケースを確認・追加できない メンテナンスコストが高い — セレクタの変更ひとつで大量のテストが壊れる 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フォームに対応できます。 レベル 方法 対応するケース 1 get_by_label 正しく <label> がマークアップされたフォーム 2 get_by_placeholder placeholder 属性で入力ヒントを持つフォーム 3 DOM構造を辿る <label> がinputと同じ親要素内にあるフォーム 4 name 属性マッピング <label> がないがname属性は一貫しているフォーム 5 type 属性 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人がこの投稿は役に立ったと言っています。 The post Markdownで書くE2Eテスト:自然言語シナリオをPlaywrightで自動実行する方法 first appeared on SIOS Tech Lab .