零度AI
高级18 分钟阅读

Claude Code 单元测试进阶

使用 Claude Code 编写深度单元测试,包括 Mock、Stub、测试覆盖率和测试最佳实践

Claude Code单元测试JestVitestMockStub

单元测试进阶概述

深度单元测试能有效保障代码质量。Claude Code 可以帮你编写高质量的测试用例。

测试金字塔

单元测试 → 集成测试 → E2E 测试,比例约为 70:20:10。

测试框架配置

Vitest 配置

bash
帮我创建一个 React + Vitest 的测试配置。
javascript
// vitest.config.js
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.js"],
    coverage: {
      provider: "v8",
      reporter: ["text", "json", "html"],
      exclude: [
        "node_modules/",
        "src/test/",
        "**/*.d.ts",
        "**/*.config.*",
        ".eslintrc.cjs",
      ],
      thresholds: {
        lines: 80,
        functions: 80,
        branches: 70,
        statements: 80,
      },
    },
    include: ["src/**/*.{test,spec}.{js,jsx,ts,tsx}"],
    exclude: ["node_modules", "dist"],
    testTimeout: 10000,
  },
  resolve: {
    alias: {
      "@": path.resolve(__dirname, "./src"),
    },
  },
});

测试设置文件

javascript
// src/test/setup.js
import { expect, afterEach, vi } from "vitest";
import { cleanup } from "@testing-library/react";
import "@testing-library/jest-dom";

// 每个测试后清理
afterEach(() => {
  cleanup();
});

// 全局 Mock
vi.mock("axios", () => ({
  default: {
    get: vi.fn(),
    post: vi.fn(),
    put: vi.fn(),
    delete: vi.fn(),
  },
}));

// Mock localStorage
const localStorageMock = {
  getItem: vi.fn(),
  setItem: vi.fn(),
  removeItem: vi.fn(),
  clear: vi.fn(),
};
vi.stubGlobal("localStorage", localStorageMock);

// Mock window.matchMedia
Object.defineProperty(window, "matchMedia", {
  writable: true,
  value: vi.fn().mockImplementation((query) => ({
    matches: false,
    media: query,
    onchange: null,
    addListener: vi.fn(),
    removeListener: vi.fn(),
    addEventListener: vi.fn(),
    removeEventListener: vi.fn(),
    dispatchEvent: vi.fn(),
  })),
});

Mock 和 Stub

函数 Mock

javascript
// src/test/mocks/userService.test.js
import { describe, it, expect, vi, beforeEach } from "vitest";
import * as userService from "@/services/userService";
import * as userRepository from "@/repositories/userRepository";
import bcrypt from "bcryptjs";

vi.mock("@/repositories/userRepository");

describe("UserService", () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  describe("createUser", () => {
    it("应该正确创建用户", async () => {
      const userData = {
        username: "testuser",
        email: "test@example.com",
        password: "password123",
      };

      const hashedPassword = await bcrypt.hash(userData.password, 10);
      const mockUser = {
        id: "1",
        ...userData,
        password: hashedPassword,
        createdAt: new Date(),
      };

      userRepository.create.mockResolvedValue(mockUser);

      const result = await userService.createUser(userData);

      expect(result).toEqual(mockUser);
      expect(userRepository.create).toHaveBeenCalledWith({
        username: userData.username,
        email: userData.email,
        password: expect.any(String),
      });
    });

    it("用户已存在时应该抛出错误", async () => {
      const userData = {
        username: "existing",
        email: "existing@example.com",
        password: "password123",
      };

      userRepository.findByEmail.mockResolvedValue({
        id: "1",
        email: userData.email,
      });

      await expect(userService.createUser(userData)).rejects.toThrow(
        "用户已存在"
      );
    });

    it("密码应该被正确哈希", async () => {
      const userData = {
        username: "test",
        email: "test@example.com",
        password: "plainpassword",
      };

      userRepository.create.mockImplementation((data) =>
        Promise.resolve({ id: "1", ...data })
      );

      await userService.createUser(userData);

      const calledWith = userRepository.create.mock.calls[0][0];
      expect(calledWith.password).not.toBe(userData.password);
      expect(calledWith.password).toMatch(/^\$2[aby]?\$\d{1,2}\$/);
    });
  });

  describe("authenticate", () => {
    it("正确的凭据应该返回用户", async () => {
      const password = "correctpassword";
      const hashedPassword = await bcrypt.hash(password, 10);

      userRepository.findByEmail.mockResolvedValue({
        id: "1",
        email: "test@example.com",
        password: hashedPassword,
      });

      const result = await userService.authenticate("test@example.com", password);

      expect(result).toEqual({
        id: "1",
        email: "test@example.com",
      });
    });

    it("错误的密码应该返回 null", async () => {
      const hashedPassword = await bcrypt.hash("correctpassword", 10);

      userRepository.findByEmail.mockResolvedValue({
        id: "1",
        email: "test@example.com",
        password: hashedPassword,
      });

      const result = await userService.authenticate(
        "test@example.com",
        "wrongpassword"
      );

      expect(result).toBeNull();
    });
  });
});

