#14までで、認証・DB・Markdown・画像アップロードを備えたミニブログの機能がほぼ完成しました。
しかし、ここまで手動でしか動作確認をしていません。
今後、機能を追加したり既存のコードを修正したりするたびに、すべての機能を手作業で再確認するのは現実的ではありません。
今回は、Vitestによるユニットテストと、PlaywrightによるE2E(End to End)テストを導入し、コードの変更が既存の機能を壊していないかを自動で確認できるようにします。
この記事は、#14までに実装した機能を対象にテストを書いていきます。
この記事のゴール
- ユニットテストとE2Eテストの役割の違いを理解する
- Vitestで、Server Actions内のロジック(特に#13の所有者チェック)をテストする
- Playwrightで、「ログイン→投稿→一覧に表示される」という一連の流れをテストする
ユニットテストとE2Eテストの違い
| ユニットテスト(Vitest) | E2Eテスト(Playwright) | |
|---|---|---|
| 対象 | 関数やロジックの一部 | ブラウザ上でのユーザー操作全体 |
| 速度 | 速い(ミリ秒単位) | 遅い(実際にブラウザを起動する) |
| 得意なこと | 条件分岐やバリデーションなど、細かいロジックの検証 | 画面遷移やUIの見た目を含めた、実際のユーザー体験の検証 |
| 例 | 「他人の記事を削除しようとしたらエラーになるか」 | 「ログインしてフォームを送信したら、一覧に記事が増えるか」 |
どちらか一方だけでは不十分です。
ユニットテストで細かいロジックを網羅しつつ、E2Eテストで「実際に一通り使えるか」を確認する、という2段構えで品質を担保します。
Vitestをセットアップする
インストール
npm install -D vitest @vitejs/plugin-react@4 vite-tsconfig-pathsもし、npm audit fix(--forceなし)を実行するよう求められたら、実行してください。
// vitest.config.ts
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import tsconfigPaths from "vite-tsconfig-paths";
export default defineConfig({
plugins: [react(), tsconfigPaths()],
test: {
environment: "node",
globals: true,
},
});// package.json(抜粋)
{
"scripts": {
//省略(dev,buildなどはそのままで末尾に追記)
"test": "vitest run",
"test:watch": "vitest"
}
}Prismaをモックしてロジックだけをテストする
Server Actionsのテストで一番悩ましいのが、「本物のデータベースに接続せずに、ロジックだけをテストしたい」という点です。
DBに繋いだままテストすると、実行のたびにデータが変化して結果が安定しませんし、実行速度も遅くなります。
そこで、Prisma Clientを偽物(モック)に差し替えるという方法を使います。
このセットアップは3つのファイルで役割が分かれています。
| ファイル | 役割 |
|---|---|
lib/__mocks__/prisma.ts | 偽物のPrisma Client(prismaMock)そのものを作る |
vitest.setup.ts | コード中のimport { prisma } from "@/lib/prisma"を、すべて上の偽物に差し替える「配線」 |
vitest.config.ts | vitest.setup.tsを読み込むよう指示する |
npm install -D vitest-mock-extended// lib/__mocks__/prisma.ts
import { PrismaClient } from "@/app/generated/prisma/client";
import { mockDeep, mockReset, DeepMockProxy } from "vitest-mock-extended";
import { beforeEach } from "vitest";
export const prismaMock = mockDeep<PrismaClient>() as DeepMockProxy<PrismaClient>;
beforeEach(() => {
mockReset(prismaMock);
});// vitest.setup.ts
import { vi } from "vitest";
vi.mock("@/lib/prisma", async () => {
const { prismaMock } = await import("@/lib/__mocks__/prisma");
return { prisma: prismaMock };
});// vitest.config.ts(setupFilesを追加)
export default defineConfig({
// ...
test: {
environment: "node",
globals: true,
setupFiles: ["./vitest.setup.ts"],
},
});これで、テストコードの中でprisma.post.findUnique(...)のようなコードを実行しても、実際にはDBに接続せず、prismaMockに対して「こう呼ばれたら、こう返す」という指示ができるようになります。
Server Actionsの所有者チェックをテストする
#13で実装した、「他人の記事を削除できない」というロジックをテストしてみましょう。
deletePostは、auth.api.getSession(...)・revalidatePath(...)・redirect(...)など、Prisma以外にもいくつかの外部の関数を呼び出しています。
これらはどれも「Next.jsのサーバーが実際にリクエストを処理している最中」でしか正しく動かない仕組みに依存しているため、Prismaと同じようにテストの中ではすべてモックに差し替える必要があります。
// app/posts/actions.test.ts
import { describe, it, expect, vi } from "vitest";
import { deletePost } from "./actions";
import { prismaMock } from "@/lib/__mocks__/prisma";
import type { Post } from "@/app/generated/prisma/client";
vi.mock("@/lib/auth", () => ({
auth: {
api: {
getSession: vi.fn(),
},
},
}));
vi.mock("next/headers", () => ({
headers: vi.fn().mockResolvedValue(new Headers()),
}));
vi.mock("next/cache", () => ({
revalidatePath: vi.fn(),
}));
vi.mock("next/navigation", () => ({
redirect: vi.fn(),
}));
import { auth } from "@/lib/auth";
// auth.api.getSession の実際の戻り値の型を、手動で書き写さずそのまま流用する
type MockSession = Awaited<ReturnType<typeof auth.api.getSession>>;
// テストに必要な最小限のプロパティだけを持つダミーのセッションを作るヘルパー
function mockSessionFor(userId: string): MockSession {
return { user: { id: userId } } as unknown as MockSession;
}
describe("deletePost", () => {
it("ログインしていない場合はエラーになる", async () => {
vi.mocked(auth.api.getSession).mockResolvedValue(null);
await expect(deletePost(1)).rejects.toThrow("ログインが必要です");
});
it("他人の記事を削除しようとするとエラーになる", async () => {
vi.mocked(auth.api.getSession).mockResolvedValue(mockSessionFor("user-a"));
prismaMock.post.findUnique.mockResolvedValue({
id: 1,
authorId: "user-b", // 別のユーザーが投稿者
title: "他人の記事",
content: "本文",
published: true,
coverImage: null,
createdAt: new Date(),
updatedAt: new Date(),
} as Post);
await expect(deletePost(1)).rejects.toThrow(
"この記事を削除する権限がありません"
);
});
it("自分の記事は削除できる", async () => {
vi.mocked(auth.api.getSession).mockResolvedValue(mockSessionFor("user-a"));
prismaMock.post.findUnique.mockResolvedValue({
id: 1,
authorId: "user-a", // 自分自身
title: "自分の記事",
content: "本文",
published: true,
coverImage: null,
createdAt: new Date(),
updatedAt: new Date(),
} as Post);
prismaMock.post.delete.mockResolvedValue({} as Post);
await expect(deletePost(1)).resolves.not.toThrow();
expect(prismaMock.post.delete).toHaveBeenCalledWith({
where: { id: 1 },
});
});
});prismaMock.post.findUnique.mockResolvedValue(...)で、実際のDBに接続せずに「このような記事が存在する」という状況を再現しています。
これにより、#13で実装した3段階チェック(ログイン確認→存在確認→所有者確認)が、意図したとおりに動いているかをミリ秒単位で確認できます。
実行してみましょう。
npm run test✓ app/posts/actions.test.ts (3)
✓ deletePost (3)
✓ ログインしていない場合はエラーになる
✓ 他人の記事を削除しようとするとエラーになる
✓ 自分の記事は削除できる
Test Files 1 passed (1)
Tests 3 passed (3)Playwrightをセットアップする
続いて、実際にブラウザを操作してテストするPlaywrightを導入します。
npm init playwright@latestいくつか質問されますが、TypeScriptを使う設定のまま進めて問題ありません。
完了すると、playwright.config.tsとtestsディレクトリが生成されます。
E2Eテストにおける「ログイン」の扱い
E2Eテストで一番の壁になるのが認証です。
実際のGoogleログイン画面を毎回自動操作するのは不安定ですし、Google側の利用規約にも抵触しかねません。
そこで、テスト環境専用の認証バイパスを用意するのが一般的な対処法です。
Better Authには標準で「メール/パスワード認証」の機能が組み込まれているので、これをテスト環境限定で有効化し、Googleの画面を経由せずにログインできるようにします。
// lib/auth.ts
import { betterAuth } from "better-auth";
import { prismaAdapter } from "better-auth/adapters/prisma";
import { nextCookies } from "better-auth/next-js";
import { prisma } from "@/lib/prisma";
const isTestEnv =
process.env.NODE_ENV === "test" || process.env.PLAYWRIGHT_TEST === "true";
export const auth = betterAuth({
database: prismaAdapter(prisma, {
provider: "postgresql",
}),
socialProviders: {
google: {
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
},
},
emailAndPassword: {
enabled: isTestEnv, // テスト環境限定で有効化
},
plugins: [nextCookies()],
});
isTestEnvの条件分岐により、本番環境ではemailAndPasswordは無効のままです。テスト用の抜け道を本番に持ち込まないための、重要な安全策です。
「投稿してから一覧に表示される」までをテストする
PlaywrightのstorageState機能を使うと、事前にログイン状態を作っておき、各テストで使い回すことができます。
ここでは、画面上のフォーム操作ではなく、Better Authが公開しているAPIエンドポイントに直接リクエストを送ることで、UIを介さずログイン状態を作ります。
ここから登場するファイルも、役割ごとに整理しておきます。
| ファイル | 役割 |
|---|---|
lib/auth.ts | テスト環境限定でemailAndPasswordを有効化する(Better Auth側の設定) |
tests/auth.setup.ts | 実際にログインし、その状態(クッキーなど)をplaywright/.auth/user.jsonに保存する |
playwright/.auth/user.json | 保存されたログイン状態。auth.setup.ts実行のたびに生成される(自分で書くファイルではない) |
playwright.config.ts | 開発サーバーの自動起動(webServer)、auth.setup.tsを先に実行する順序(dependencies)、保存したログイン状態を各テストに読み込ませる設定をまとめる |
tests/post.spec.ts | ログイン済みの状態から実行される、実際のテストケース |
流れとしては、auth.setup.tsが一度だけログインしてファイルに保存し、post.spec.tsをはじめとする各テストはそのファイルを読み込むだけでログイン済みからスタートする、という関係になっています。
// tests/auth.setup.ts
import { test as setup } from "@playwright/test";
const authFile = "playwright/.auth/user.json";
setup("認証済み状態を作成", async ({ playwright, baseURL }) => {
const context = await playwright.request.newContext({ baseURL });
// 初回のみ成功し、2回目以降はユーザーが既に存在するためエラーになるが無視してよい
await context
.post("/api/auth/sign-up/email", {
data: {
email: "test@example.com",
password: "test-password-1234",
name: "Test User",
},
})
.catch(() => {});
await context.post("/api/auth/sign-in/email", {
data: {
email: "test@example.com",
password: "test-password-1234",
},
});
await context.storageState({ path: authFile });
await context.dispose();
});// playwright.config.ts(抜粋)
import { defineConfig } from "@playwright/test";
export default defineConfig({
//・・・省略
webServer: {
command: "npm run dev",
url: "http://localhost:3000",
reuseExistingServer: !process.env.CI,
env: {
PLAYWRIGHT_TEST: "true",
},
},
use: {
baseURL: "http://localhost:3000",
},
projects: [
{ name: "setup", testMatch: /auth\.setup\.ts/ },
{
name: "chromium",
use: { storageState: "playwright/.auth/user.json" },
dependencies: ["setup"],
},
],
});webServerは、npx playwright test実行時にPlaywrightが自動的に開発サーバーを起動・停止してくれる設定です。
ここでenvにPLAYWRIGHT_TEST: "true"を渡しているのがポイントで、これによってlib/auth.tsのisTestEnvがtrueになり、emailAndPasswordが有効化されます。
これで、chromiumプロジェクトの各テストは、あらかじめログイン済みの状態からスタートします。
// tests/post.spec.ts
import { test, expect } from "@playwright/test";
test("記事を投稿すると一覧に表示される", async ({ page }) => {
await page.goto("/posts");
await page.getByRole("button", { name: "新しい記事を書く" }).click();
const title = `テスト記事 ${Date.now()}`;
await page.getByPlaceholder("タイトル").fill(title);
await page.getByPlaceholder("Markdownで本文を書けます").fill("これはテスト本文です。");
await page.getByRole("button", { name: "投稿する" }).click();
await expect(page.getByText(title)).toBeVisible();
});
test("他人の記事には編集ボタンが表示されない", async ({ page }) => {
// 事前にシードしておいた、別ユーザーの記事のIDを使う
await page.goto("/posts/999");
await expect(page.getByRole("button", { name: "編集" })).not.toBeVisible();
});1つ目のテストは、#11〜#14で実装したUI(モーダル・フォーム・一覧表示)が一通り繋がって動作しているかを確認しています。
2つ目は、#13のアクセス制御がUIレベルでも機能しているかの確認です。
実行してみましょう。
npx playwright test結果はターミナルにも表示されますが、実際の件数やworker数は作成したテストファイルの数やマシンのスペックによって変わるため、数字そのものよりもすべて緑のチェックが付いているかを見てください。
HTMLレポートを開くと、より確実に確認できます。
npx playwright show-report失敗した場合、Playwrightはデフォルトでスクリーンショットやトレースを残してくれるため、このレポートからどこで失敗したかを詳しく確認できます。
どこまでテストを書くべきか
すべての画面・すべての分岐を網羅しようとすると、テストの保守コスト自体が重荷になってしまいます。
実務では次のような優先順位で考えるとバランスが取りやすいです。
- お金や個人情報に関わる処理(今回でいう認証・権限チェック)は手厚く
- 主要なユーザーフロー(投稿・閲覧など、アプリの核となる操作)はE2Eで最低限カバー
- 見た目だけの微調整(ボタンの色など)は基本的にテスト不要
「バグが起きたら困る場所」から優先的にテストを書く、という考え方を持っておくと、限られた時間の中でも効果的にテストを整備できます。
まとめ
この記事では、以下を実装しました。
- Vitest +
vitest-mock-extendedで、Prismaをモック化してServer Actionsのロジックをテスト - #13の所有者チェックが正しく機能しているかをユニットテストで検証
- テスト環境限定のCredentialsプロバイダーで、E2Eテストにおける認証の壁を回避
- Playwrightで「投稿→一覧表示」「アクセス制御」のユーザーフローをテスト
これで、今後コードを変更した際にも、既存の機能が壊れていないかを自動で確認できる体制が整いました。
次回の#16 パフォーマンス最適化 — 画像・フォント・バンドルサイズでは、ここまで作ってきたアプリを対象に、実際の表示速度やCore Web Vitalsを改善していきます。


























