E2E Test SDK — 実装概要
spec: e2e-test-sdk(requirements / design / tasks: .spec-workflow/specs/e2e-test-sdk/) PR: #43 親 spec: e2e-automation-tests(PR #29 でマージ済)
このドキュメントは「なぜ SDK を作ったか」「何を作ったか」「どう使うか」を このリポジトリのコンテキストで 1 ファイルに集約したもの。API 早見表は tests/e2e/sdk/README.md、lower-layer の設計と落とし穴は E2E Testing Guide を参照。
1. なぜ作ったか
e2e-automation-tests で Playwright + Electron の土台ができた段階で、シナリオ 1 件を新規に書こうとすると次が必要だった:
- 6 個のヘルパーの存在を知っている(
launchApp/withTempUserData/seedFixtureDatabase/step/dismissOnboardingDialogs/dbAccess) - 暗黙知を踏み抜かない:
- タブクリックは
dispatchEvent('click')(OperationalChecklistPanel が pointer を intercept) - タブ名は
/^DB$|DB.*閲覧/(narrow viewport で短縮されるため両形対応) - profile 切替は
localStorage[syncKey]上書き →location.reload() - fixture の
typeはRank_2v2かNormal_2v2(isReflectable2v2Matchfilter が2v2部分文字列で切る)
- タブクリックは
- production の DB schema / IPC channel 名 / accessible name を理解する
- assertion パターン(試合詳細 header / Pick・Ban 描画 / Overlay 1.5 秒以内同期)を毎回スクラッチで書く
「あなた(ユーザー)が思い付いた E2E シナリオを 5〜10 行で書ける」を目標に SDK 化したのが本 PR。
2. 何を作ったか
ディレクトリ構成
tests/e2e/
├── sdk/ # ★ ユーザー向け fluent API
│ ├── index.ts # 単一 entry — `import { scenario } from '../sdk'`
│ ├── scenario.ts # scenario(name).withFixture().run/.smoke
│ ├── pageObjects/
│ │ ├── ControlPanel.ts # tab / detailButton / myUid / profile / expectVisible
│ │ ├── Overlay.ts # page() / waitForConfigUpdate()
│ │ └── constants.ts # TAB_NAMES / STORAGE_KEYS / SELECTORS
│ ├── fixtures/
│ │ ├── MatchFixtureBuilder.ts # f.match().player().pick().ban().done()
│ │ └── schema.ts # FIXTURE_DEFAULTS / Team / MatchType
│ ├── actions/
│ │ └── syncToOverlay.ts # Control syncConfig → Overlay listener 合成
│ ├── templates/
│ │ └── example.e2e.ts.template # 写経 skeleton
│ ├── __tests__/ # vitest unit (7 件)
│ └── README.md # API 早見表 + 典型 4 サンプル
├── helpers/ # lower layer(SDK の内部実装、段階移行のため温存)
└── scenarios/
├── startup.e2e.ts # 既存(helpers ベース、未変更)
├── log-import.e2e.ts # 既存(helpers ベース、未変更)
├── db-viewer.e2e.ts # ★ SDK 経由に書き換え(dogfooding)
├── profile-switch.e2e.ts # 既存(helpers ベース、未変更)
└── overlay-sync.e2e.ts # 既存(helpers ベース、未変更)主要 API
| API | 役割 |
|---|---|
scenario(name).withFixture(fn).run(fn) | launch / fixture seed / cleanup を 1 行で。.smoke で smoke project にも乗る |
controlPanel.tab(name).open() | dispatchEvent('click') で intercept 回避済み |
controlPanel.detailButton(n).open() | n 番目の「詳細」ボタンを押す |
controlPanel.myUid.set(uid) | localStorage + reload() + dialog 閉じまで一括 |
controlPanel.profile.select(id) | syncKey 上書き + reload() |
controlPanel.expectVisible(text) | step バナー付きの assert |
controlPanel.expectNoErrorDialog() | dialog[name=エラー] の不在 |
overlay.page() | Overlay window が立ち上がるのを待って Page を返す |
overlay.waitForConfigUpdate(matcher) | electronAPI.onConfigUpdated を仕掛けて matcher hit を待つ |
syncToOverlay(page, overlay, opts) | Control 発火 + Overlay listener 待ち合わせの合成 |
f.defaultMatch() | 既知の 2v2 Rank 1 試合 (自分=MVP/Win) を seed |
f.match().player().pick().ban().done() | カスタム fixture の fluent 組み立て |
3. Before / After
scenarios/db-viewer.e2e.ts の比較。
Before — helpers 直叩き(28 行 / 6 import)
import { test, expect } from '../helpers/withTempUserData';
import { launchApp } from '../helpers/launchApp';
import { seedFixtureDatabase } from '../helpers/seedDatabase';
import { step } from '../helpers/step';
test.describe('S3: DB ビューア(fixture DB seed)', () => {
test('seed した試合の詳細を開き、Pick/Ban テーブルが描画される', async ({ userDataDir }) => {
seedFixtureDatabase(userDataDir);
const { controlPage, cleanup } = await launchApp({ userDataDir });
try {
await step(controlPage, 'DB 閲覧タブへ移動', async () => {
await controlPage.getByRole('tab')
.filter({ hasText: /^DB$|DB.*閲覧/ }).first()
.dispatchEvent('click');
});
await step(controlPage, '最初の row の「詳細」ボタンを押下', async () => {
const detailBtn = controlPage.getByRole('button', { name: '詳細' }).first();
await detailBtn.waitFor({ state: 'attached', timeout: 30_000 });
await detailBtn.dispatchEvent('click');
});
await step(controlPage, '「試合詳細」ヘッダが描画されるのを待つ', async () => {
await expect(controlPage.getByText('試合詳細').first()).toBeVisible({ timeout: 15_000 });
});
await step(controlPage, '「被BAN」セクションが描画されるのを待つ', async () => {
await expect(controlPage.getByText(/被BAN/).first()).toBeVisible({ timeout: 15_000 });
});
} finally {
await cleanup();
}
});
});After — SDK 経由(13 行 / 1 import)
import { scenario } from '../sdk';
scenario('S3: DB ビューア — seed した試合の詳細を開く')
.withFixture((f) => f.defaultMatch())
.run(async ({ controlPanel }) => {
await controlPanel.tab('DB').open();
await controlPanel.detailButton(0).open();
await controlPanel.expectVisible('試合詳細');
await controlPanel.expectVisible(/被BAN/);
});dispatchEvent('click') / getByRole('tab').filter(/^DB$|DB.*閲覧/) / cleanup() / step() ラップは全て SDK 内に隠蔽。同じ assertion を保ちながら scenario 本体は 6 行(assertion 4 行 + 操作 2 行)。
4. 設計判断
4-1. helpers を残す段階移行
既存 5 scenario をいきなり書き換えると flake が紛れ込んだ時の切り分けが難しくなる。SDK は helpers の上に被せる薄い lower layer として実装し、scenarios/db-viewer.e2e.ts だけを dogfooding 対象とした。
将来 SDK で表現できないケース(特殊な BrowserWindow 操作 / 自前 IPC handler の cold path 等)は helpers に降りるエスケープハッチを残す。
4-2. Page Object は class ではなく factory function
Playwright 公式の class ベース Page Object は不採用。理由:
- this binding 問題が出ない
- type inference がそのまま流れる(
createControlPanel(page).tab('DB').open()は全部推論) - step 自動 wrap が
Object.definePropertyハック無しで素直に書ける
4-3. タブ名 / DB schema は constants ファイルに集約
production の表記が変わったときに修正する箇所を 1 ファイルに閉じ込める。
sdk/pageObjects/constants.ts—TAB_NAMES/STORAGE_KEYS/SELECTORSsdk/fixtures/schema.ts—FIXTURE_DEFAULTS/Team/MatchType
タブが増えたら TAB_NAMES.NEW_TAB = { regex: ..., label: ... } を 1 行足すだけ。
4-4. scenario() は test() の薄い wrapper
scenario('S3: 詳細を開ける')
.withFixture((f) => f.defaultMatch())
.run(async (ctx) => { ... });
// ↓ 内部で生成される
test('S3: 詳細を開ける', async ({ userDataDir }) => {
const fixture = createFixtureBuilder();
fn(fixture); fixture.save(userDataDir);
const launched = await launchApp({ userDataDir });
// ... ctx 構築 ...
try { await scenarioFn(ctx); } finally { await launched.cleanup(); }
});ctx に app / page / expect が露出しているので、SDK で表現しきれない処理は素の Playwright API に降りられる。
4-5. .smoke() で title に @smoke tag
playwright.config の smoke project は testMatch で startup.e2e.ts / log-import.e2e.ts を列挙している現状の方式を温存しつつ、SDK 側で .smoke を呼ぶと title に @smoke が付く。grep フィルタでも乗せられる。
5. 新規シナリオを追加する手順
cp tests/e2e/sdk/templates/example.e2e.ts.template \
tests/e2e/scenarios/my-feature.e2e.ts
# 1. シナリオ名と assertion を埋める(README の API 早見表 / 典型 4 サンプルを参考に)
# 2. yarn test:e2e my-feature # 1 回流す
# 3. yarn test:e2e:demo my-feature # 黄色いステップ banner で目視確認
# 4. smoke に乗せたい場合は .smoke() を使うtemplate には「画面を整える → 操作する → assert」の 3 ステップ構造がコメント付きで書かれているので、写経で 5 分以内に 1 シナリオが書ける。
6. 検証結果
| Check | Result |
|---|---|
corepack yarn typecheck | exit=0 |
corepack yarn lint --max-warnings 0 | exit=0 |
corepack yarn test:unit | 1138 pass / 30 skip / 0 fail (107 files) |
SDK 単体 (tests/e2e/sdk/__tests__/) | 7 pass — MatchFixtureBuilder の deep clone / fluent / 複数 match 構築 / TAB_NAMES.regex の 3 形態 match |
yarn test:e2e:smoke は main 取り込み後の別 regression で fail(preload 経由で electronAPI.isElectron が undefined になる)。SDK は production コードを 1 行も触っていないので無関係。詳細は PR #43 本文の「既知 issue」セクション、もしくは別 PR で調査する。
7. 残タスク
.spec-workflow/specs/e2e-test-sdk/tasks.md の Phase 5 のうち未完:
- 5.3
yarn test:e2e:smoke最終 pass — 上記 regression が解消したら走らせる - 5.5 PR 作成 → 完了(#43)
スコープ外の follow-up:
- 他 4 scenario も SDK 化(startup / log-import / profile-switch / overlay-sync)— 本 PR には含めない
- main 取り込み後 smoke regression の調査(preload 分割の影響)
8. 参考
- API 早見表:
tests/e2e/sdk/README.md - Lower layer の設計と落とし穴: E2E Testing Guide
- spec:
.spec-workflow/specs/e2e-test-sdk/ - 実装ログ:
phase-1-4_2026-05-06_sdk-skeleton.md - 親 spec:
.spec-workflow/specs/e2e-automation-tests/