前端单元测试实战指南

Created on

前言

“我的代码没问题,不需要测试”——这是很多前端开发者的想法。然而,当项目规模增长、需求频繁变更时,没有测试的代码就像没有保险的汽车,随时可能翻车。本文将系统讲解前端单元测试的理论和实践。

为什么需要单元测试?

真实案例

某电商网站的购物车功能上线后,用户反馈优惠券计算错误。排查发现,是因为某次重构时修改了价格计算逻辑,导致优惠券叠加规则出错。如果有单元测试,这个问题在提交代码时就会被发现。

单元测试的价值

  1. 提前发现 Bug:在开发阶段而非生产环境发现问题
  2. 重构保障:重构时快速验证功能未被破坏
  3. 文档作用:测试用例展示代码的使用方式
  4. 提高代码质量:可测试的代码通常设计更好
  5. 节省时间:自动化测试比手动测试快得多

测试金字塔

        /\
       /E2E\         少量 E2E 测试(慢、覆盖完整流程)
      /______\
     /        \
    /Integration\   适量集成测试(中速、验证模块协作)
   /____________\
  /              \
 /  Unit Tests    \  大量单元测试(快、覆盖单一功能)
/__________________\

推荐比例

Jest vs Vitest

Jest

# 安装
npm install --save-dev jest @types/jest

# 配置 jest.config.js
module.exports = {
  testEnvironment: 'jsdom',
  setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
  moduleNameMapper: {
    '\\.(css|less|scss)$': 'identity-obj-proxy',
    '^@/(.*)$': '<rootDir>/src/$1'
  },
  collectCoverageFrom: [
    'src/**/*.{js,jsx,ts,tsx}',
    '!src/**/*.d.ts',
    '!src/**/*.stories.tsx'
  ],
  coverageThresholds: {
    global: {
      branches: 80,
      functions: 80,
      lines: 80,
      statements: 80
    }
  }
};

Vitest(推荐,Vite 项目)

# 安装
npm install --save-dev vitest @vitest/ui

# 配置 vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  test: {
    globals: true,
    environment: 'jsdom',
    setupFiles: './src/test/setup.ts',
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html'],
      exclude: ['node_modules/', 'src/test/']
    }
  }
});

基础测试示例

1. 纯函数测试

// utils/math.js
export function add(a, b) {
  return a + b;
}

export function multiply(a, b) {
  return a * b;
}

export function divide(a, b) {
  if (b === 0) {
    throw new Error("Division by zero");
  }
  return a / b;
}
// utils/math.test.js
import { add, multiply, divide } from "./math";

describe("Math Utils", () => {
  describe("add", () => {
    it("should add two positive numbers", () => {
      expect(add(2, 3)).toBe(5);
    });

    it("should add negative numbers", () => {
      expect(add(-2, -3)).toBe(-5);
    });

    it("should handle zero", () => {
      expect(add(0, 5)).toBe(5);
    });
  });

  describe("multiply", () => {
    it("should multiply two numbers", () => {
      expect(multiply(3, 4)).toBe(12);
    });

    it("should handle multiplication by zero", () => {
      expect(multiply(5, 0)).toBe(0);
    });
  });

  describe("divide", () => {
    it("should divide two numbers", () => {
      expect(divide(10, 2)).toBe(5);
    });

    it("should throw error when dividing by zero", () => {
      expect(() => divide(10, 0)).toThrow("Division by zero");
    });
  });
});

2. 异步函数测试

// api/user.js
export async function fetchUser(id) {
  const response = await fetch(`/api/users/${id}`);
  if (!response.ok) {
    throw new Error("User not found");
  }
  return response.json();
}
// api/user.test.js
import { fetchUser } from "./user";

describe("fetchUser", () => {
  beforeEach(() => {
    global.fetch = jest.fn();
  });

  afterEach(() => {
    jest.resetAllMocks();
  });

  it("should fetch user successfully", async () => {
    const mockUser = { id: 1, name: "John" };
    global.fetch.mockResolvedValue({
      ok: true,
      json: async () => mockUser,
    });

    const user = await fetchUser(1);
    expect(user).toEqual(mockUser);
    expect(global.fetch).toHaveBeenCalledWith("/api/users/1");
  });

  it("should throw error when user not found", async () => {
    global.fetch.mockResolvedValue({
      ok: false,
    });

    await expect(fetchUser(999)).rejects.toThrow("User not found");
  });
});

React 组件测试

使用 React Testing Library

npm install --save-dev @testing-library/react @testing-library/jest-dom @testing-library/user-event

测试用户交互

// components/Counter.jsx
import { useState } from "react";

export function Counter({ initialCount = 0 }) {
  const [count, setCount] = useState(initialCount);

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
      <button onClick={() => setCount(count - 1)}>Decrement</button>
      <button onClick={() => setCount(0)}>Reset</button>
    </div>
  );
}
// components/Counter.test.jsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Counter } from "./Counter";