模块 Mock

javascript
// src/test/mocks/emailService.test.js
import { describe, it, expect, vi, beforeEach } from "vitest";
import { sendWelcomeEmail } from "@/services/emailService";
import * as emailProvider from "@/providers/emailProvider";

vi.mock("@/providers/emailProvider");

describe("EmailService", () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  describe("sendWelcomeEmail", () => {
    it("应该发送欢迎邮件", async () => {
      const user = {
        email: "test@example.com",
        username: "testuser",
      };

      emailProvider.send.mockResolvedValue({ messageId: "msg-123" });

      const result = await sendWelcomeEmail(user);

      expect(result).toBe(true);
      expect(emailProvider.send).toHaveBeenCalledWith({
        to: user.email,
        subject: expect.stringContaining("欢迎"),
        html: expect.stringContaining(user.username),
      });
    });

    it("发送失败时应该抛出错误", async () => {
      const user = { email: "test@example.com", username: "test" };

      emailProvider.send.mockRejectedValue(new Error("SMTP 错误"));

      await expect(sendWelcomeEmail(user)).rejects.toThrow("邮件发送失败");
    });

    it("重试次数应该正确设置", async () => {
      const user = { email: "test@example.com", username: "test" };

      emailProvider.send
        .mockRejectedValueOnce(new Error("timeout"))
        .mockResolvedValueOnce({ messageId: "msg-123" });

      await sendWelcomeEmail(user);

      // 第一次失败后会重试
      expect(emailProvider.send).toHaveBeenCalledTimes(2);
    });
  });
});

React 组件测试

Hook 测试

javascript
// src/test/hooks/useCounter.test.js
import { renderHook, act } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";
import { useCounter } from "@/hooks/useCounter";

