Vitestの紹介記事は増えてきましたが、実務で困るのは「結局どう設計して、どう回すのが正解?」という点です。 特にNext.js(App Router)では、Server Components / Route Handlers / Server Actions などが入り、テストの切り方が複雑になりがちです。
この記事は、初心者にも理解できる説明をベースにしつつ、 実務チームがそのまま導入・運用できるように 設定・サンプル・落とし穴・考え方まで深掘りします。 長文ですが、読み終えたときに「どこから手を付ければよいか」が明確になる構成にしています。
テストがうまく回らないチームの典型は、「全部を同じ種類のテストでやろうとしている」ことです。 Next.jsには、大きく3つのレイヤーがあります。
Jestは成熟したテストランナーで、巨大エコシステムがあります。 一方、Next.jsの世界はESM・高速ビルド・App Router前提へ寄っています。 そのとき、Jestで起きがちな悩みは次のようなものです。
Vitestは、Viteの高速トランスパイルやESM思想と相性が良く、 「設定で苦しむ時間」を減らしやすいのがメリットです。
まずはReactコンポーネントをテストする前提で、Testing Libraryとjsdom環境を入れます。
npm install -D vitest jsdom @testing-library/react @testing-library/jest-dom
※ Next.jsがReactを持っているので、通常はreact/react-domを追加する必要はありません(すでに依存に含まれているため)。
import { defineConfig } from "vitest/config"
export default defineConfig({
test: {
environment: "jsdom",
globals: true,
setupFiles: "./vitest.setup.ts",
},
})
import "@testing-library/jest-dom"
Next.jsプロジェクトでは @/ をよく使います。
テストで import ... from "@/lib/..." が解決できないと、最初に詰まります。
最も確実なのは、Vite側にaliasを設定することです。
import { defineConfig } from "vitest/config"
import path from "node:path"
export default defineConfig({
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
test: {
environment: "jsdom",
globals: true,
setupFiles: "./vitest.setup.ts",
},
})
※ あなたのプロジェクトで src を使っていないなら、./ や ./app 等に合わせてください。
コンポーネントがCSS Modulesをimportしていると、テスト環境で失敗することがあります。 この場合は「スタイルはテストしない」方針で、スタブに逃がします。
import { render, screen } from "@testing-library/react"
import Page from "./page"
describe("Page", () => {
it("renders heading", () => {
render(<Page />)
expect(screen.getByRole("heading")).toBeInTheDocument()
})
})
ここで重要なのは、「画面の見た目そのもの」ではなく、 ユーザーが意味として認識する要素(role、text、label)で検証することです。 これがTesting Libraryの思想で、テストが壊れにくくなります。
実務では、クリック・入力・送信が多いです。
その場合は @testing-library/user-event を追加します。
npm install -D @testing-library/user-event
import { render, screen } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import LoginForm from "./LoginForm"
it("submits when valid", async () => {
render(<LoginForm />)
const user = userEvent.setup()
await user.type(screen.getByLabelText("Email"), "a@b.com")
await user.type(screen.getByLabelText("Password"), "pass1234")
await user.click(screen.getByRole("button", { name: "Sign in" }))
expect(screen.getByText("Signing in...")).toBeInTheDocument()
})
App RouterではServer Componentsがデフォルトです。 ただし、Server Componentsは「サーバー側で実行される」前提なので、 jsdom環境でそのままレンダリングしようとすると無理が出ます。
よくあるのは「DBから取得→整形→表示」です。
整形部分を lib/ に切り出すだけでも、テスト可能性が急に上がります。
// lib/formatUser.ts
export function formatUserName(first: string, last: string) {
return `${last} ${first}`.trim()
}
// lib/formatUser.test.ts
import { describe, it, expect } from "vitest"
import { formatUserName } from "./formatUser"
describe("formatUserName", () => {
it("formats last + first", () => {
expect(formatUserName("Taro", "Yamada")).toBe("Yamada Taro")
})
})
これだけでも、Server側の処理の品質を安定させられます。 UIは、Client Component側(フォーム等)を重点的にテストする戦略が取りやすいです。
Next.jsのRoute Handlers(app/api/**/route.ts)は、実務で重要です。
ここをテストできると、E2Eの負担を減らせます。
// app/api/hello/route.ts
export async function GET() {
return Response.json({ ok: true })
}
// app/api/hello/route.test.ts
import { GET } from "./route"
test("GET returns ok", async () => {
const res = await GET()
expect(res.status).toBe(200)
const body = await res.json()
expect(body.ok).toBe(true)
})
VitestはESM前提なので、モックの考え方が「静的importをどう差し替えるか」になります。 ここを理解すると、急に扱いやすくなります。
import { vi } from "vitest"
vi.mock("@/lib/db", () => ({
getUser: vi.fn().mockResolvedValue({ id: 1, name: "Test" }),
}))
初心者がやりがちなミスは、モックを書く位置やimport順序がバラバラになることです。 「モック→対象import」の順に統一すると事故が減ります。
App Routerでは useRouter や redirect を使います。
これらはテストでそのまま動かないことが多いので、モックします。
vi.mock("next/navigation", () => ({
useRouter: () => ({
push: vi.fn(),
replace: vi.fn(),
prefetch: vi.fn(),
}),
}))
※ 実際に使っているAPI(pushだけなど)に合わせて最小化してください。
フォーム送信や一覧取得のUIをテストする場合、 単純にAPI関数をモックするだけだと「実際の通信フロー」が検証できません。
そこで有効なのが MSW(Mock Service Worker) です。 テスト中だけ “擬似サーバー” を立てて、fetchリクエストに応答させられます。
npm install -D msw
// test/msw/handlers.ts
import { http, HttpResponse } from "msw"
export const handlers = [
http.get("/api/users", () => {
return HttpResponse.json([{ id: 1, name: "Alice" }])
}),
]
// test/msw/server.ts
import { setupServer } from "msw/node"
import { handlers } from "./handlers"
export const server = setupServer(...handlers)
// vitest.setup.ts
import "@testing-library/jest-dom"
import { server } from "./test/msw/server"
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
状態管理は「UIより先に壊れる」ことが多いので、ここを単体テストできると強いです。 Zustandなら store を直接叩けます。
import { useStore } from "@/store"
test("increments count", () => {
const before = useStore.getState().count
useStore.getState().increment()
expect(useStore.getState().count).toBe(before + 1)
})
Reduxでも同様に reducer / selector / thunk を単体で検証する発想が基本です。
テストが増えると「どこが未検証か」が分からなくなります。 そこでカバレッジをCIで出すと、チームが改善しやすくなります。
// vitest.config.ts(testの中)
coverage: {
reporter: ["text", "html"],
}
npx vitest run --coverage
ローカルでしか動かないテストは、長期的に壊れます。 CIで毎回実行される状態が「品質の最低ライン」です。
name: Test
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
- run: npm ci
- run: npx vitest run --coverage
※ Nodeのバージョンはプロジェクトに合わせて揃えてください(Next.jsの推奨に合わせるのが無難)。
「UIテストもE2Eで全部やればよいのでは?」と思いがちですが、E2Eは重く、壊れやすいです。 おすすめの棲み分けは次の通りです。
最近はCopilotやChatGPTでテストを生成できますが、 生成物は「動かしてフィードバックする」ことで品質が上がります。 Vitestは速いので、AI生成→実行→修正のループが現実的に回ります。
App Router時代の最大の変化は「Server側で動く処理が増えた」ことです。 Server Actions、RSC内のfetch、revalidate、cookies/headers などは、 正しく設計しないとテスト不能なブラックボックスになります。
よくある例:
// app/actions/createUser.ts
"use server"
import { db } from "@/lib/db"
export async function createUser(data: { name: string }) {
if (!data.name) {
throw new Error("Name required")
}
return db.user.create({ data })
}
このままではDB依存が強く、テストが難しいです。
改善例:
// lib/userService.ts
export function validateUserInput(name: string) {
if (!name) throw new Error("Name required")
return name.trim()
}
// app/actions/createUser.ts
"use server"
import { db } from "@/lib/db"
import { validateUserInput } from "@/lib/userService"
export async function createUser(data: { name: string }) {
const name = validateUserInput(data.name)
return db.user.create({ data: { name } })
}
これで、ビジネスロジックは単体テスト可能になります。
// lib/userService.test.ts
import { describe, it, expect } from "vitest"
import { validateUserInput } from "./userService"
describe("validateUserInput", () => {
it("throws when empty", () => {
expect(() => validateUserInput("")).toThrow()
})
it("trims whitespace", () => {
expect(validateUserInput(" Alice ")).toBe("Alice")
})
})
RSCではfetchにキャッシュ戦略を指定できます。
await fetch("https://api.example.com/data", {
cache: "no-store",
})
ここも同様に、直接fetchを呼ばずラップします。
// lib/apiClient.ts
export async function fetchData(fetchImpl = fetch) {
const res = await fetchImpl("https://api.example.com/data", {
cache: "no-store",
})
return res.json()
}
// lib/apiClient.test.ts
import { describe, it, expect, vi } from "vitest"
import { fetchData } from "./apiClient"
describe("fetchData", () => {
it("calls fetch and returns json", async () => {
const mockFetch = vi.fn().mockResolvedValue({
json: () => Promise.resolve({ ok: true }),
})
const result = await fetchData(mockFetch)
expect(result.ok).toBe(true)
})
})
revalidate系はNext.jsのランタイム依存が強いため、 直接テスト対象にするのではなく、呼び出し箇所を抽象化します。
// lib/cache.ts
export function triggerRevalidate(fn: Function) {
fn()
}
本番ではrevalidatePathを渡し、テストではmock関数を渡します。
RSC内で cookies() や headers() を使う場合、 それ自体を直接テストするのではなく、値を引数に渡す設計にします。
// lib/auth.ts
export function isAuthenticated(cookieValue: string | undefined) {
return Boolean(cookieValue)
}
// lib/auth.test.ts
import { describe, it, expect } from "vitest"
import { isAuthenticated } from "./auth"
describe("isAuthenticated", () => {
it("returns true when cookie exists", () => {
expect(isAuthenticated("token")).toBe(true)
})
})
これを守るだけで、App Router環境でも テスト不能なコードはほぼ消えます。
ここまで、Next.js(App Router)環境でのVitest活用を、 基礎から実務レベル、さらにServer Actions / RSC設計まで整理してきました。 最後に全体を体系化します。
Server ComponentsやServer Actionsは「直接テストする」のではなく、 内部ロジックを切り出して検証することが鍵です。
テストが自然に回る開発体制は、設計の健全さの証明でもあります。 Next.js × Vitest は、そのための合理的な基盤です。