Skip to content

Latest commit

 

History

History
1557 lines (1280 loc) · 45.4 KB

01-unit-testing.md

File metadata and controls

1557 lines (1280 loc) · 45.4 KB

单元测试指南 (Unit Testing Guide)

面向会基本编程、刚开始学习 Next.js 的读者:先验证纯函数的输入输出,再验证组件和 Hook。单元测试不能证明服务端渲染、路由或完整应用构建成功。

单元测试基础

什么是单元测试

单元测试是针对程序中最小可测试单元(如函数、方法、类)进行的测试。在Next.js中,单元测试主要用于测试:

  • 工具函数(Utility Functions)
  • React组件的渲染逻辑
  • 自定义Hooks
  • 业务逻辑函数
  • 数据验证函数

为什么需要单元测试

  1. 代码质量保证: 确保代码按照预期工作
  2. 重构安全性: 在重构时提供安全网
  3. 文档作用: 测试用例作为代码的使用示例
  4. 快速反馈: 开发过程中快速发现问题
  5. 维护成本降低: 减少回归bug

测试工具栈

1. Vitest - 现代测试框架

# 安装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"),
    },
  },
})

2. Testing Library - React测试工具

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 "/"
  },
}))

测试工具函数

1. 工具函数测试

// 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")
    })
  })
})

2. 数据验证函数测试

// 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("标签")
      }
    })
  })
})

React组件测试

1. 基础组件测试

// 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()
  })
})

2. 复杂组件测试

// 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("") // 表单应该被重置
    })
  })
})

自定义Hooks测试

1. 状态管理Hooks

// 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 或同步多个标签页时还须补充相应状态机。写入失败时当前示例保留旧状态并记录错误,正式界面应展示可重试错误提示。

2. 数据获取Hooks

// 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()
  })
})

异步函数测试

1. API函数测试

// 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("用户不存在")
    })
  })
})

测试最佳实践

1. 测试组织结构

__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/
    └── ...

2. 测试命名规范

// 好的测试命名
describe("Button Component", () => {
  it("应该渲染默认样式的按钮", () => {
    // 测试代码
  })

  it("应该在点击时触发onClick事件", () => {
    // 测试代码
  })

  it("应该正确处理禁用状态", () => {
    // 测试代码
  })
})

// 不好的测试命名
describe("Button", () => {
  it("test button render", () => {
    // 测试代码
  })

  it("click works", () => {
    // 测试代码
  })
})

3. 测试覆盖率配置

// 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,
        },
      },
    },
  },
})

4. 测试工具函数

// 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的使用
  • 异步测试的处理
  • 测试覆盖率的配置

从PHP开发者角度

  • 从PHPUnit到Vitest的转变
  • 前端测试的特殊考虑
  • 组件测试与单元测试的区别

从一个会失败的边界案例开始

前置是纯函数、Promise 与 TypeScript。先定义输入输出契约,再挑能区分正确和错误实现的案例。例如 slug 前后空格应被移除,而不是先替换成连字符后留下 '-spaces-';日期格式必须约定时区,否则 UTC 午夜在其他机器可能是前一天。测试不能把开发机的默认环境当成业务规则。

把纯函数测试放在 node 环境,需要 DOM 的组件测试才用 jsdom。Vitest 转译 TypeScript 不等于完整类型检查,CI 另外执行 tsc --noEmit。异步测试必须 await 返回的 Promise;只调用函数不等待,测试可能在断言前就结束。

练习:对 generateSlug 增加前后空格、连续连字符、中文标题三个输入,先决定是否支持 Unicode,再记录期望。让日期测试在两个时区执行,输出应遵守显式约定。临时引入一个已知缺陷,确认对应测试变红;覆盖率高但对缺陷不敏感,仍不是有效测试。

依据:Vitest 与 Next、Vitest 类型检查。本文历史片段未全量执行,需在练习项目中补齐组件和依赖后运行。

🔗 相关资源链接

官方资源

技术文章

工具和资源

📚 模块内相关文档

同模块相关文档

相关知识模块

基础前置知识


✨ 总结

交付时应能说明什么

  1. 测试边界:单元测试验证一个函数、组件或 Hook 的可观察契约;浏览器导航、第三方系统和完整登录链路留给集成或 E2E 测试。测试金字塔是分配成本的依据,不是必须达到的覆盖率比例。
  2. 用户可见行为:用 Testing Library 按角色、标签和文本查找,再触发真实用户动作并断言可见结果;避免把组件内部 state、私有函数或 CSS 类名当作主要断言对象。
  3. 异步与清理:等待界面最终状态而不是固定 sleep;每个 mock、定时器、环境变量和网络处理器在用例间恢复,避免通过顺序才会出现的污染。
  4. 依赖替身:只替换测试不关心的边界(时间、网络、存储或昂贵服务),并为替身写出输入和返回约定;如果 mock 与真实接口形状不同,测试通过也不代表集成可用。
  5. 覆盖率的用途:把覆盖率报告作为“哪些路径尚未执行”的提示,针对权限拒绝、网络失败、空数据和重试等风险补测试;不以凑百分比替代断言质量。

学习成果验收

  • 为一个表单或列表组件写出“初始、成功、失败”三个用户可见状态,运行测试并保存通过输出。
  • 把一个断言故意改错,确认测试失败信息能指出预期行为;恢复后确认只依赖该用例的必要准备。
  • 为一个异步 Hook 覆盖成功与失败,并验证卸载或重新请求后旧结果不会覆盖新界面。
  • 让网络 mock 返回与真实接口不符的字段,确认类型检查、契约测试或组件测试能暴露问题。
  • 查看覆盖率中未执行的分支,选择一个真实风险路径增加断言,并说明为何它比单纯提高行覆盖率有价值。

🤝 贡献与反馈

贡献指南

欢迎提交Issue和Pull Request来改进本模块内容!请遵循以下步骤:

  1. Fork 本仓库
  2. 创建特性分支 (git checkout -b feature/amazing-feature)
  3. 提交变更 (git commit -m 'Add amazing feature')
  4. 推送到分支 (git push origin feature/amazing-feature)
  5. 创建Pull Request

反馈渠道

贡献者

  • Dev Quest Team - 核心内容开发
  • 社区贡献者 - 内容完善和纠错

📜 文档版本: v1.0.0 📅 最后更新: 2026年9月 🏷️ 标签: #unit-testing #vitest #testing-library #react-testing #mocking ⭐ 推荐指数: ⭐⭐⭐

💡 提示: 本模块专注于现代前端单元测试实践,建议结合实际项目进行练习。

🎯 学习建议:

  • 建议学习周期: 1-2周
  • 理论与实践时间比例: 4:6
  • 重点掌握组件测试和Hook测试
  • 从简单工具函数开始,逐步过渡到复杂组件测试

阅读导航

本模块理解地图 · 完整目录与版本 · 通用术语