Skip to content

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 の typeRank_2v2Normal_2v2isReflectable2v2Match filter が 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)

ts
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)

ts
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.tsTAB_NAMES / STORAGE_KEYS / SELECTORS
  • sdk/fixtures/schema.tsFIXTURE_DEFAULTS / Team / MatchType

タブが増えたら TAB_NAMES.NEW_TAB = { regex: ..., label: ... } を 1 行足すだけ。

4-4. scenario()test() の薄い wrapper

ts
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 は testMatchstartup.e2e.ts / log-import.e2e.ts を列挙している現状の方式を温存しつつ、SDK 側で .smoke を呼ぶと title に @smoke が付く。grep フィルタでも乗せられる。


5. 新規シナリオを追加する手順

bash
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. 検証結果

CheckResult
corepack yarn typecheckexit=0
corepack yarn lint --max-warnings 0exit=0
corepack yarn test:unit1138 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:smokemain 取り込み後の別 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. 参考