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)と正規表現ベースのパターンマッチの組み合わせにより、少ないコード量で実用的なフレームワークを構築できます。
テストが仕様書と乖離する問題に悩んでいるなら、ぜひ試してみてください。