describe("Counter", () => {
  it("should render with initial count", () => {
    render(<Counter initialCount={5} />);
    expect(screen.getByText("Count: 5")).toBeInTheDocument();
  });

  it("should increment count", async () => {
    const user = userEvent.setup();
    render(<Counter />);

    await user.click(screen.getByText("Increment"));
    expect(screen.getByText("Count: 1")).toBeInTheDocument();
  });

  it("should decrement count", async () => {
    const user = userEvent.setup();
    render(<Counter initialCount={5} />);

    await user.click(screen.getByText("Decrement"));
    expect(screen.getByText("Count: 4")).toBeInTheDocument();
  });

  it("should reset count", async () => {
    const user = userEvent.setup();
    render(<Counter initialCount={10} />);

    await user.click(screen.getByText("Reset"));
    expect(screen.getByText("Count: 0")).toBeInTheDocument();
  });
});

测试表单

// components/LoginForm.jsx
import { useState } from "react";

export function LoginForm({ onSubmit }) {
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const [error, setError] = useState("");

  const handleSubmit = (e) => {
    e.preventDefault();

    if (!email || !password) {
      setError("All fields are required");
      return;
    }

    if (!/\S+@\S+\.\S+/.test(email)) {
      setError("Invalid email format");
      return;
    }

    setError("");
    onSubmit({ email, password });
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="email"
        placeholder="Email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
      />
      <input
        type="password"
        placeholder="Password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
      />
      <button type="submit">Login</button>
      {error && <p role="alert">{error}</p>}
    </form>
  );
}
// components/LoginForm.test.jsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { LoginForm } from "./LoginForm";

describe("LoginForm", () => {
  it("should show error when fields are empty", async () => {
    const user = userEvent.setup();
    const onSubmit = jest.fn();
    render(<LoginForm onSubmit={onSubmit} />);

    await user.click(screen.getByText("Login"));

    expect(screen.getByRole("alert")).toHaveTextContent(
      "All fields are required"
    );
    expect(onSubmit).not.toHaveBeenCalled();
  });

  it("should show error for invalid email", async () => {
    const user = userEvent.setup();
    const onSubmit = jest.fn();
    render(<LoginForm onSubmit={onSubmit} />);

    await user.type(screen.getByPlaceholderText("Email"), "invalid-email");
    await user.type(screen.getByPlaceholderText("Password"), "password123");
    await user.click(screen.getByText("Login"));

    expect(screen.getByRole("alert")).toHaveTextContent("Invalid email format");
    expect(onSubmit).not.toHaveBeenCalled();
  });

  it("should submit form with valid data", async () => {
    const user = userEvent.setup();
    const onSubmit = jest.fn();
    render(<LoginForm onSubmit={onSubmit} />);

    await user.type(screen.getByPlaceholderText("Email"), "[email protected]");
    await user.type(screen.getByPlaceholderText("Password"), "password123");
    await user.click(screen.getByText("Login"));

    expect(onSubmit).toHaveBeenCalledWith({
      email: "[email protected]",
      password: "password123",
    });
    expect(screen.queryByRole("alert")).not.toBeInTheDocument();
  });
});

Mock 和 Spy

Mock 函数

// 创建 mock 函数
const mockFn = jest.fn();

// 设置返回值
mockFn.mockReturnValue(42);
expect(mockFn()).toBe(42);

// 设置异步返回值
mockFn.mockResolvedValue({ data: "success" });
await expect(mockFn()).resolves.toEqual({ data: "success" });

// 设置多次调用的返回值
mockFn.mockReturnValueOnce(1).mockReturnValueOnce(2).mockReturnValue(3);

expect(mockFn()).toBe(1);
expect(mockFn()).toBe(2);
expect(mockFn()).toBe(3);

// 检查调用
expect(mockFn).toHaveBeenCalledTimes(3);
expect(mockFn).toHaveBeenCalledWith(expectedArg);

Mock 模块

// api/userService.js
export const getUserById = async (id) => {
  const response = await fetch(`/api/users/${id}`);
  return response.json();
};
// components/UserProfile.test.jsx
import { getUserById } from "../api/userService";

jest.mock("../api/userService");

describe("UserProfile", () => {
  it("should display user name", async () => {
    getUserById.mockResolvedValue({ id: 1, name: "John Doe" });

    render(<UserProfile userId={1} />);

    expect(await screen.findByText("John Doe")).toBeInTheDocument();
  });
});

Spy 监听

const calculator = {
  add: (a, b) => a + b,
  multiply: (a, b) => a * b,
};

const addSpy = jest.spyOn(calculator, "add");

calculator.add(2, 3);

expect(addSpy).toHaveBeenCalledWith(2, 3);
expect(addSpy).toHaveReturnedWith(5);

addSpy.mockRestore(); // 恢复原始实现

Hooks 测试

