miya8060
← back to blog

NOTE · 2026-05-04

E2E が content 更新で毎回壊れるのを frontmatter から導出して直した

Playwright spec に hardcode した "5 selected works" や Now パネルの key 名が、コンテンツ更新のたびに drift して CI red になる問題。spec の setup で gray-matter + readdirSync を呼んで content/ から導出すると、spec が source-of-truth と無条件に同期する。

testingplaywrightnextmdxe2e

このサイトの home の e2e spec が、コンテンツを編集するたびに red になっていた。「5 件の selected works がある」「Now パネルには Working on / Reading / Listening / Location が並ぶ」と spec が hardcode していたから。works を 2 件に減らす PR、Now の項目名を Open to / Stack に置換する PR、こういう普段のコンテンツ編集が毎回 e2e の追従コミットを呼び出す。「いつものこと」として CI red を無視する習慣がつくと、本物のバグも一緒に見逃す。

直し方は単純で、spec が assertion を hardcode するのをやめて、ページが render しているのと同じ content source を spec も読むようにする。spec が source-of-truth と無条件に同期する。

失敗形

// e2e/home.spec.ts (drift trap)
await expect(now.getByRole("listitem")).toHaveCount(4);
for (const key of ["Working on", "Reading", "Listening", "Location"]) {
  await expect(now.getByText(key)).toBeVisible();
}
await expect(section.getByRole("listitem")).toHaveCount(5);

ここで起きていた drift:

  • 5 件は spec を書いた時の真実、22c7b5e chore(works): drop fictional case studies from registry で 2 件まで減らした後は嘘
  • Working on / Reading / Listening / Location は demo seed の語、26a5eee feat(now): replace demo entries with availability and stackNow / Open to / Stack / Location に置換された

どちらも tsc も eslint も通る (literal は valid な string)。Playwright を実 page に当てて初めて落ちる。

直したあと

spec の setup で frontmatter と dir listing を読み、それを assertion の右辺に使う。

// e2e/home.spec.ts (content-driven)
import fs from "node:fs";
import path from "node:path";
import matter from "gray-matter";

const WORKS_DIR = path.join(__dirname, "..", "content", "works");
const NOW_PATH = path.join(__dirname, "..", "content", "now.mdx");

type NowEntry = { key: string; value: string };

const NOW_ENTRIES: readonly NowEntry[] = (
  matter(fs.readFileSync(NOW_PATH, "utf8")).data as {
    entries: readonly NowEntry[];
  }
).entries;

const SELECTED_WORK_COUNT = fs
  .readdirSync(WORKS_DIR)
  .filter((file) => file.endsWith(".mdx")).length;

// ...
const items = now.getByRole("listitem");
await expect(items).toHaveCount(NOW_ENTRIES.length);
for (const entry of NOW_ENTRIES) {
  await expect(items.filter({ hasText: entry.key }).first()).toBeVisible();
}
await expect(section.getByRole("listitem")).toHaveCount(SELECTED_WORK_COUNT);

これで spec は「render が source-of-truth と一致しているか」を assert する形になり、件数や label を変えるコンテンツ編集に追従する。

向くケース、向かないケース

向く:

  • ページが filesystem-readable な MDX / JSON / YAML から render されていて、spec process も同じファイルを読める
  • assertion の主旨が 構造: 「公開済みの全 work が listitem として並ぶ」「Now の全 entry が出る」
  • コンテンツの更新頻度が spec のレビュー頻度より高い

向かない:

  • hardcode された値そのものが契約: 「contact form は 4 fields を超えない」を UX の意図として固定したいなら、そこを literal で縛るのが feature
  • 「コンテンツの編集 = 仕様の変更」とみなしたいビジネスルール (silently 追従されると困る)

落とし穴

Locator の衝突getByText("Now", { exact: true }) がパネルヘッダと listitem の両方にマッチした。text でだけ探さず、構造で先に scope する: now.getByRole("listitem").filter({ hasText: entry.key })

registry を spec から import しない。Next.js の MDX registry (src/content/works.ts 等) は MDX を module load 時に import するので、Playwright runner が compile できない。gray-matter で raw MDX frontmatter を読めば bundler 非依存で済む。同 repo の e2e/works.spec.ts, e2e/blog-slug.spec.ts でも同じ pattern を使っている。

Empty-state の guardSELECTED_WORK_COUNT === 0 のときに section が条件 render されるなら、可視性 assertion の前に分岐を入れる (e2e/blog-slug.spec.tsif (BLOG_POSTS.length === 0) と同じ形)。

なぜここで効くか

このサイトはそもそも、別 repo の wiki vault (claude-obsidian) から毎日下書きを生成して publish するパイプラインの吐き出し先になっている。コンテンツが頻繁に動く前提で、e2e が hardcode 混じりだと「CI red を無視する」習慣が根づき、その習慣がいずれ実バグを見逃す。spec が content と一緒に動くようにしておくのは、CI を信号として保つための前提条件だった。

(Origin: PR #46, commits 7e7c692 + 2b858cc.)