面向会基本编程、刚开始学习 Next.js 的读者:先验证纯函数的输入输出,再验证组件和 Hook。单元测试不能证明服务端渲染、路由或完整应用构建成功。
单元测试是针对程序中最小可测试单元(如函数、方法、类)进行的测试。在Next.js中,单元测试主要用于测试:
- 工具函数(Utility Functions)
- React组件的渲染逻辑
- 自定义Hooks
- 业务逻辑函数
- 数据验证函数
- 代码质量保证: 确保代码按照预期工作
- 重构安全性: 在重构时提供安全网
- 文档作用: 测试用例作为代码的使用示例
- 快速反馈: 开发过程中快速发现问题
- 维护成本降低: 减少回归bug
# 安装Vitest
npm install -D vitest @vitest/ui jsdom @vitest/coverage-v8 @vitejs/plugin-react
在 package.json 的 scripts 中合并以下配置,保留已有的开发和构建命令:
{
"scripts": {
"test": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest --coverage"
}
}// vitest.config.ts
import { defineConfig } from "vitest/config"
import react from "@vitejs/plugin-react"
import path from "path"
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: "jsdom",
setupFiles: "./src/test/setup.ts",
},
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
})npm install -D @testing-library/react @testing-library/jest-dom @testing-library/user-event// src/test/setup.ts
import "@testing-library/jest-dom/vitest"
import { vi } from "vitest"
// 模拟next/router
vi.mock("next/router", () => ({
useRouter() {
return {
route: "/",
pathname: "/",
query: {},
asPath: "/",
push: vi.fn(),
pop: vi.fn(),
reload: vi.fn(),
back: vi.fn(),
prefetch: vi.fn().mockResolvedValue(undefined),
beforePopState: vi.fn(),
events: {
on: vi.fn(),
off: vi.fn(),
emit: vi.fn(),
},
isFallback: false,
}
},
}))
// 模拟next/navigation
vi.mock("next/navigation", () => ({
useRouter() {
return {
push: vi.fn(),
replace: vi.fn(),
back: vi.fn(),
forward: vi.fn(),
refresh: vi.fn(),
}
},
useSearchParams() {
return new URLSearchParams()
},
usePathname() {
return "/"
},
}))// src/lib/utils/date.ts
export function formatDate(date: Date | string): string {
const d = new Date(date)
if (Number.isNaN(d.getTime())) throw new RangeError("无效日期")
return d.toLocaleDateString("zh-CN", {
timeZone: "UTC", // 本例明确使用 UTC,避免测试随机器时区变化
year: "numeric",
month: "long",
day: "numeric",
})
}
export function isValidEmail(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
return emailRegex.test(email)
}
export function truncateText(text: string, maxLength: number): string {
if (!Number.isInteger(maxLength) || maxLength < 0) {
throw new RangeError("maxLength 必须是非负整数")
}
const characters = Array.from(text)
if (characters.length <= maxLength) return text
return characters.slice(0, maxLength).join("") + "..."
}
export function generateSlug(text: string): string {
return text
.trim()
.toLowerCase()
.replace(/[^\w\s-]/g, "")
.replace(/\s+/g, "-")
.replace(/-+/g, "-")
.replace(/^-+|-+$/g, "")
}这里明确选择 UTC 日期与 Unicode 码点截断:maxLength 不含后缀的三个点;码点仍不等于人眼看到的完整字素,例如家庭 emoji 可能由多个码点组成。需要按显示字符截断时使用 Intl.Segmenter 并单独测试。邮箱正则只做基础格式提示,不证明地址存在。slug 函数只支持 ASCII 标识,中文标题可能得到空串;保存前必须拒绝空串并在服务端处理重复值。
// __tests__/utils/date.test.ts
import { formatDate, isValidEmail, truncateText, generateSlug } from "@/lib/utils/date"
describe("Date Utils", () => {
describe("formatDate", () => {
it("应该正确格式化日期", () => {
const date = new Date("2024-01-15")
const result = formatDate(date)
expect(result).toBe("2024年1月15日")
})
it("应该能处理字符串日期", () => {
const result = formatDate("2024-01-15")
expect(result).toBe("2024年1月15日")
})
it("跨年时使用 UTC 日期,不取设备本地年份", () => {
expect(formatDate("2025-01-01T01:00:00+08:00")).toBe("2024年12月31日")
expect(() => formatDate("not-a-date")).toThrow(RangeError)
})
})
describe("isValidEmail", () => {
it("应该验证有效的邮箱地址", () => {
expect(isValidEmail("test@example.com")).toBe(true)
expect(isValidEmail("user.name@domain.co.uk")).toBe(true)
expect(isValidEmail("user+tag@example.org")).toBe(true)
})
it("应该拒绝无效的邮箱地址", () => {
expect(isValidEmail("invalid-email")).toBe(false)
expect(isValidEmail("@example.com")).toBe(false)
expect(isValidEmail("test@")).toBe(false)
expect(isValidEmail("")).toBe(false)
})
})
describe("truncateText", () => {
it("不应该截断短文本", () => {
const text = "短文本"
const result = truncateText(text, 10)
expect(result).toBe(text)
})
it("应该正确截断长文本", () => {
const text = "这是一个很长的文本,需要被截断"
const result = truncateText(text, 10)
expect(result).toBe("这是一个很长的文本,...")
expect(result.length).toBeLessThanOrEqual(13) // 10 + "..."
})
it("按码点处理 emoji,拒绝非法长度", () => {
expect(truncateText("😀ab", 1)).toBe("😀...")
expect(truncateText("a", 0)).toBe("...")
expect(() => truncateText("abc", -1)).toThrow(RangeError)
expect(() => truncateText("abc", 1.5)).toThrow(RangeError)
})
})
describe("generateSlug", () => {
it("应该生成正确的slug", () => {
expect(generateSlug("Hello World")).toBe("hello-world")
expect(generateSlug("React & Next.js")).toBe("react-nextjs")
expect(generateSlug(" Spaces ")).toBe("spaces")
expect(generateSlug("Multiple---Dashes")).toBe("multiple-dashes")
expect(generateSlug("--Hello--")).toBe("hello")
})
})
})// src/lib/validation/schemas.ts
import { z } from "zod"
export const userSchema = z.object({
name: z.string().min(2, "姓名至少需要2个字符").max(50, "姓名不能超过50个字符"),
email: z.string().email("请输入有效的邮箱地址"),
age: z.number().int("年龄必须是整数").min(18, "年龄必须至少18岁").max(120, "年龄不能超过120岁"),
bio: z.string().max(500, "个人简介不能超过500字符").optional(),
website: z.string().url("请输入有效的网址").optional().or(z.literal("")),
})
export const postSchema = z.object({
title: z.string().trim().min(1, "标题不能为空").max(100, "标题不能超过100字符"),
content: z.string().trim().min(1, "内容不能为空").max(10000, "内容不能超过10000字符"),
tags: z.array(z.string()).max(5, "最多添加5个标签"),
published: z.boolean().default(false),
})
export type UserInput = z.infer<typeof userSchema>
export type PostInput = z.infer<typeof postSchema>// __tests__/validation/schemas.test.ts
import { userSchema, postSchema } from "@/lib/validation/schemas"
describe("Validation Schemas", () => {
describe("userSchema", () => {
it("应该验证有效的用户数据", () => {
const validUser = {
name: "张三",
email: "zhangsan@example.com",
age: 25,
bio: "前端开发者",
website: "https://example.com",
}
const result = userSchema.safeParse(validUser)
expect(result.success).toBe(true)
})
it("应该拒绝无效的邮箱", () => {
const invalidUser = {
name: "张三",
email: "invalid-email",
age: 25,
}
const result = userSchema.safeParse(invalidUser)
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error.issues[0].message).toContain("邮箱")
}
})
it("应该验证年龄范围", () => {
const youngUser = {
name: "小明",
email: "xiaoming@example.com",
age: 15, // 太年轻
}
const result = userSchema.safeParse(youngUser)
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error.issues[0].message).toContain("年龄")
}
})
it("应该允许可选字段为空", () => {
const minimalUser = {
name: "李四",
email: "lisi@example.com",
age: 30,
}
const result = userSchema.safeParse(minimalUser)
expect(result.success).toBe(true)
})
})
describe("postSchema", () => {
it("应该验证有效的帖子数据", () => {
const validPost = {
title: "我的第一篇博客",
content: "这是博客内容...",
tags: ["React", "Next.js"],
published: true,
}
const result = postSchema.safeParse(validPost)
expect(result.success).toBe(true)
})
it("应该拒绝空标题", () => {
const invalidPost = {
title: "",
content: "内容...",
tags: [],
}
const result = postSchema.safeParse(invalidPost)
expect(result.success).toBe(false)
})
it("应该限制标签数量", () => {
const postWithManyTags = {
title: "标签测试",
content: "内容...",
tags: ["标签1", "标签2", "标签3", "标签4", "标签5", "标签6"], // 6个标签
}
const result = postSchema.safeParse(postWithManyTags)
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error.issues[0].message).toContain("标签")
}
})
})
})// src/components/ui/button.tsx
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, ...props }, ref) => {
return (
<button
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }// __tests__/components/ui/button.test.tsx
import { render, screen } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { Button } from "@/components/ui/button"
describe("Button Component", () => {
it("应该渲染默认样式的按钮", () => {
render(<Button>点击我</Button>)
const button = screen.getByRole("button", { name: /点击我/i })
expect(button).toBeInTheDocument()
expect(button).toHaveClass("bg-primary")
})
it("应该支持不同的变体", () => {
const { rerender } = render(<Button variant="destructive">删除</Button>)
let button = screen.getByRole("button", { name: /删除/i })
expect(button).toHaveClass("bg-destructive")
rerender(<Button variant="outline">边框</Button>)
button = screen.getByRole("button", { name: /边框/i })
expect(button).toHaveClass("border")
})
it("应该支持不同的尺寸", () => {
const { rerender } = render(<Button size="sm">小按钮</Button>)
let button = screen.getByRole("button", { name: /小按钮/i })
expect(button).toHaveClass("h-9")
rerender(<Button size="lg">大按钮</Button>)
button = screen.getByRole("button", { name: /大按钮/i })
expect(button).toHaveClass("h-11")
})
it("应该正确处理点击事件", async () => {
const handleClick = vi.fn()
const user = userEvent.setup()
render(<Button onClick={handleClick}>点击我</Button>)
const button = screen.getByRole("button", { name: /点击我/i })
await user.click(button)
expect(handleClick).toHaveBeenCalledTimes(1)
})
it("应该支持禁用状态", () => {
render(<Button disabled>禁用按钮</Button>)
const button = screen.getByRole("button", { name: /禁用按钮/i })
expect(button).toBeDisabled()
expect(button).toHaveClass("disabled:opacity-50")
})
it("应该支持自定义className", () => {
render(<Button className="custom-class">自定义</Button>)
const button = screen.getByRole("button", { name: /自定义/i })
expect(button).toHaveClass("custom-class")
})
it("应该正确传递额外的props", () => {
render(<Button data-testid="custom-button">测试</Button>)
const button = screen.getByTestId("custom-button")
expect(button).toBeInTheDocument()
})
})// src/components/forms/contact-form.tsx
"use client"
import { useState } from "react"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import * as z from "zod"
import { Button } from "@/components/ui/button"
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { toast } from "react-hot-toast"
const contactSchema = z.object({
name: z.string().min(2, "姓名至少需要2个字符"),
email: z.string().email("请输入有效的邮箱地址"),
subject: z.string().min(5, "主题至少需要5个字符"),
message: z.string().min(10, "消息至少需要10个字符"),
})
type ContactFormData = z.infer<typeof contactSchema>
interface ContactFormProps {
onSubmit: (data: ContactFormData) => Promise<void>
}
export function ContactForm({ onSubmit }: ContactFormProps) {
const [isSubmitting, setIsSubmitting] = useState(false)
const form = useForm<ContactFormData>({
resolver: zodResolver(contactSchema),
defaultValues: {
name: "",
email: "",
subject: "",
message: "",
},
})
const handleSubmit = async (data: ContactFormData) => {
setIsSubmitting(true)
try {
await onSubmit(data)
form.reset()
toast.success("消息已发送!")
} catch (error) {
toast.error("发送失败,请重试")
} finally {
setIsSubmitting(false)
}
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>姓名</FormLabel>
<FormControl>
<Input placeholder="您的姓名" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>邮箱</FormLabel>
<FormControl>
<Input type="email" placeholder="your@email.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="subject"
render={({ field }) => (
<FormItem>
<FormLabel>主题</FormLabel>
<FormControl>
<Input placeholder="消息主题" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="message"
render={({ field }) => (
<FormItem>
<FormLabel>消息</FormLabel>
<FormControl>
<Textarea
placeholder="您的消息..."
className="min-h-[100px]"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" disabled={isSubmitting} className="w-full">
{isSubmitting ? "发送中..." : "发送消息"}
</Button>
</form>
</Form>
)
}// __tests__/components/forms/contact-form.test.tsx
import { render, screen, waitFor } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { ContactForm } from "@/components/forms/contact-form"
import { toast } from "react-hot-toast"
// 模拟toast
vi.mock("react-hot-toast", () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
},
}))
describe("ContactForm Component", () => {
const mockOnSubmit = vi.fn()
beforeEach(() => {
mockOnSubmit.mockClear()
vi.clearAllMocks()
})
it("应该渲染所有表单字段", () => {
render(<ContactForm onSubmit={mockOnSubmit} />)
expect(screen.getByLabelText(/姓名/i)).toBeInTheDocument()
expect(screen.getByLabelText(/邮箱/i)).toBeInTheDocument()
expect(screen.getByLabelText(/主题/i)).toBeInTheDocument()
expect(screen.getByLabelText(/消息/i)).toBeInTheDocument()
expect(screen.getByRole("button", { name: /发送消息/i })).toBeInTheDocument()
})
it("应该显示验证错误信息", async () => {
const user = userEvent.setup()
render(<ContactForm onSubmit={mockOnSubmit} />)
// 尝试提交空表单
await user.click(screen.getByRole("button", { name: /发送消息/i }))
// 等待验证错误显示
await waitFor(() => {
expect(screen.getByText("姓名至少需要2个字符")).toBeInTheDocument()
expect(screen.getByText("请输入有效的邮箱地址")).toBeInTheDocument()
expect(screen.getByText("主题至少需要5个字符")).toBeInTheDocument()
expect(screen.getByText("消息至少需要10个字符")).toBeInTheDocument()
})
// 不应该调用onSubmit
expect(mockOnSubmit).not.toHaveBeenCalled()
})
it("应该在输入正确数据时成功提交", async () => {
const user = userEvent.setup()
const mockPromise = Promise.resolve()
mockOnSubmit.mockReturnValue(mockPromise)
render(<ContactForm onSubmit={mockOnSubmit} />)
// 填写表单
await user.type(screen.getByLabelText(/姓名/i), "张三")
await user.type(screen.getByLabelText(/邮箱/i), "zhangsan@example.com")
await user.type(screen.getByLabelText(/主题/i), "咨询产品")
await user.type(screen.getByLabelText(/消息/i), "我想了解更多关于您的产品信息")
// 提交表单
await user.click(screen.getByRole("button", { name: /发送消息/i }))
// 等待提交
await waitFor(() => {
expect(mockOnSubmit).toHaveBeenCalledWith({
name: "张三",
email: "zhangsan@example.com",
subject: "咨询产品",
message: "我想了解更多关于您的产品信息",
})
})
// 显示成功消息
await waitFor(() => {
expect(toast.success).toHaveBeenCalledWith("消息已发送!")
})
})
it("应该处理提交错误", async () => {
const user = userEvent.setup()
const mockError = new Error("提交失败")
mockOnSubmit.mockRejectedValue(mockError)
render(<ContactForm onSubmit={mockOnSubmit} />)
// 填写表单
await user.type(screen.getByLabelText(/姓名/i), "张三")
await user.type(screen.getByLabelText(/邮箱/i), "zhangsan@example.com")
await user.type(screen.getByLabelText(/主题/i), "咨询产品")
await user.type(screen.getByLabelText(/消息/i), "我想了解更多关于您的产品信息")
// 提交表单
await user.click(screen.getByRole("button", { name: /发送消息/i }))
// 显示错误消息
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith("发送失败,请重试")
})
})
it("应该在提交时禁用按钮", async () => {
const user = userEvent.setup()
const mockPromise = new Promise(() => {}) // 永不resolve的promise
mockOnSubmit.mockReturnValue(mockPromise)
render(<ContactForm onSubmit={mockOnSubmit} />)
// 填写表单
await user.type(screen.getByLabelText(/姓名/i), "张三")
await user.type(screen.getByLabelText(/邮箱/i), "zhangsan@example.com")
await user.type(screen.getByLabelText(/主题/i), "咨询产品")
await user.type(screen.getByLabelText(/消息/i), "我想了解更多关于您的产品信息")
// 提交表单
await user.click(screen.getByRole("button", { name: /发送消息/i }))
// 按钮应该被禁用
expect(screen.getByRole("button", { name: /发送中/i })).toBeDisabled()
})
it("应该在成功提交后重置表单", async () => {
const user = userEvent.setup()
const mockPromise = Promise.resolve()
mockOnSubmit.mockReturnValue(mockPromise)
render(<ContactForm onSubmit={mockOnSubmit} />)
// 填写表单
const nameInput = screen.getByLabelText(/姓名/i)
await user.type(nameInput, "张三")
// 提交表单
await user.click(screen.getByRole("button", { name: /发送消息/i }))
// 等待提交完成
await waitFor(() => {
expect(nameInput).toHaveValue("") // 表单应该被重置
})
})
})// src/hooks/use-local-storage.ts
import { useState, useRef } from "react"
export function useLocalStorage<T>(key: string, initialValue: T) {
const [storedValue, setStoredValue] = useState<T>(() => {
if (typeof window === "undefined") {
return initialValue
}
try {
const item = window.localStorage.getItem(key)
return item ? JSON.parse(item) : initialValue
} catch (error) {
console.error(`Error reading localStorage key "${key}":`, error)
return initialValue
}
})
const latest = useRef(storedValue)
const setValue = (value: T | ((val: T) => T)) => {
try {
const valueToStore = value instanceof Function ? value(latest.current) : value
if (typeof window !== "undefined") {
window.localStorage.setItem(key, JSON.stringify(valueToStore))
}
latest.current = valueToStore
setStoredValue(valueToStore)
} catch (error) {
console.error(`Error setting localStorage key "${key}":`, error)
}
}
return [storedValue, setValue] as const
}// __tests__/hooks/use-local-storage.test.tsx
import { renderHook, act } from "@testing-library/react"
import { useLocalStorage } from "@/hooks/use-local-storage"
describe("useLocalStorage", () => {
beforeEach(() => {
// 清理localStorage
localStorage.clear()
// 模拟window对象
Object.defineProperty(window, "localStorage", {
value: {
getItem: vi.fn(),
setItem: vi.fn(),
removeItem: vi.fn(),
clear: vi.fn(),
},
writable: true,
})
})
it("应该返回初始值当localStorage为空时", () => {
const { result } = renderHook(() => useLocalStorage("test-key", "initial-value"))
expect(result.current[0]).toBe("initial-value")
})
it("应该从localStorage读取值", () => {
const storedValue = JSON.stringify("stored-value")
vi.spyOn(localStorage, "getItem").mockReturnValue(storedValue)
const { result } = renderHook(() => useLocalStorage("test-key", "initial-value"))
expect(result.current[0]).toBe("stored-value")
expect(localStorage.getItem).toHaveBeenCalledWith("test-key")
})
it("应该设置值到localStorage", () => {
const { result } = renderHook(() => useLocalStorage("test-key", "initial-value"))
const [, setValue] = result.current
act(() => {
setValue("new-value")
})
expect(result.current[0]).toBe("new-value")
expect(localStorage.setItem).toHaveBeenCalledWith("test-key", JSON.stringify("new-value"))
})
it("应该支持函数式更新", () => {
const { result } = renderHook(() => useLocalStorage("counter", 0))
const [, setCounter] = result.current
act(() => {
setCounter(prev => prev + 1)
})
expect(result.current[0]).toBe(1)
expect(localStorage.setItem).toHaveBeenCalledWith("counter", JSON.stringify(1))
})
it("应该处理localStorage错误", () => {
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {})
vi.spyOn(localStorage, "getItem").mockImplementation(() => {
throw new Error("Storage error")
})
const { result } = renderHook(() => useLocalStorage("test-key", "fallback-value"))
expect(result.current[0]).toBe("fallback-value")
expect(consoleSpy).toHaveBeenCalled()
consoleSpy.mockRestore()
})
it("同一批次的函数式更新读取最新值", () => {
const { result } = renderHook(() => useLocalStorage("counter", 0))
act(() => {
result.current[1](n => n + 1)
result.current[1](n => n + 1)
})
expect(result.current[0]).toBe(2)
expect(localStorage.setItem).toHaveBeenLastCalledWith("counter", "2")
})
it("保存失败时不把未保存的数据显示为已保存", () => {
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {})
vi.spyOn(localStorage, "setItem").mockImplementation(() => { throw new Error("quota") })
const { result } = renderHook(() => useLocalStorage("test-key", "initial"))
act(() => result.current[1]("new"))
expect(result.current[0]).toBe("initial")
consoleSpy.mockRestore()
})
})服务器测试应放在独立的 Node 测试环境,并使用服务端渲染器。删除 jsdom 的 window 后仍调用浏览器 renderHook,会破坏测试工具自身,并不能模拟 SSR。
// @vitest-environment node
// __tests__/hooks/use-local-storage.server.test.ts
import { createElement } from "react"
import { renderToString } from "react-dom/server"
import { expect, it } from "vitest"
import { useLocalStorage } from "@/hooks/use-local-storage"
it("服务端渲染返回初始值", () => {
function Probe() {
const [value] = useLocalStorage("test-key", "server-value")
return createElement("span", null, value)
}
expect(renderToString(createElement(Probe))).toBe("<span>server-value</span>")
})这个测试只覆盖服务器分支,不证明 hydration 一致。上面的 Hook 是固定 key、JSON 可序列化值的客户端练习:它在浏览器首次渲染就读存储,因此不要直接用于服务端预渲染且显示存储值的页面。完整 Next.js 版本应先渲染相同初始值,挂载后读取并显式处理加载、损坏数据和写入失败;需要切换 key 或同步多个标签页时还须补充相应状态机。写入失败时当前示例保留旧状态并记录错误,正式界面应展示可重试错误提示。
// src/hooks/use-debounce.ts
import { useState, useEffect } from "react"
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value)
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value)
}, delay)
return () => {
clearTimeout(handler)
}
}, [value, delay])
return debouncedValue
}// __tests__/hooks/use-debounce.test.tsx
import { renderHook, act } from "@testing-library/react"
import { useDebounce } from "@/hooks/use-debounce"
import { vi, beforeEach, afterEach } from "vitest"
describe("useDebounce", () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it("应该立即返回初始值", () => {
const { result } = renderHook(() => useDebounce("test", 500))
expect(result.current).toBe("test")
})
it("应该在延迟后更新值", () => {
const { result, rerender } = renderHook(
({ value, delay }) => useDebounce(value, delay),
{ initialProps: { value: "initial", delay: 500 } }
)
// 更新值
rerender({ value: "updated", delay: 500 })
// 延迟前应该还是旧值
expect(result.current).toBe("initial")
// 快进时间
act(() => {
vi.advanceTimersByTime(500)
})
// 延迟后应该更新为新值
expect(result.current).toBe("updated")
})
it("应该在多次更新时只使用最后一次值", () => {
const { result, rerender } = renderHook(
({ value, delay }) => useDebounce(value, delay),
{ initialProps: { value: "initial", delay: 500 } }
)
// 多次快速更新
rerender({ value: "update1", delay: 500 })
rerender({ value: "update2", delay: 500 })
rerender({ value: "update3", delay: 500 })
// 快进时间
act(() => {
vi.advanceTimersByTime(500)
})
// 应该只有最后一次更新生效
expect(result.current).toBe("update3")
})
it("应该在组件卸载时清除定时器", () => {
const { result, rerender, unmount } = renderHook(
({ value, delay }) => useDebounce(value, delay),
{ initialProps: { value: "initial", delay: 500 } }
)
const clearTimeoutSpy = vi.spyOn(global, "clearTimeout")
// 更新值
rerender({ value: "updated", delay: 500 })
// 卸载组件
unmount()
// 应该清除定时器
expect(clearTimeoutSpy).toHaveBeenCalled()
clearTimeoutSpy.mockRestore()
})
})// src/lib/api/user.ts
import { prisma } from "@/lib/prisma"
import { userSchema } from "@/lib/validation/schemas"
export async function createUser(data: {
name: string
email: string
age: number
bio?: string
}) {
const validatedData = userSchema.parse(data)
const user = await prisma.user.create({
data: {
name: validatedData.name,
email: validatedData.email,
age: validatedData.age,
bio: validatedData.bio,
},
})
return user
}
export async function getUserById(id: string) {
const user = await prisma.user.findUnique({
where: { id },
select: {
id: true,
name: true,
email: true,
age: true,
bio: true,
createdAt: true,
updatedAt: true,
},
})
if (!user) {
throw new Error("用户不存在")
}
return user
}
export async function updateUser(id: string, data: {
name?: string
email?: string
age?: number
bio?: string
}) {
const existingUser = await prisma.user.findUnique({
where: { id },
})
if (!existingUser) {
throw new Error("用户不存在")
}
const updatedUser = await prisma.user.update({
where: { id },
data,
})
return updatedUser
}
export async function deleteUser(id: string) {
const existingUser = await prisma.user.findUnique({
where: { id },
})
if (!existingUser) {
throw new Error("用户不存在")
}
await prisma.user.delete({
where: { id },
})
return { success: true }
}// __tests__/lib/api/user.test.ts
import { createUser, getUserById, updateUser, deleteUser } from "@/lib/api/user"
import { prisma } from "@/lib/prisma"
// 模拟Prisma
vi.mock("@/lib/prisma", () => ({
prisma: {
user: {
create: vi.fn(),
findUnique: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
},
}))
describe("User API", () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe("createUser", () => {
it("应该成功创建用户", async () => {
const mockUser = {
id: "1",
name: "张三",
email: "zhangsan@example.com",
age: 25,
bio: "测试用户",
createdAt: new Date(),
updatedAt: new Date(),
}
vi.mocked(prisma.user.create).mockResolvedValue(mockUser)
const result = await createUser({
name: "张三",
email: "zhangsan@example.com",
age: 25,
bio: "测试用户",
})
expect(result).toEqual(mockUser)
expect(prisma.user.create).toHaveBeenCalledWith({
data: {
name: "张三",
email: "zhangsan@example.com",
age: 25,
bio: "测试用户",
},
})
})
it("应该验证输入数据", async () => {
const invalidData = {
name: "", // 无效的姓名
email: "invalid-email", // 无效的邮箱
age: 15, // 无效的年龄
}
await expect(createUser(invalidData as any)).rejects.toThrow()
expect(prisma.user.create).not.toHaveBeenCalled()
})
})
describe("getUserById", () => {
it("应该返回存在的用户", async () => {
const mockUser = {
id: "1",
name: "张三",
email: "zhangsan@example.com",
age: 25,
bio: "测试用户",
createdAt: new Date(),
updatedAt: new Date(),
}
vi.mocked(prisma.user.findUnique).mockResolvedValue(mockUser)
const result = await getUserById("1")
expect(result).toEqual(mockUser)
expect(prisma.user.findUnique).toHaveBeenCalledWith({
where: { id: "1" },
select: {
id: true,
name: true,
email: true,
age: true,
bio: true,
createdAt: true,
updatedAt: true,
},
})
})
it("应该为不存在的用户抛出错误", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue(null)
await expect(getUserById("non-existent")).rejects.toThrow("用户不存在")
})
})
describe("updateUser", () => {
it("应该成功更新用户", async () => {
const existingUser = {
id: "1",
name: "张三",
email: "zhangsan@example.com",
age: 25,
bio: "测试用户",
}
const updatedUser = {
...existingUser,
name: "李四",
age: 26,
}
vi.mocked(prisma.user.findUnique).mockResolvedValue(existingUser)
vi.mocked(prisma.user.update).mockResolvedValue(updatedUser)
const result = await updateUser("1", {
name: "李四",
age: 26,
})
expect(result).toEqual(updatedUser)
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "1" },
data: {
name: "李四",
age: 26,
},
})
})
it("应该为不存在的用户抛出错误", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue(null)
await expect(updateUser("non-existent", { name: "新名字" })).rejects.toThrow("用户不存在")
})
})
describe("deleteUser", () => {
it("应该成功删除用户", async () => {
const existingUser = {
id: "1",
name: "张三",
email: "zhangsan@example.com",
age: 25,
}
vi.mocked(prisma.user.findUnique).mockResolvedValue(existingUser)
vi.mocked(prisma.user.delete).mockResolvedValue(existingUser)
const result = await deleteUser("1")
expect(result).toEqual({ success: true })
expect(prisma.user.delete).toHaveBeenCalledWith({
where: { id: "1" },
})
})
it("应该为不存在的用户抛出错误", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue(null)
await expect(deleteUser("non-existent")).rejects.toThrow("用户不存在")
})
})
})__tests__/
├── components/
│ ├── ui/
│ │ ├── button.test.tsx
│ │ ├── input.test.tsx
│ │ └── ...
│ ├── forms/
│ │ ├── contact-form.test.tsx
│ │ └── ...
│ └── layout/
│ ├── header.test.tsx
│ └── ...
├── hooks/
│ ├── use-local-storage.test.tsx
│ ├── use-debounce.test.tsx
│ └── ...
├── lib/
│ ├── utils/
│ │ ├── date.test.ts
│ │ └── ...
│ ├── api/
│ │ ├── user.test.ts
│ │ └── ...
│ └── validation/
│ └── schemas.test.ts
└── e2e/
└── ...
// 好的测试命名
describe("Button Component", () => {
it("应该渲染默认样式的按钮", () => {
// 测试代码
})
it("应该在点击时触发onClick事件", () => {
// 测试代码
})
it("应该正确处理禁用状态", () => {
// 测试代码
})
})
// 不好的测试命名
describe("Button", () => {
it("test button render", () => {
// 测试代码
})
it("click works", () => {
// 测试代码
})
})// vitest.config.ts
import { defineConfig } from "vitest/config"
export default defineConfig({
test: {
coverage: {
provider: "v8",
reporter: ["text", "json", "html"],
include: ["src/**/*"],
exclude: [
"src/**/*.d.ts",
"src/**/*.stories.tsx",
"src/test/**/*",
],
thresholds: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
},
},
})// src/test/utils.ts
import { render, RenderOptions } from "@testing-library/react"
import { ReactElement } from "react"
// 自定义render函数
export function renderWithProviders(
ui: ReactElement,
options?: Omit<RenderOptions, "wrapper">
) {
return render(ui, options)
}
// 创建模拟数据
export function createMockUser(overrides?: Partial<User>) {
return {
id: "1",
name: "测试用户",
email: "test@example.com",
age: 25,
bio: "这是一个测试用户",
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
}
}
// 等待异步操作
export async function waitForLoadingToFinish() {
return await new Promise(resolve => setTimeout(resolve, 0))
}通过本指南,我们学习了Next.js项目中单元测试的各个方面:
- 单元测试的基本概念和重要性
- 现代测试工具栈的选择和配置
- 测试环境的搭建和最佳实践
- 工具函数的单元测试
- React组件的渲染和交互测试
- 自定义Hooks的状态管理测试
- 异步函数和API调用的测试
- 测试组织和命名规范
- Mock和Stub的使用
- 异步测试的处理
- 测试覆盖率的配置
- 从PHPUnit到Vitest的转变
- 前端测试的特殊考虑
- 组件测试与单元测试的区别
前置是纯函数、Promise 与 TypeScript。先定义输入输出契约,再挑能区分正确和错误实现的案例。例如 slug 前后空格应被移除,而不是先替换成连字符后留下 '-spaces-';日期格式必须约定时区,否则 UTC 午夜在其他机器可能是前一天。测试不能把开发机的默认环境当成业务规则。
把纯函数测试放在 node 环境,需要 DOM 的组件测试才用 jsdom。Vitest 转译 TypeScript 不等于完整类型检查,CI 另外执行 tsc --noEmit。异步测试必须 await 返回的 Promise;只调用函数不等待,测试可能在断言前就结束。
练习:对 generateSlug 增加前后空格、连续连字符、中文标题三个输入,先决定是否支持 Unicode,再记录期望。让日期测试在两个时区执行,输出应遵守显式约定。临时引入一个已知缺陷,确认对应测试变红;覆盖率高但对缺陷不敏感,仍不是有效测试。
依据:Vitest 与 Next、Vitest 类型检查。本文历史片段未全量执行,需在练习项目中补齐组件和依赖后运行。
- 端到端测试指南 - 学习完整的E2E测试策略和实践
- JavaScript 测试基础 - JavaScript测试的基础概念
- TypeScript 类型检查 - TypeScript在测试中的应用
- React Hooks 深入理解 - React Hooks的测试原理
- 测试边界:单元测试验证一个函数、组件或 Hook 的可观察契约;浏览器导航、第三方系统和完整登录链路留给集成或 E2E 测试。测试金字塔是分配成本的依据,不是必须达到的覆盖率比例。
- 用户可见行为:用 Testing Library 按角色、标签和文本查找,再触发真实用户动作并断言可见结果;避免把组件内部 state、私有函数或 CSS 类名当作主要断言对象。
- 异步与清理:等待界面最终状态而不是固定 sleep;每个 mock、定时器、环境变量和网络处理器在用例间恢复,避免通过顺序才会出现的污染。
- 依赖替身:只替换测试不关心的边界(时间、网络、存储或昂贵服务),并为替身写出输入和返回约定;如果 mock 与真实接口形状不同,测试通过也不代表集成可用。
- 覆盖率的用途:把覆盖率报告作为“哪些路径尚未执行”的提示,针对权限拒绝、网络失败、空数据和重试等风险补测试;不以凑百分比替代断言质量。
- 为一个表单或列表组件写出“初始、成功、失败”三个用户可见状态,运行测试并保存通过输出。
- 把一个断言故意改错,确认测试失败信息能指出预期行为;恢复后确认只依赖该用例的必要准备。
- 为一个异步 Hook 覆盖成功与失败,并验证卸载或重新请求后旧结果不会覆盖新界面。
- 让网络 mock 返回与真实接口不符的字段,确认类型检查、契约测试或组件测试能暴露问题。
- 查看覆盖率中未执行的分支,选择一个真实风险路径增加断言,并说明为何它比单纯提高行覆盖率有价值。
欢迎提交Issue和Pull Request来改进本模块内容!请遵循以下步骤:
- Fork 本仓库
- 创建特性分支 (
git checkout -b feature/amazing-feature) - 提交变更 (
git commit -m 'Add amazing feature') - 推送到分支 (
git push origin feature/amazing-feature) - 创建Pull Request
- 问题反馈: GitHub Issues
- 内容建议: Discussion板块
- 技术交流: 欢迎提交PR或Issue参与讨论
- Dev Quest Team - 核心内容开发
- 社区贡献者 - 内容完善和纠错
📜 文档版本: v1.0.0
📅 最后更新: 2026年9月
🏷️ 标签: #unit-testing #vitest #testing-library #react-testing #mocking
⭐ 推荐指数: ⭐⭐⭐
💡 提示: 本模块专注于现代前端单元测试实践,建议结合实际项目进行练习。
🎯 学习建议:
- 建议学习周期: 1-2周
- 理论与实践时间比例: 4:6
- 重点掌握组件测试和Hook测试
- 从简单工具函数开始,逐步过渡到复杂组件测试