miya8060
← back to blog

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-nikkicigithub-actionsvitestplaywrighttestcontainersdependabotvercel

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 を介してしか確認できない。

種別ツール配置速度何を守るか
単体Vitestlib/**/*.test.ts (co-locate)~20s純関数、zod 境界値、cookie ヘルパ
結合Vitest + Testcontainerstests/integration/~2-3 分Server Action、Cron、認証フロー、並行性
E2EPlaywright (Chromium)tests/e2e/~3-5 分ユーザシナリオ、a11y、UI ガード
探索Playwright MCP(CI 非搭載)対話的演出確認、バグ再現、prod verification

この区分は vitest.config.tsprojectsunitintegration を分けて宣言することで実装に落としている。unitlib/**/*.test.tsapp/**/*.test.ts (co-locate)、integrationtests/integration/**/*.test.tsglobalSetup: ["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/postgresqltests/setup/db.tsbeforeAll に 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 中に効く。

integrationpnpm 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 の中で:

  1. Testcontainers で Postgres 16 を起動
  2. prisma migrate deploy
  3. pnpm build
  4. next start を子プロセスで spawn
  5. / への HTTP polling で readiness 確認

を直列に行う。webServerglobalSetup を並走させると環境変数 (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@v7if: 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) への移行 + directUrlprisma.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 つ:

  1. 演出確認: DiarySection / StickerStack / ScatterSection のスクロール演出を画面ダンプで確認する。CSS の transform を Playwright spec で assert するのは脆くて壊れやすい。MCP 目視のほうが情報量が多い。
  2. バグ再現: 報告されたバグを MCP セッションで再現 → そのときのロケータをコピーしてそのまま tests/e2e/ に昇格。MCP で使うロケータは自動 E2E と同じ data-testid 命名規約に揃えて、昇格コストをゼロに近づける。
  3. 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.tsxglobal-error.tsx は実用テスト不能 (前者は dev overlay と production build の差で揺れ、後者は再現条件が厳しい) なので、レビューで目視確認するレイヤに切り分ける。

運用ポリシーの要点

ここまでで何度か出てきた選択を、ポリシーとしてまとめておく。

  1. 要件 ID をテスト名に貼るdocs/requirements.md の F-/NF- ID をテスト名コメントまたは describe 名に明示し、testing.md §5 のマッピング表で双方向にトレースする。テストが何を守っているのか forget しないため。
  2. DB は本番と同じ Postgres。SQLite を使った瞬間に「通るテスト」が嘘になる。
  3. Resend は完全モック。実 API を叩く CI を作らない。アカウント停止と料金事故の両方を避ける。
  4. 時間モックは使わない。Prisma との相性問題があるので、DB に createdAt を直接書く。
  5. **カバレッジは lib/**app/\_actions/**app/api/cron/** のみ**。UI 演出は計測対象外、axe と E2E が代わりに見る。
  6. MCP は CI に載せない。CI 自動 E2E と探索的 MCP は別レイヤ、agenda control の体験を CI に持ち込まない。
  7. dependabot は週 1、Prisma major だけ ignore。bot に判断負荷の高い PR を立てさせない。

このポリシーを docs/testing.md 1 ファイルに集約しておくと、エージェント (Claude) も人間も同じ前提で動ける。エージェントとペアで開発するとき、テスト戦略を「暗黙知」にしておくと毎セッション説明が要るので、ファイルに固めるほうが安い。

参考リンク