NOTE · 2026-05-07
kokan-nikki の CI/CD とテスト運用を解説する
Y2K カワイイ系の交換日記アプリ kokan-nikki (Next.js 16 + Auth.js v5 + Prisma 6 + Postgres 16) の運用を、要件 ID 駆動のテスト戦略、Testcontainers と Playwright のピラミッド、5 ジョブの GitHub Actions、coverage gate、dependabot の運用ポリシー、Playwright MCP の二層運用、Vercel の cron 設定までまとめて解説する。
kokan-nikki は、ペアまたは小グループ (最大 6 人) で 1 冊のノートを順番に回す Y2K カワイイ系の交換日記アプリ。Next.js 16 (App Router) + React 19 + Auth.js v5 (Resend Email) + Prisma 6 / Postgres 16 + zod 4 + Tailwind v4 で組んでいて、Vercel + Neon に乗っている。
このアプリの事業価値は「ターン制」「招待の atomic claim」「ナッジの 24h クールダウン」のような DB と密結合した時間・並行制約のドメインロジック に集中している。UI を眺めても挙動が見えず、純粋関数だけで再現できない。テストと CI を雑に組むと、F-INV-08 (同時受諾) や F-NUDGE-02 (24h クールダウン) のような「実機で滅多に踏まないが踏むと致命的」な欠陥が混入する。
この記事では docs/testing.md (テスト戦略 v0.1) と .github/workflows/pr.yml (5 ジョブ) と dependabot.yml を根拠に、kokan-nikki の運用を解説する。
テストはバランス型ピラミッドで組む
ピラミッド全体は単体・結合・E2E の 3 段。MVP では結合に最も投資する。理由はドメインロジックが DB と一体になっているから。pickNextOrderIndex(members, latest) のような純粋計算は単体に切り出せるが、その上の「ターンが本当に回ったか」は Prisma を介してしか確認できない。
| 種別 | ツール | 配置 | 速度 | 何を守るか |
|---|---|---|---|---|
| 単体 | Vitest | lib/**/*.test.ts (co-locate) | ~20s | 純関数、zod 境界値、cookie ヘルパ |
| 結合 | Vitest + Testcontainers | tests/integration/ | ~2-3 分 | Server Action、Cron、認証フロー、並行性 |
| E2E | Playwright (Chromium) | tests/e2e/ | ~3-5 分 | ユーザシナリオ、a11y、UI ガード |
| 探索 | Playwright MCP | (CI 非搭載) | 対話的 | 演出確認、バグ再現、prod verification |
この区分は vitest.config.ts の projects に unit と integration を分けて宣言することで実装に落としている。unit は lib/**/*.test.ts と app/**/*.test.ts (co-locate)、integration は tests/integration/**/*.test.ts で globalSetup: ["tests/setup/db.ts"] から Postgres 16 コンテナを起こす。
// vitest.config.ts (抜粋)
projects: [
{ extends: true, test: { name: "unit", include: ["lib/**/*.test.ts", "app/**/*.test.ts"] } },
{
extends: true,
test: {
name: "integration",
include: ["tests/integration/**/*.test.ts"],
globalSetup: ["tests/setup/db.ts"],
setupFiles: ["tests/setup/db.per-test.ts"],
testTimeout: 60_000,
fileParallelism: false,
server: { deps: { inline: ["next-auth", "@auth/core"] } },
},
},
],
fileParallelism: false は MVP の妥協。今は 1 個のコンテナを共有して直列実行している。worker ID をスキーマ名に組み込んで per-worker schema にする TODO が testing.md §4.2 に記されている。server.deps.inline: ["next-auth", "@auth/core"] は next-auth の ESM bundle が next/server を拡張子なしで import するので Node の resolver が ERR_MODULE_NOT_FOUND を出す問題に対する穴埋め。
DB は Testcontainers で本番と同じ Postgres を立てる
SQLite で代替しない。理由は kokan-nikki のロジックが updateMany の WHERE 条件で atomic claim を実現していて、これが Postgres 固有の挙動 (CONCURRENT UPDATE のロック粒度) に依存しているから。SQLite で通っても Postgres で落ちるテストを書きたくない。
@testcontainers/postgresql で tests/setup/db.ts の beforeAll に Postgres 16 を起動して prisma migrate deploy を打ち、beforeEach で全テーブル truncate する。テストごとにコンテナを作り直すのではなく、コンテナ 1 個を共有して中身を空にするほうが速い。
CI でも同じ仕組みが効く。actions/checkout@v6 のあと services: ブロックを書かずに、Testcontainers が ubuntu-latest の Docker daemon を直接叩いて Postgres を立てる。これで pr.yml から DB セットアップの記述が完全に消える。
並行性は Promise.allSettled で検証する
F-INV-08 (招待の同時受諾) は kokan-nikki でいちばん壊しやすいロジックなので、結合に置く。検証パターン:
const [a, b] = await Promise.allSettled([
acceptInvite({ code, userId: userA.id }),
acceptInvite({ code, userId: userB.id }),
]);
// 期待: 片方が fulfilled、もう片方が rejected (InviteUnavailableError)
// メンバー数は claim 成功した側だけ +1
Postgres の真の同時実行を再現する必要はなく、updateMany の WHERE 条件が atomic claim になっていることを確認できれば十分。より強い検証 (pg_advisory_lock でブロック → 解放) は MVP では不要、と testing.md §6.1 に明記してある。
時間モックは使わない
vi.useFakeTimers() は Prisma が内部的に new Date() を使う箇所と相性が悪い。代わりに DB に直接 createdAt を打ち込む:
await prisma.nudge.create({
data: { fromId, toId, createdAt: subHours(new Date(), 23) },
});
await expect(sendNudge({ ... })).rejects.toThrow();
24h 経過後の許可も subHours(now, 25) で別ケース。F-NUDGE-04 の Cron メール 72h 閾値も同じ手法で subHours(now, 73) の Entry を作って検証する。Prisma との相性問題を避けつつテーブルの状態だけで時間軸を再現できる。
E2E は magic link を迂回する
Playwright で magic link をクリックして email を読むようなフローを組まない。代わりに:
// tests/e2e/helpers/auth.ts (概念図)
const session = await prisma.session.create({ data: { userId, ... } });
await context.addCookies([{
name: "authjs.session-token",
value: session.sessionToken,
domain: "localhost",
path: "/",
}]);
直接 Session 行を INSERT して cookie を注入するだけ。マジックリンク自体の検証は結合テスト側で VerificationToken 検証 → Session 発行を踏むので、E2E では迂回する。
Resend は vi.mock ではなく MSW を Node 側で立てる。https://api.resend.com/emails を 200 で握り、tests/e2e/helpers/resend-mock.ts から呼び出し履歴を取得可能にする。テスト中に実 API を叩かない、をどの層でも徹底する。
E2E は MVP として 5 本に絞る:
tests/e2e/
├── error-pages.spec.ts # 404 + 独自 UI + LP 遷移 (PR #76)
├── notebook-flow.spec.ts # ノート作成→招待→受諾→ターン
├── nudge-cooldown.spec.ts # ナッジ 24h クールダウン
├── reduce-motion.spec.ts # .reduce-motion クラスで装飾停止
└── turn-guard.spec.ts # 他人のターン中 /write 直アクセスで notFound
シナリオ末尾で injectAxe(page); checkA11y(page) を呼んで axe-core/playwright で a11y も合わせて見る。/、/auth/signin、/notebooks、/notebooks/[id]、/notebooks/[id]/write、/settings の 6 画面で serious 以上を fail に設定。
CI は 5 jobs、依存関係は cov-gate だけ
.github/workflows/pr.yml のジョブ構成:
trigger: pull_request, push (branch: main)
concurrency: workflow + ref で cancel-in-progress
lint : pnpm install --frozen-lockfile → pnpm lint → pnpm typecheck
unit : pnpm test:unit
integration : pnpm test:int --coverage → upload-artifact (coverage/)
e2e : playwright install --with-deps chromium → pnpm test:e2e
失敗時も playwright-report と test-results を upload
cov-gate : needs: integration → download-artifact → cobertura 確認
concurrency: cancel-in-progress: true で同じ ref で push が連続したときに古いジョブを kill する。merge 待ちの blocker を増やさないためで、特に dependabot の連続 merge 中に効く。
integration の pnpm test:int --coverage は vitest.config.ts の coverage.thresholds を読んで:
thresholds: {
"lib/**": { lines: 80, branches: 75 },
"app/_actions/**": { lines: 70, branches: 65 },
"app/api/cron/**": { lines: 70, branches: 65 },
}
しきい値違反は vitest が non-zero で exit する。これが本来の品質ゲート。cov-gate ジョブはその上で stable な check name を branch protection に提供するための薄いラッパで、actions/download-artifact@v8 で coverage を引き受けて cobertura レポートが artifact に存在することだけを assert する。coverage.thresholds.perFile は厳しすぎるので使わない、ディレクトリ単位の集約閾値だけにする、と testing.md §8 に明記してある。
UI 層 (components/** と page/layout) はカバレッジ計測対象外。演出 SVG だらけのコンポーネントを line で測っても意味のある signal にならない。代わりに axe と E2E が信号源になる。
E2E ジョブは webServer を使わず global-setup に寄せる
Playwright の webServer 機能は使っていない。tests/e2e/global-setup.ts の中で:
- Testcontainers で Postgres 16 を起動
prisma migrate deploypnpm buildnext startを子プロセスで spawn/への HTTP polling で readiness 確認
を直列に行う。webServer と globalSetup を並走させると環境変数 (DATABASE_URL など) の race が起きる。シーケンシャルにすればその race が消える、という割り切り。
CI 側はこれを knowing して services: も webServer 起動オプションも書かない。pnpm test:e2e 1 行を打つだけ。
E2E は失敗時のフォレンジック資料を吐く
playwright.config.ts:
use: {
baseURL: BASE_URL,
trace: "retain-on-failure",
video: "retain-on-failure",
screenshot: "only-on-failure",
}
落ちたときだけ trace、video、screenshot を残す。green のときは何も保存しない。CI ではこれが playwright-report/ と test-results/ に出るので、actions/upload-artifact@v7 で if: always() に固定して artifact に上げる。失敗 PR を後追いで開いて、その場で trace.zip を npx playwright show-trace で再生できる。
dependabot は週 1、Prisma の major だけ ignore
.github/dependabot.yml で npm と github-actions の 2 ecosystem を毎週月曜 9:00 JST にスキャン。npm は同時 open 上限 10、github-actions は 5。groups は使っていない (kokan-nikki は依存がそこまで多くない)。
ignore で抜いているのは Prisma の major だけ:
ignore:
- dependency-name: "prisma"
update-types: ["version-update:semver-major"]
- dependency-name: "@prisma/client"
update-types: ["version-update:semver-major"]
理由はコメントに書いてあって、Prisma 7 が driver adapter (@prisma/adapter-pg) への移行 + directUrl を prisma.config.ts に移すリファクタ + new PrismaClient(...) 全 call site の書き換えを要求するから。dependabot に「すぐ merge できそう」な顔の PR を毎週立てさせるとレビュアー (= 自分) の判断負荷が増えるので、bot から除外して手動で別タスクにする。
testing.md には書かれていない実戦の学びも蓄積している。先日の初回バーストで踏んだ 6 つの罠 (init burst / conflict cascade / @dependabot rebase 無反応 / lockfile 手解き / --auto 即時 merge / LSP と tsc のズレ) は別記事の dependabot 11 本の triage で踏んだ 6 つの罠 にまとめてある。
Playwright MCP は CI に載せない
CI 自動 E2E と Playwright MCP は 別レイヤ として制度化している。MCP は人または Claude が対話的に dev サーバを実ブラウザで操作するためのもので、CI には絶対に載せない。
用途は 3 つ:
- 演出確認:
DiarySection/StickerStack/ScatterSectionのスクロール演出を画面ダンプで確認する。CSS の transform を Playwright spec で assert するのは脆くて壊れやすい。MCP 目視のほうが情報量が多い。 - バグ再現: 報告されたバグを MCP セッションで再現 → そのときのロケータをコピーしてそのまま
tests/e2e/に昇格。MCP で使うロケータは自動 E2E と同じdata-testid命名規約に揃えて、昇格コストをゼロに近づける。 - prod verification: 本番デプロイ後の sanity check を MCP で踏む。実例として、issue #68 の Stage 2 (X OAuth リンク → ユーザマージの 2-step confirm) を https://kokannikki.app に対して MCP で踏破した。magic link の email を読む箇所だけは Claude が触れないので、operator (= 自分) が
/auth/confirm?to=...URL をチャットに paste して Playwright がそこから navigate する、というハンドオフで通した。
CI に載せない理由は明快で、MCP は agenda control の体験 が本質だから。「次にどこを突くか」を人 (or Claude) が判断するから情報量が出る。それを CI に載せた瞬間に決め打ちシナリオに退化して、ただの遅い E2E になる。
branch protection は PR 経由必須、cov-gate は stable な check name
main への直 push は禁止。feature branch → gh pr create → CI 5 jobs green → squash merge の流れに統一。cov-gate ジョブの存在意義はここで、integration ジョブの中で coverage threshold を判定しているにもかかわらず別ジョブを切っているのは、branch protection の "Required status checks" に stable な名前 を登録したいから。integration の中の vitest 実行で fail することがあっても、status check の名前は cov-gate で固定されるので、branch protection 設定が壊れにくい。
dependabot の PR も同じゲートを通る。--auto --squash でキューに入れた PR が CI を通り抜けるたびに 1 本ずつ merge される。先日の初回バースト 11 本もこのフローに乗せて捌いた。
Vercel の cron は vercel.json に直書き
vercel.json:
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"crons": [{ "path": "/api/cron/nudge", "schedule": "0 0 * * *" }]
}
毎日 UTC 0 時 (JST 9 時) に /api/cron/nudge を Vercel が叩く。中で「最終投稿から 72h 経った notebook を拾って Resend で nudge メールを送る」処理を実行 (F-NUDGE-04)。
このエンドポイントは Authorization: Bearer ${CRON_SECRET} を要求するように作ってあって、未認証なら 401 を返す。NF-SEC-02 として結合テストでも検証している (tests/integration/cron/nudge-emails.test.ts)。
エラーページは Next.js 16 の三層構造
app/not-found.tsx (Server)、app/error.tsx (Client)、app/global-error.tsx (Client、root layout を replace) の 3 ファイルを揃えて全パスを塞いでいる。global-error.tsx だけは root layout が無効になるので fonts / palette / globals.css を全て inline で書く。詳しくは別記事の Next.js 16 のエラーページ三層を全部書いた に書いた。
E2E は tests/e2e/error-pages.spec.ts で not-found のみ smoke (404 + Sticker + LP 遷移)、9/9 green。error.tsx と global-error.tsx は実用テスト不能 (前者は dev overlay と production build の差で揺れ、後者は再現条件が厳しい) なので、レビューで目視確認するレイヤに切り分ける。
運用ポリシーの要点
ここまでで何度か出てきた選択を、ポリシーとしてまとめておく。
- 要件 ID をテスト名に貼る。
docs/requirements.mdの F-/NF- ID をテスト名コメントまたは describe 名に明示し、testing.md §5のマッピング表で双方向にトレースする。テストが何を守っているのか forget しないため。 - DB は本番と同じ Postgres。SQLite を使った瞬間に「通るテスト」が嘘になる。
- Resend は完全モック。実 API を叩く CI を作らない。アカウント停止と料金事故の両方を避ける。
- 時間モックは使わない。Prisma との相性問題があるので、DB に
createdAtを直接書く。 - **カバレッジは
lib/**とapp/\_actions/**とapp/api/cron/**のみ**。UI 演出は計測対象外、axe と E2E が代わりに見る。 - MCP は CI に載せない。CI 自動 E2E と探索的 MCP は別レイヤ、agenda control の体験を CI に持ち込まない。
- dependabot は週 1、Prisma major だけ ignore。bot に判断負荷の高い PR を立てさせない。
このポリシーを docs/testing.md 1 ファイルに集約しておくと、エージェント (Claude) も人間も同じ前提で動ける。エージェントとペアで開発するとき、テスト戦略を「暗黙知」にしておくと毎セッション説明が要るので、ファイルに固めるほうが安い。
参考リンク
- kokan-nikki (public)
- docs/testing.md: テスト戦略 v0.1 の一次資料
- .github/workflows/pr.yml: CI 5 jobs の実装
- vitest.config.ts: unit/integration project 分割と coverage threshold
- playwright.config.ts: Chromium 1 ブラウザ + global-setup
- dependabot 11 本の triage で踏んだ 6 つの罠
- Next.js 16 のエラーページ三層を全部書いた