describe("useCounter", () => {
  it("应该初始化为 0", () => {
    const { result } = renderHook(() => useCounter());
    expect(result.current.count).toBe(0);
  });

  it("应该支持自定义初始值", () => {
    const { result } = renderHook(() => useCounter(10));
    expect(result.current.count).toBe(10);
  });

  it("increment 应该增加计数", () => {
    const { result } = renderHook(() => useCounter());

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

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

  it("decrement 应该减少计数", () => {
    const { result } = renderHook(() => useCounter(5));

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

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

  it("incrementBy 应该按指定值增加", () => {
    const { result } = renderHook(() => useCounter());

    act(() => {
      result.current.incrementBy(5);
    });

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

  it("reset 应该重置为初始值", () => {
    const { result } = renderHook(() => useCounter(10));

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

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

组件测试

jsx
// src/test/components/UserCard.test.jsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi } from "vitest";
import UserCard from "@/components/UserCard";

const mockUser = {
  id: "1",
  username: "testuser",
  email: "test@example.com",
  avatar: "https://example.com/avatar.jpg",
  bio: "测试用户简介",
};

describe("UserCard", () => {
  it("应该正确显示用户信息", () => {
    render(<UserCard user={mockUser} />);

    expect(screen.getByText("testuser")).toBeInTheDocument();
    expect(screen.getByText("test@example.com")).toBeInTheDocument();
    expect(screen.getByText("测试用户简介")).toBeInTheDocument();
  });

  it("点击编辑按钮应该触发回调", async () => {
    const onEdit = vi.fn();
    const user = userEvent.setup();

    render(<UserCard user={mockUser} onEdit={onEdit} />);

    await user.click(screen.getByRole("button", { name: "编辑" }));

    expect(onEdit).toHaveBeenCalledWith(mockUser.id);
  });

  it("头像加载失败应该显示默认头像", () => {
    render(<UserCard user={{ ...mockUser, avatar: "invalid-url" }} />);

    const img = screen.getByRole("img");
    expect(img).toHaveAttribute("alt", "testuser");
  });
});

测试覆盖率

覆盖率配置

javascript
// vitest.config.js - 添加覆盖率配置
export default defineConfig({
  test: {
    coverage: {
      reporter: ["text", "json", "html", "lcov"],
      reportsDirectory: "./coverage",
      exclude: [
        "node_modules/**",
        "*.config.*",
        "**/*.d.ts",
        "**/*.test.*",
        "**/*.spec.*",
      ],
    },
  },
});

覆盖率报告解读

bash
# 运行测试并生成覆盖率报告
npm run test:coverage

# 查看报告
# Statements: 代码语句执行比例
# Branches: 条件分支执行比例
# Functions: 函数执行比例
# Lines: 代码行执行比例

覆盖率检查 CI

yaml
# .github/workflows/test.yml
- name: Check Coverage
  run: |
    npx vitest run --coverage
    npx coverageThreshold@latest \
      --config ./coverageThreshold.json
json
// coverageThreshold.json
{
  "src/**/*.{js,jsx,ts,tsx}": {
    "lines": 80,
    "functions": 80,
    "branches": 75,
    "statements": 80
  }
}

异步测试

async/await 测试

javascript
// src/test/services/asyncService.test.js
import { describe, it, expect, vi } from "vitest";
import { fetchUserData, processUserList } from "@/services/asyncService";

global.fetch = vi.fn();

describe("AsyncService", () => {
  describe("fetchUserData", () => {
    it("应该正确获取用户数据", async () => {
      const mockUser = { id: "1", name: "Test" };
      global.fetch.mockResolvedValueOnce({
        ok: true,
        json: async () => mockUser,
      });

      const result = await fetchUserData("1");

      expect(result).toEqual(mockUser);
      expect(fetch).toHaveBeenCalledWith("/api/users/1");
    });

    it("请求失败时应该抛出错误", async () => {
      global.fetch.mockResolvedValueOnce({
        ok: false,
        status: 404,
      });

      await expect(fetchUserData("999")).rejects.toThrow("用户不存在");
    });
  });

  describe("processUserList", () => {
    it("应该并发处理用户列表", async () => {
      const users = [
        { id: "1", email: "a@test.com" },
        { id: "2", email: "b@test.com" },
        { id: "3", email: "c@test.com" },
      ];

      global.fetch.mockImplementation((url) =>
        Promise.resolve({
          ok: true,
          json: async () => ({ id: url.split("/").pop(), processed: true }),
        })
      );

      const results = await processUserList(users);

      expect(results).toHaveLength(3);
      expect(results.every((r) => r.processed)).toBe(true);
    });
  });
});

测试最佳实践

  • 测试应该快速、独立、可重复
  • 每个测试只验证一个行为
  • 使用描述性的测试名称
  • 及时清理测试数据和 Mock

总结

使用 Claude Code 编写深度单元测试:

  • 配置完整的测试环境
  • 使用 Mock 和 Stub 隔离依赖
  • 测试边界条件和异常情况
  • 达到合理的测试覆盖率
  • 遵循测试最佳实践