mirror of
https://github.com/greedychipmunk/agent-skills.git
synced 2026-09-14 20:46:42 +08:00
11 KiB
11 KiB
Next.js Testing Patterns
Overview
This guide covers testing strategies for Next.js applications using Jest, React Testing Library, and Playwright.
Setup
Jest Configuration
// jest.config.js
const nextJest = require("next/jest")
const createJestConfig = nextJest({
dir: "./",
})
const customJestConfig = {
setupFilesAfterEnv: ["<rootDir>/jest.setup.js"],
testEnvironment: "jest-environment-jsdom",
moduleNameMapper: {
"^@/(.*)$": "<rootDir>/src/$1",
},
}
module.exports = createJestConfig(customJestConfig)
Jest Setup
// jest.setup.js
import "@testing-library/jest-dom"
Package Installation
npm install --save-dev jest jest-environment-jsdom @testing-library/react @testing-library/jest-dom
Component Testing
Basic Component Test
// components/button.test.tsx
import { render, screen, fireEvent } from "@testing-library/react"
import { Button } from "./button"
describe("Button", () => {
it("renders button with text", () => {
render(<Button>Click me</Button>)
expect(screen.getByRole("button")).toHaveTextContent("Click me")
})
it("calls onClick when clicked", () => {
const handleClick = jest.fn()
render(<Button onClick={handleClick}>Click me</Button>)
fireEvent.click(screen.getByRole("button"))
expect(handleClick).toHaveBeenCalledTimes(1)
})
it("is disabled when disabled prop is true", () => {
render(<Button disabled>Click me</Button>)
expect(screen.getByRole("button")).toBeDisabled()
})
})
Testing with User Events
import { render, screen } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { SearchInput } from "./search-input"
describe("SearchInput", () => {
it("updates value on user input", async () => {
const user = userEvent.setup()
const handleSearch = jest.fn()
render(<SearchInput onSearch={handleSearch} />)
const input = screen.getByRole("textbox")
await user.type(input, "hello")
expect(input).toHaveValue("hello")
})
it("submits on enter key", async () => {
const user = userEvent.setup()
const handleSearch = jest.fn()
render(<SearchInput onSearch={handleSearch} />)
await user.type(screen.getByRole("textbox"), "test{enter}")
expect(handleSearch).toHaveBeenCalledWith("test")
})
})
Testing Async Components
Server Component Testing
// app/posts/page.test.tsx
import { render, screen } from "@testing-library/react"
import PostsPage from "./page"
// Mock the data fetching
jest.mock("@/lib/db", () => ({
db: {
post: {
findMany: jest.fn().mockResolvedValue([
{ id: "1", title: "Post 1" },
{ id: "2", title: "Post 2" },
]),
},
},
}))
describe("PostsPage", () => {
it("renders posts from database", async () => {
const PostsPageComponent = await PostsPage()
render(PostsPageComponent)
expect(screen.getByText("Post 1")).toBeInTheDocument()
expect(screen.getByText("Post 2")).toBeInTheDocument()
})
})
Testing with Suspense
import { render, screen, waitFor } from "@testing-library/react"
import { Suspense } from "react"
import { PostList } from "./post-list"
describe("PostList", () => {
it("shows loading state then content", async () => {
render(
<Suspense fallback={<div>Loading...</div>}>
<PostList />
</Suspense>
)
expect(screen.getByText("Loading...")).toBeInTheDocument()
await waitFor(() => {
expect(screen.getByText("Post 1")).toBeInTheDocument()
})
})
})
Testing Hooks
Custom Hook Testing
// hooks/use-counter.test.ts
import { renderHook, act } from "@testing-library/react"
import { useCounter } from "./use-counter"
describe("useCounter", () => {
it("initializes with default value", () => {
const { result } = renderHook(() => useCounter())
expect(result.current.count).toBe(0)
})
it("initializes with custom value", () => {
const { result } = renderHook(() => useCounter(10))
expect(result.current.count).toBe(10)
})
it("increments count", () => {
const { result } = renderHook(() => useCounter())
act(() => {
result.current.increment()
})
expect(result.current.count).toBe(1)
})
it("decrements count", () => {
const { result } = renderHook(() => useCounter(5))
act(() => {
result.current.decrement()
})
expect(result.current.count).toBe(4)
})
})
API Route Testing
Route Handler Testing
// app/api/posts/route.test.ts
import { GET, POST } from "./route"
import { NextRequest } from "next/server"
// Mock the database
jest.mock("@/lib/db", () => ({
db: {
post: {
findMany: jest.fn(),
create: jest.fn(),
},
},
}))
import { db } from "@/lib/db"
describe("POST /api/posts", () => {
beforeEach(() => {
jest.clearAllMocks()
})
it("returns all posts", async () => {
const mockPosts = [
{ id: "1", title: "Post 1" },
{ id: "2", title: "Post 2" },
]
;(db.post.findMany as jest.Mock).mockResolvedValue(mockPosts)
const response = await GET()
const data = await response.json()
expect(response.status).toBe(200)
expect(data).toEqual(mockPosts)
})
it("creates a new post", async () => {
const newPost = { id: "3", title: "New Post" }
;(db.post.create as jest.Mock).mockResolvedValue(newPost)
const request = new NextRequest("http://localhost/api/posts", {
method: "POST",
body: JSON.stringify({ title: "New Post" }),
})
const response = await POST(request)
const data = await response.json()
expect(response.status).toBe(201)
expect(data).toEqual(newPost)
})
})
Server Actions Testing
Testing Server Actions
// app/actions.test.ts
import { createPost, deletePost } from "./actions"
import { revalidatePath } from "next/cache"
jest.mock("next/cache", () => ({
revalidatePath: jest.fn(),
}))
jest.mock("@/lib/db", () => ({
db: {
post: {
create: jest.fn(),
delete: jest.fn(),
},
},
}))
import { db } from "@/lib/db"
describe("Server Actions", () => {
beforeEach(() => {
jest.clearAllMocks()
})
describe("createPost", () => {
it("creates post and revalidates path", async () => {
const formData = new FormData()
formData.append("title", "Test Post")
formData.append("content", "Test content")
;(db.post.create as jest.Mock).mockResolvedValue({
id: "1",
title: "Test Post",
})
await createPost(formData)
expect(db.post.create).toHaveBeenCalledWith({
data: {
title: "Test Post",
content: "Test content",
},
})
expect(revalidatePath).toHaveBeenCalledWith("/posts")
})
})
describe("deletePost", () => {
it("deletes post by id", async () => {
await deletePost("1")
expect(db.post.delete).toHaveBeenCalledWith({
where: { id: "1" },
})
})
})
})
Integration Testing
Testing Page with Router
// app/posts/[id]/page.test.tsx
import { render, screen } from "@testing-library/react"
import PostPage from "./page"
jest.mock("next/navigation", () => ({
useRouter: () => ({
push: jest.fn(),
back: jest.fn(),
}),
useParams: () => ({ id: "1" }),
}))
jest.mock("@/lib/api", () => ({
getPost: jest.fn().mockResolvedValue({
id: "1",
title: "Test Post",
content: "Test content",
}),
}))
describe("PostPage", () => {
it("renders post content", async () => {
const PageComponent = await PostPage({ params: { id: "1" } })
render(PageComponent)
expect(screen.getByText("Test Post")).toBeInTheDocument()
expect(screen.getByText("Test content")).toBeInTheDocument()
})
})
E2E Testing with Playwright
Setup
// playwright.config.ts
import { defineConfig, devices } from "@playwright/test"
export default defineConfig({
testDir: "./e2e",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: "html",
use: {
baseURL: "http://localhost:3000",
trace: "on-first-retry",
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
],
webServer: {
command: "npm run dev",
url: "http://localhost:3000",
reuseExistingServer: !process.env.CI,
},
})
E2E Tests
// e2e/posts.spec.ts
import { test, expect } from "@playwright/test"
test.describe("Posts", () => {
test("displays posts list", async ({ page }) => {
await page.goto("/posts")
await expect(page.getByRole("heading", { name: "Posts" })).toBeVisible()
await expect(page.getByRole("listitem")).toHaveCount(3)
})
test("creates new post", async ({ page }) => {
await page.goto("/posts/new")
await page.getByLabel("Title").fill("New Post")
await page.getByLabel("Content").fill("Post content")
await page.getByRole("button", { name: "Create" }).click()
await expect(page).toHaveURL(/\/posts\/\d+/)
await expect(page.getByText("New Post")).toBeVisible()
})
test("navigates to post detail", async ({ page }) => {
await page.goto("/posts")
await page.getByRole("link", { name: "First Post" }).click()
await expect(page).toHaveURL("/posts/1")
await expect(page.getByText("First Post")).toBeVisible()
})
})
Mocking Patterns
Mocking Next.js Modules
// Mock next/navigation
jest.mock("next/navigation", () => ({
useRouter: () => ({
push: jest.fn(),
replace: jest.fn(),
back: jest.fn(),
forward: jest.fn(),
refresh: jest.fn(),
prefetch: jest.fn(),
}),
usePathname: () => "/current-path",
useSearchParams: () => new URLSearchParams("q=test"),
useParams: () => ({ id: "1" }),
}))
// Mock next/headers
jest.mock("next/headers", () => ({
cookies: () => ({
get: jest.fn(),
set: jest.fn(),
delete: jest.fn(),
}),
headers: () => new Headers(),
}))
Mocking Fetch
// Global fetch mock
global.fetch = jest.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ data: "mocked" }),
})
) as jest.Mock
// Per-test mock
beforeEach(() => {
;(fetch as jest.Mock).mockImplementation(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve([]),
})
)
})
Testing Best Practices
Guidelines
- Test behavior, not implementation
- Use meaningful test descriptions
- Keep tests independent
- Mock external dependencies
- Test error states and edge cases
File Organization
src/
├── components/
│ ├── button.tsx
│ └── button.test.tsx
├── app/
│ └── posts/
│ ├── page.tsx
│ └── page.test.tsx
└── e2e/
└── posts.spec.ts
Running Tests
# Unit tests
npm test
# Watch mode
npm test -- --watch
# Coverage
npm test -- --coverage
# E2E tests
npx playwright test
Last Updated: January 2026