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 と無条件に同期する。
このサイトの 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 stackでNow / 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 の guard。SELECTED_WORK_COUNT === 0 のときに section が条件 render されるなら、可視性 assertion の前に分岐を入れる (e2e/blog-slug.spec.ts の if (BLOG_POSTS.length === 0) と同じ形)。
なぜここで効くか
このサイトはそもそも、別 repo の wiki vault (claude-obsidian) から毎日下書きを生成して publish するパイプラインの吐き出し先になっている。コンテンツが頻繁に動く前提で、e2e が hardcode 混じりだと「CI red を無視する」習慣が根づき、その習慣がいずれ実バグを見逃す。spec が content と一緒に動くようにしておくのは、CI を信号として保つための前提条件だった。
(Origin: PR #46, commits 7e7c692 + 2b858cc.)