import { renderHook, waitFor } from "@testing-library/react";
import { useCounter } from "./useCounter";

describe("useCounter", () => {
  it("should increment counter", () => {
    const { result } = renderHook(() => useCounter(0));

    expect(result.current.count).toBe(0);

    act(() => {
      result.current.increment();
    });

    expect(result.current.count).toBe(1);
  });

  it("should fetch data", async () => {
    global.fetch = jest.fn().mockResolvedValue({
      json: async () => ({ data: "test" }),
    });

    const { result } = renderHook(() => useFetch("/api/data"));

    expect(result.current.loading).toBe(true);

    await waitFor(() => {
      expect(result.current.loading).toBe(false);
    });

    expect(result.current.data).toEqual({ data: "test" });
  });
});

TDD 开发流程

Red-Green-Refactor

// 1. Red - 编写失败的测试
describe("calculateDiscount", () => {
  it("should apply 10% discount for orders over $100", () => {
    expect(calculateDiscount(150)).toBe(135);
  });
});

// 2. Green - 编写最简单的实现让测试通过
function calculateDiscount(amount) {
  if (amount > 100) {
    return amount * 0.9;
  }
  return amount;
}

// 3. Refactor - 重构代码
function calculateDiscount(amount) {
  const DISCOUNT_THRESHOLD = 100;
  const DISCOUNT_RATE = 0.1;

  if (amount > DISCOUNT_THRESHOLD) {
    return amount * (1 - DISCOUNT_RATE);
  }
  return amount;
}

测试覆盖率

package.json 配置

{
  "scripts": {
    "test": "vitest",
    "test:coverage": "vitest run --coverage",
    "test:ui": "vitest --ui"
  }
}

查看覆盖率报告

npm run test:coverage

# 生成 HTML 报告
open coverage/index.html

合理的覆盖率目标

E2E 测试

Playwright 示例

npm install --save-dev @playwright/test
// e2e/login.spec.js
import { test, expect } from "@playwright/test";

test.describe("Login Flow", () => {
  test("should login successfully", async ({ page }) => {
    await page.goto("http://localhost:3000/login");

    await page.fill('input[name="email"]', "[email protected]");
    await page.fill('input[name="password"]', "password123");
    await page.click('button[type="submit"]');

    await expect(page).toHaveURL("http://localhost:3000/dashboard");
    await expect(page.locator("h1")).toContainText("Welcome");
  });

  test("should show error for invalid credentials", async ({ page }) => {
    await page.goto("http://localhost:3000/login");

    await page.fill('input[name="email"]', "[email protected]");
    await page.fill('input[name="password"]', "wrongpassword");
    await page.click('button[type="submit"]');

    await expect(page.locator('[role="alert"]')).toContainText(
      "Invalid credentials"
    );
  });
});

最佳实践

1. 测试命名规范

// ✅ 好的命名
describe("UserService", () => {
  describe("createUser", () => {
    it("should create user with valid data", () => {});
    it("should throw error when email is duplicate", () => {});
    it("should hash password before saving", () => {});
  });
});

// ❌ 不好的命名
describe("test1", () => {
  it("works", () => {});
});

2. AAA 模式

it("should add item to cart", () => {
  // Arrange - 准备测试数据
  const cart = new ShoppingCart();
  const item = { id: 1, name: "Book", price: 20 };

  // Act - 执行操作
  cart.addItem(item);

  // Assert - 验证结果
  expect(cart.items).toContain(item);
  expect(cart.total).toBe(20);
});

3. 避免测试实现细节

// ❌ 测试实现细节
it("should call setState with correct value", () => {
  const setStateSpy = jest.spyOn(component, "setState");
  component.handleClick();
  expect(setStateSpy).toHaveBeenCalledWith({ count: 1 });
});

// ✅ 测试用户行为
it("should increment counter when button is clicked", async () => {
  render(<Counter />);
  await user.click(screen.getByText("Increment"));
  expect(screen.getByText("Count: 1")).toBeInTheDocument();
});

4. 使用 data-testid 谨慎

// ❌ 过度使用 data-testid
<button data-testid="submit-button">Submit</button>

// ✅ 优先使用语义化查询
<button type="submit">Submit</button>

// 在测试中
screen.getByRole('button', { name: /submit/i });

CI/CD 集成

# .github/workflows/test.yml
name: Test

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
        with:
          node-version: "18"
          cache: "npm"

      - run: npm ci
      - run: npm run test:coverage

      - name: Upload coverage to Codecov
        uses: codecov/codecov-action@v3
        with:
          files: ./coverage/coverage-final.json

总结

前端单元测试的核心原则:

  1. 测试行为,不测实现
  2. 保持测试简单、快速
  3. 遵循测试金字塔原则
  4. 持续维护测试代码
  5. TDD 能写出更好的代码

记住:测试不是浪费时间,而是节省时间的投资。

参考资源