第八篇:【React 性能调优】从优化实践到自动化性能监控

197 阅读3分钟

第九篇:【前端自动化测试】从零搭建 React 项目完整测试体系

测试驱动开发:让你的 React 应用更加健壮、可靠和可维护

各位 React 开发者,你们是否曾经面临过这些问题:

  • 修复一个 bug 后,意外地引入了另一个 bug?
  • 重构代码时担心破坏现有功能?
  • 新成员加入团队时,不了解关键业务逻辑?
  • 对应用的质量和稳定性缺乏信心?

今天,我们将一起探索如何为 React 应用构建完整的测试体系,从单元测试到端到端测试,全面保障应用质量!

1. 测试战略与测试金字塔

在开始编写测试前,我们需要理解不同类型的测试及其目的:

                  /\
                 /  \
                /    \
               /  E2E \      少量端到端测试
              /--------\
             /          \
            / 集成测试    \    适量集成测试
           /--------------\
          /                \
         /    单元测试       \   大量单元测试
        /------------------  \

测试配置与环境搭建:

# 安装核心测试依赖
npm install --save-dev vitest @testing-library/react @testing-library/user-event @testing-library/jest-dom happy-dom

# 安装端到端测试工具
npm install --save-dev cypress @cypress/code-coverage

# 安装代码覆盖率工具
npm install --save-dev @vitest/coverage-c8
// vitest.config.ts - Vitest配置
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";

export default defineConfig({
  plugins: [react()],
  test: {
    globals: true,
    environment: "happy-dom", // 模拟DOM环境
    setupFiles: "./src/test/setup.ts",
    coverage: {
      reporter: ["text", "json", "html"],
      exclude: [
        "node_modules/",
        "src/test/",
        "**/*.d.ts",
        "**/*.config.*",
        "**/index.ts",
      ],
      lines: 80,
      functions: 80,
      branches: 70,
      statements: 80,
    },
  },
});
// src/test/setup.ts - 测试环境设置
import "@testing-library/jest-dom";
import { cleanup } from "@testing-library/react";
import { afterEach, vi } from "vitest";

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

// 模拟localStorage
const localStorageMock = {
  getItem: vi.fn(),
  setItem: vi.fn(),
  removeItem: vi.fn(),
  clear: vi.fn(),
};

Object.defineProperty(window, "localStorage", {
  value: localStorageMock,
});

// 模拟matchMedia
Object.defineProperty(window, "matchMedia", {
  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(),
  })),
});

// 模拟Fetch API
global.fetch = vi.fn();

2. 单元测试:基础构建块

首先,我们从单元测试开始,确保各个功能单元正常工作:

工具函数测试
// src/utils/formatters.ts - 原始代码
export function formatCurrency(value: number, currency = "CNY"): string {
  return new Intl.NumberFormat("zh-CN", {
    style: "currency",
    currency,
  }).format(value);
}

export function formatDate(date: Date | string): string {
  const d = typeof date === "string" ? new Date(date) : date;
  return d.toLocaleDateString("zh-CN", {
    year: "numeric",
    month: "long",
    day: "numeric",
  });
}

export function truncateText(text: string, maxLength = 100): string {
  if (text.length <= maxLength) return text;
  return text.slice(0, maxLength) + "...";
}
// src/utils/__tests__/formatters.test.ts - 单元测试
import { describe, it, expect } from "vitest";
import { formatCurrency, formatDate, truncateText } from "../formatters";

describe("formatters", () => {
  describe("formatCurrency", () => {
    it("should format number as CNY by default", () => {
      expect(formatCurrency(1234.56)).toBe("¥1,234.56");
    });

    it("should format number as USD when specified", () => {
      expect(formatCurrency(1234.56, "USD")).toBe("US$1,234.56");
    });

    it("should handle zero properly", () => {
      expect(formatCurrency(0)).toBe("¥0.00");
    });

    it("should handle negative values", () => {
      expect(formatCurrency(-99.99)).toBe("-¥99.99");
    });
  });

  describe("formatDate", () => {
    it("should format Date object", () => {
      const date = new Date(2023, 0, 15); // Jan 15, 2023
      expect(formatDate(date)).toMatch(/2023年1月15日/);
    });

    it("should format date string", () => {
      expect(formatDate("2023-02-20")).toMatch(/2023年2月20日/);
    });

    it("should handle invalid date string", () => {
      // Invalid dates will return "Invalid Date" in Chinese
      expect(formatDate("invalid-date")).toMatch(/无效日期/);
    });
  });

  describe("truncateText", () => {
    it("should not truncate text shorter than maxLength", () => {
      const text = "Hello, world!";
      expect(truncateText(text, 20)).toBe(text);
    });

    it("should truncate text longer than maxLength", () => {
      const text = "This is a very long text that needs to be truncated";
      expect(truncateText(text, 20)).toBe("This is a very long t...");
    });

    it("should use default maxLength if not specified", () => {
      const longText = "a".repeat(150);
      const result = truncateText(longText);
      expect(result.length).toBe(103); // 100 chars + 3 for ellipsis
      expect(result.endsWith("...")).toBe(true);
    });
  });
});
自定义 Hook 测试
// src/hooks/useLocalStorage.ts - 自定义Hook
import { useState, useEffect } from 'react';

function useLocalStorage<T>(key: string, initialValue: T) {
  // 状态初始化
  const [storedValue, setStoredValue] = useState<T>(() => {
    try {
      // 尝试从localStorage获取值
      const item = window.localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch (error) {
      console.error('Error reading from localStorage', error);
      return initialValue;
    }
  });

  // 数据持久化函数
  const setValue = (value: T | ((val: T) => T)) => {
    try {
      // 允许函数式更新
      const valueToStore =
        value instanceof Function ? value(storedValue) : value;
      // 保存到state
      setStoredValue(valueToStore);
      // 保存到localStorage
      window.localStorage.setItem(key, JSON.stringify(valueToStore));
    } catch (error) {
      console.error('Error writing to localStorage', error);
    }
  };

  // 处理其他窗口更新localStorage的情况
  useEffect(() => {
    const handleStorageChange = (event: StorageEvent) => {
      if (event.key === key && event.newValue) {
        setStoredValue(JSON.parse(event.newValue));
      }
    };

    // 监听storage事件
    window.addEventListener('storage', handleStorageChange);
    return () => {
      window.removeEventListener('storage', handleStorageChange);
    };
  }, [key]);

  return [storedValue, setValue] as const;
}

export default useLocalStorage;
// src/hooks/__tests__/useLocalStorage.test.tsx - Hook测试
import { renderHook, act } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import useLocalStorage from "../useLocalStorage";

describe("useLocalStorage", () => {
  // 测试前清理localStorage模拟
  beforeEach(() => {
    vi.clearAllMocks();
    vi.spyOn(Storage.prototype, "getItem");
    vi.spyOn(Storage.prototype, "setItem");
  });

  it("should retrieve value from localStorage", () => {
    // 模拟localStorage中已存在的值
    Storage.prototype.getItem = vi
      .fn()
      .mockReturnValueOnce(JSON.stringify("stored value"));

    const { result } = renderHook(() =>
      useLocalStorage("testKey", "default value")
    );

    expect(Storage.prototype.getItem).toHaveBeenCalledWith("testKey");
    expect(result.current[0]).toBe("stored value");
  });

  it("should use initial value when localStorage is empty", () => {
    // 模拟localStorage为空
    Storage.prototype.getItem = vi.fn().mockReturnValueOnce(null);

    const { result } = renderHook(() =>
      useLocalStorage("testKey", "default value")
    );

    expect(Storage.prototype.getItem).toHaveBeenCalledWith("testKey");
    expect(result.current[0]).toBe("default value");
  });

  it("should handle localStorage parsing errors", () => {
    // 模拟localStorage中存在无效JSON
    Storage.prototype.getItem = vi.fn().mockReturnValueOnce("invalid json");
    console.error = vi.fn(); // 抑制错误输出

    const { result } = renderHook(() =>
      useLocalStorage("testKey", "default value")
    );

    expect(console.error).toHaveBeenCalled();
    expect(result.current[0]).toBe("default value");
  });

  it("should update localStorage when state changes", () => {
    const { result } = renderHook(() => useLocalStorage("testKey", "initial"));

    act(() => {
      result.current[1]("new value");
    });

    expect(Storage.prototype.setItem).toHaveBeenCalledWith(
      "testKey",
      JSON.stringify("new value")
    );
    expect(result.current[0]).toBe("new value");
  });

  it("should support functional updates", () => {
    const { result } = renderHook(() =>
      useLocalStorage("testKey", { count: 0 })
    );

    act(() => {
      result.current[1]((prev) => ({ count: prev.count + 1 }));
    });

    expect(Storage.prototype.setItem).toHaveBeenCalledWith(
      "testKey",
      JSON.stringify({ count: 1 })
    );
    expect(result.current[0]).toEqual({ count: 1 });
  });

  it("should respond to storage events from other windows", () => {
    const { result } = renderHook(() => useLocalStorage("testKey", "initial"));

    // 模拟来自其他窗口的storage事件
    act(() => {
      window.dispatchEvent(
        new StorageEvent("storage", {
          key: "testKey",
          newValue: JSON.stringify("updated from another window"),
        })
      );
    });

    expect(result.current[0]).toBe("updated from another window");
  });
});

3. 组件测试:确保 UI 正常工作

接下来,让我们测试 React 组件的渲染和交互:

// src/components/common/Button.tsx - 按钮组件
import React from "react";
import clsx from "clsx";

export type ButtonVariant = "primary" | "secondary" | "danger" | "ghost";
export type ButtonSize = "small" | "medium" | "large";

export interface ButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  children: React.ReactNode;
  variant?: ButtonVariant;
  size?: ButtonSize;
  isLoading?: boolean;
  fullWidth?: boolean;
  leftIcon?: React.ReactNode;
  rightIcon?: React.ReactNode;
}

export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
  (
    {
      children,
      variant = "primary",
      size = "medium",
      isLoading = false,
      fullWidth = false,
      leftIcon,
      rightIcon,
      className,
      disabled,
      ...props
    },
    ref
  ) => {
    const baseStyles =
      "rounded font-medium focus:outline-none transition-colors";

    const variantStyles = {
      primary:
        "bg-blue-600 text-white hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50",
      secondary:
        "bg-gray-200 text-gray-800 hover:bg-gray-300 focus:ring-2 focus:ring-gray-400 focus:ring-opacity-50",
      danger:
        "bg-red-600 text-white hover:bg-red-700 focus:ring-2 focus:ring-red-500 focus:ring-opacity-50",
      ghost:
        "bg-transparent hover:bg-gray-100 text-gray-800 focus:ring-2 focus:ring-gray-400 focus:ring-opacity-50",
    };

    const sizeStyles = {
      small: "py-1 px-3 text-sm",
      medium: "py-2 px-4 text-base",
      large: "py-3 px-6 text-lg",
    };

    const widthStyles = fullWidth ? "w-full" : "";

    const buttonStyles = clsx(
      baseStyles,
      variantStyles[variant],
      sizeStyles[size],
      widthStyles,
      isLoading && "opacity-70 cursor-not-allowed",
      disabled && "opacity-50 cursor-not-allowed",
      className
    );

    return (
      <button
        ref={ref}
        className={buttonStyles}
        disabled={disabled || isLoading}
        {...props}
      >
        {isLoading ? (
          <div className="flex items-center justify-center">
            <svg
              className="animate-spin -ml-1 mr-2 h-4 w-4"
              fill="none"
              viewBox="0 0 24 24"
            >
              <circle
                className="opacity-25"
                cx="12"
                cy="12"
                r="10"
                stroke="currentColor"
                strokeWidth="4"
              ></circle>
              <path
                className="opacity-75"
                fill="currentColor"
                d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
              ></path>
            </svg>
            加载中...
          </div>
        ) : (
          <div className="flex items-center justify-center">
            {leftIcon && <span className="mr-2">{leftIcon}</span>}
            {children}
            {rightIcon && <span className="ml-2">{rightIcon}</span>}
          </div>
        )}
      </button>
    );
  }
);

Button.displayName = "Button";
// src/components/common/__tests__/Button.test.tsx - 组件测试
import { render, screen, fireEvent } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";
import { Button } from "../Button";

describe("Button", () => {
  it("should render correctly", () => {
    render(<Button>Click me</Button>);
    expect(
      screen.getByRole("button", { name: /click me/i })
    ).toBeInTheDocument();
  });

  it("should handle onClick event", () => {
    const handleClick = vi.fn();
    render(<Button onClick={handleClick}>Click me</Button>);

    fireEvent.click(screen.getByRole("button"));
    expect(handleClick).toHaveBeenCalledTimes(1);
  });

  it("should be disabled when disabled prop is true", () => {
    render(<Button disabled>Disabled button</Button>);
    expect(screen.getByRole("button")).toBeDisabled();
  });

  it("should show loading state", () => {
    render(<Button isLoading>Click me</Button>);
    expect(screen.getByText(/加载中/i)).toBeInTheDocument();
    expect(screen.getByRole("button")).toBeDisabled();
  });

  it("should render with left icon", () => {
    render(
      <Button leftIcon={<span data-testid="left-icon">🔍</span>}>Search</Button>
    );

    expect(screen.getByTestId("left-icon")).toBeInTheDocument();
    expect(screen.getByText("Search")).toBeInTheDocument();
  });

  it("should render with right icon", () => {
    render(
      <Button rightIcon={<span data-testid="right-icon"></span>}>Next</Button>
    );

    expect(screen.getByTestId("right-icon")).toBeInTheDocument();
    expect(screen.getByText("Next")).toBeInTheDocument();
  });

  it("should apply fullWidth style when fullWidth is true", () => {
    render(<Button fullWidth>Full width button</Button>);
    expect(screen.getByRole("button")).toHaveClass("w-full");
  });

  it("should apply the correct variant styles", () => {
    const { rerender } = render(<Button variant="primary">Primary</Button>);
    expect(screen.getByRole("button")).toHaveClass("bg-blue-600");

    rerender(<Button variant="secondary">Secondary</Button>);
    expect(screen.getByRole("button")).toHaveClass("bg-gray-200");

    rerender(<Button variant="danger">Danger</Button>);
    expect(screen.getByRole("button")).toHaveClass("bg-red-600");

    rerender(<Button variant="ghost">Ghost</Button>);
    expect(screen.getByRole("button")).toHaveClass("bg-transparent");
  });

  it("should apply the correct size styles", () => {
    const { rerender } = render(<Button size="small">Small</Button>);
    expect(screen.getByRole("button")).toHaveClass("py-1");

    rerender(<Button size="medium">Medium</Button>);
    expect(screen.getByRole("button")).toHaveClass("py-2");

    rerender(<Button size="large">Large</Button>);
    expect(screen.getByRole("button")).toHaveClass("py-3");
  });

  it("should merge className prop with default classes", () => {
    render(<Button className="custom-class">With custom class</Button>);
    const button = screen.getByRole("button");
    expect(button).toHaveClass("custom-class");
    expect(button).toHaveClass("bg-blue-600"); // Default primary class
  });
});

4. 集成测试:确保组件协作良好

集成测试关注多个组件的协作:

// src/components/features/LoginForm.tsx - 登录表单组件
import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { useAuthStore } from "../../stores/authStore";
import { Button } from "../common/Button";
import { TextInput } from "../common/TextInput";

const loginSchema = z.object({
  email: z.string().email("请输入有效的邮箱地址").min(1, "邮箱不能为空"),
  password: z
    .string()
    .min(6, "密码至少需要6个字符")
    .max(50, "密码不能超过50个字符"),
  rememberMe: z.boolean().optional(),
});

type LoginFormData = z.infer<typeof loginSchema>;

interface LoginFormProps {
  onSuccess?: () => void;
}

export function LoginForm({ onSuccess }: LoginFormProps) {
  const [apiError, setApiError] = (useState < string) | (null > null);
  const login = useAuthStore((state) => state.login);

  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm <
  LoginFormData >
  {
    resolver: zodResolver(loginSchema),
    defaultValues: {
      email: "",
      password: "",
      rememberMe: false,
    },
  };

  const onSubmit = async (data: LoginFormData) => {
    try {
      setApiError(null);
      await login({
        email: data.email,
        password: data.password,
        rememberMe: data.rememberMe,
      });
      onSuccess?.();
    } catch (error) {
      setApiError(
        error.response?.data?.message || "登录失败,请检查您的凭据并重试。"
      );
    }
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
      <div className="space-y-4">
        <TextInput
          label="邮箱"
          id="email"
          type="email"
          autoComplete="email"
          error={errors.email?.message}
          {...register("email")}
        />

        <TextInput
          label="密码"
          id="password"
          type="password"
          autoComplete="current-password"
          error={errors.password?.message}
          {...register("password")}
        />

        <div className="flex items-center justify-between">
          <div className="flex items-center">
            <input
              id="rememberMe"
              type="checkbox"
              className="h-4 w-4 rounded border-gray-300 text-blue-600"
              {...register("rememberMe")}
            />
            <label
              htmlFor="rememberMe"
              className="ml-2 block text-sm text-gray-700"
            >
              记住我
            </label>
          </div>

          <div className="text-sm">
            <a
              href="#"
              className="font-medium text-blue-600 hover:text-blue-500"
            >
              忘记密码?
            </a>
          </div>
        </div>
      </div>

      {apiError && (
        <div className="bg-red-50 p-3 rounded-md">
          <p className="text-sm text-red-700">{apiError}</p>
        </div>
      )}

      <Button type="submit" isLoading={isSubmitting} fullWidth>
        登录
      </Button>
    </form>
  );
}
// src/components/features/__tests__/LoginForm.test.tsx - 集成测试
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { LoginForm } from "../LoginForm";
import { useAuthStore } from "../../../stores/authStore";

// 模拟authStore
vi.mock("../../../stores/authStore", () => ({
  useAuthStore: vi.fn(),
}));

describe("LoginForm", () => {
  const mockLogin = vi.fn();
  const mockOnSuccess = vi.fn();

  beforeEach(() => {
    vi.clearAllMocks();
    useAuthStore.mockReturnValue({ login: mockLogin });
  });

  it("should render login form correctly", () => {
    render(<LoginForm onSuccess={mockOnSuccess} />);

    expect(screen.getByLabelText(/邮箱/i)).toBeInTheDocument();
    expect(screen.getByLabelText(/密码/i)).toBeInTheDocument();
    expect(screen.getByLabelText(/记住我/i)).toBeInTheDocument();
    expect(screen.getByRole("button", { name: /登录/i })).toBeInTheDocument();
    expect(screen.getByText(/忘记密码/i)).toBeInTheDocument();
  });

  it("should show validation errors for invalid inputs", async () => {
    render(<LoginForm onSuccess={mockOnSuccess} />);

    // 尝试提交空表单
    fireEvent.click(screen.getByRole("button", { name: /登录/i }));

    // 等待验证错误显示
    await waitFor(() => {
      expect(screen.getByText(/邮箱不能为空/i)).toBeInTheDocument();
      expect(screen.getByText(/密码至少需要6个字符/i)).toBeInTheDocument();
    });

    // 确保未调用登录函数
    expect(mockLogin).not.toHaveBeenCalled();
  });

  it("should submit form with valid data", async () => {
    render(<LoginForm onSuccess={mockOnSuccess} />);

    // 填写有效数据
    fireEvent.change(screen.getByLabelText(/邮箱/i), {
      target: { value: "test@example.com" },
    });

    fireEvent.change(screen.getByLabelText(/密码/i), {
      target: { value: "password123" },
    });

    fireEvent.click(screen.getByLabelText(/记住我/i));

    // 提交表单
    fireEvent.click(screen.getByRole("button", { name: /登录/i }));

    // 验证登录调用
    await waitFor(() => {
      expect(mockLogin).toHaveBeenCalledWith({
        email: "test@example.com",
        password: "password123",
        rememberMe: true,
      });
    });

    // 登录成功后应调用onSuccess
    expect(mockOnSuccess).toHaveBeenCalled();
  });

  it("should display API error message on login failure", async () => {
    // 模拟登录失败
    mockLogin.mockRejectedValueOnce({
      response: { data: { message: "用户名或密码错误" } },
    });

    render(<LoginForm onSuccess={mockOnSuccess} />);

    // 填写有效数据
    fireEvent.change(screen.getByLabelText(/邮箱/i), {
      target: { value: "test@example.com" },
    });

    fireEvent.change(screen.getByLabelText(/密码/i), {
      target: { value: "password123" },
    });

    // 提交表单
    fireEvent.click(screen.getByRole("button", { name: /登录/i }));

    // 验证错误消息显示
    await waitFor(() => {
      expect(screen.getByText(/用户名或密码错误/i)).toBeInTheDocument();
    });

    // 失败时不应调用onSuccess
    expect(mockOnSuccess).not.toHaveBeenCalled();
  });

  it("should handle generic error message when API response is incomplete", async () => {
    // 模拟没有详细错误信息的登录失败
    mockLogin.mockRejectedValueOnce({});

    render(<LoginForm onSuccess={mockOnSuccess} />);

    // 填写并提交
    fireEvent.change(screen.getByLabelText(/邮箱/i), {
      target: { value: "test@example.com" },
    });

    fireEvent.change(screen.getByLabelText(/密码/i), {
      target: { value: "password123" },
    });

    fireEvent.click(screen.getByRole("button", { name: /登录/i }));

    // 验证通用错误消息
    await waitFor(() => {
      expect(
        screen.getByText(/登录失败,请检查您的凭据并重试/i)
      ).toBeInTheDocument();
    });
  });
});

5. 端到端测试:用户体验完整性验证

最后,确保整个应用从用户角度正常工作:

// cypress/e2e/auth.cy.js - 端到端测试
describe("Authentication Flow", () => {
  beforeEach(() => {
    // 重置API模拟和应用状态
    cy.intercept("POST", "/api/auth/login", {
      statusCode: 200,
      body: {
        user: {
          id: "1",
          name: "测试用户",
          email: "test@example.com",
          role: "user",
        },
        token: "fake-jwt-token",
        refreshToken: "fake-refresh-token",
      },
    }).as("loginRequest");

    cy.visit("/auth/login");
  });

  it("should allow users to login successfully", () => {
    // 填写登录表单
    cy.get('input[name="email"]').type("test@example.com");
    cy.get('input[name="password"]').type("password123");
    cy.get('button[type="submit"]').click();

    // 确保API请求已发送
    cy.wait("@loginRequest").its("request.body").should("deep.include", {
      email: "test@example.com",
      password: "password123",
    });

    // 验证重定向到仪表板
    cy.url().should("include", "/dashboard");

    // 验证欢迎信息
    cy.contains("欢迎回来,测试用户").should("be.visible");

    // 验证本地存储中保存了令牌
    cy.window().then((win) => {
      const authData = JSON.parse(win.localStorage.getItem("auth-storage"));
      expect(authData.state.token).to.equal("fake-jwt-token");
    });
  });

  it("should show validation errors for invalid inputs", () => {
    // 空邮箱测试
    cy.get('input[name="password"]').type("password123");
    cy.get('button[type="submit"]').click();
    cy.contains("邮箱不能为空").should("be.visible");

    // 清除并添加无效邮箱
    cy.get('input[name="email"]').type("not-an-email");
    cy.get('button[type="submit"]').click();
    cy.contains("请输入有效的邮箱地址").should("be.visible");

    // 修复邮箱,密码太短
    cy.get('input[name="email"]').clear().type("test@example.com");
    cy.get('input[name="password"]').clear().type("123");
    cy.get('button[type="submit"]').click();
    cy.contains("密码至少需要6个字符").should("be.visible");

    // 验证表单未提交
    cy.get("@loginRequest.all").should("have.length", 0);
  });

  it("should handle failed login attempts", () => {
    // 模拟登录失败
    cy.intercept("POST", "/api/auth/login", {
      statusCode: 401,
      body: {
        message: "用户名或密码错误",
      },
    }).as("failedLogin");

    // 填写并提交表单
    cy.get('input[name="email"]').type("test@example.com");
    cy.get('input[name="password"]').type("wrong-password");
    cy.get('button[type="submit"]').click();

    // 等待请求完成
    cy.wait("@failedLogin");

    // 验证错误消息显示
    cy.contains("用户名或密码错误").should("be.visible");

    // 确保用户仍在登录页
    cy.url().should("include", "/auth/login");
  });

  it("should navigate to forgot password page", () => {
    cy.contains("忘记密码").click();
    cy.url().should("include", "/auth/forgot-password");
  });

  it("should navigate to registration page", () => {
    cy.contains("创建新账户").click();
    cy.url().should("include", "/auth/register");
  });

  it("should remember user login state", () => {
    // 勾选"记住我"
    cy.get('input[name="rememberMe"]').check();

    // 登录
    cy.get('input[name="email"]').type("test@example.com");
    cy.get('input[name="password"]').type("password123");
    cy.get('button[type="submit"]').click();

    // 等待登录完成并重定向
    cy.wait("@loginRequest");
    cy.url().should("include", "/dashboard");

    // 关闭当前窗口,模拟浏览器重启(Cypress限制)
    // 实际测试可以使用 cy.reload() 代替
    cy.reload();

    // 验证用户仍然登录
    cy.url().should("include", "/dashboard");
    cy.contains("测试用户").should("be.visible");
  });
});
// cypress/e2e/projectManagement.cy.js - 项目管理E2E测试
describe("Project Management", () => {
  beforeEach(() => {
    // 模拟API响应
    cy.intercept("GET", "/api/projects", {
      statusCode: 200,
      body: [
        {
          id: "1",
          name: "网站重设计",
          description: "公司网站的完整重设计项目",
          status: "active",
          startDate: "2023-01-15",
          endDate: "2023-06-30",
          priority: "high",
        },
        {
          id: "2",
          name: "移动应用开发",
          description: "iOS和Android客户端开发",
          status: "planning",
          startDate: "2023-05-01",
          endDate: null,
          priority: "medium",
        },
      ],
    }).as("getProjects");

    cy.intercept("POST", "/api/projects", {
      statusCode: 201,
      body: {
        id: "3",
        name: "新测试项目",
        description: "通过E2E测试创建的项目",
        status: "planning",
        startDate: "2023-07-01",
        endDate: "2023-12-31",
        priority: "medium",
      },
    }).as("createProject");

    cy.intercept("GET", "/api/projects/*", {
      statusCode: 200,
      body: {
        id: "1",
        name: "网站重设计",
        description: "公司网站的完整重设计项目",
        status: "active",
        startDate: "2023-01-15",
        endDate: "2023-06-30",
        priority: "high",
        tasks: [
          {
            id: "101",
            title: "竞品分析",
            status: "done",
            assignee: {
              id: "1",
              name: "张三",
            },
          },
          {
            id: "102",
            title: "设计首页原型",
            status: "in-progress",
            assignee: {
              id: "2",
              name: "李四",
            },
          },
        ],
      },
    }).as("getProjectDetails");

    // 模拟用户已登录
    cy.window().then((win) => {
      win.localStorage.setItem(
        "auth-storage",
        JSON.stringify({
          state: {
            token: "fake-jwt-token",
            user: {
              id: "1",
              name: "测试用户",
              role: "manager",
            },
            isAuthenticated: true,
          },
        })
      );
    });

    cy.visit("/projects");
  });

  it("should display list of projects", () => {
    cy.wait("@getProjects");

    // 验证项目列表显示
    cy.get('[data-testid="project-card"]').should("have.length", 2);
    cy.contains("网站重设计").should("be.visible");
    cy.contains("移动应用开发").should("be.visible");

    // 验证状态标签
    cy.contains("进行中").should("be.visible");
    cy.contains("规划中").should("be.visible");
  });

  it("should create a new project", () => {
    cy.wait("@getProjects");

    // 点击创建项目按钮
    cy.contains("创建项目").click();
    cy.url().should("include", "/projects/new");

    // 填写项目表单
    cy.get('input[name="name"]').type("新测试项目");
    cy.get('textarea[name="description"]').type("通过E2E测试创建的项目");

    // 选择开始日期 (使用日期选择器)
    cy.get('input[name="startDate"]').click();
    cy.get(".react-datepicker__day--001").click(); // 选择当月1号

    // 选择结束日期
    cy.get('input[name="endDate"]').click();
    cy.get(".react-datepicker__day--031").click(); // 选择当月31号

    // 选择优先级
    cy.get('select[name="priority"]').select("medium");

    // 选择状态
    cy.get('select[name="status"]').select("planning");

    // 提交表单
    cy.get('button[type="submit"]').click();

    // 等待API请求
    cy.wait("@createProject");

    // 验证重定向到项目列表
    cy.url().should("include", "/projects");

    // 验证成功提示
    cy.contains("项目已创建").should("be.visible");

    // 验证新项目添加到列表(需要重新模拟GET请求以包含新项目)
    cy.intercept("GET", "/api/projects", {
      statusCode: 200,
      body: [
        {
          id: "1",
          name: "网站重设计",
          description: "公司网站的完整重设计项目",
          status: "active",
          startDate: "2023-01-15",
          endDate: "2023-06-30",
          priority: "high",
        },
        {
          id: "2",
          name: "移动应用开发",
          description: "iOS和Android客户端开发",
          status: "planning",
          startDate: "2023-05-01",
          endDate: null,
          priority: "medium",
        },
        {
          id: "3",
          name: "新测试项目",
          description: "通过E2E测试创建的项目",
          status: "planning",
          startDate: "2023-07-01",
          endDate: "2023-12-31",
          priority: "medium",
        },
      ],
    }).as("getUpdatedProjects");

    cy.visit("/projects");
    cy.wait("@getUpdatedProjects");

    cy.get('[data-testid="project-card"]').should("have.length", 3);
    cy.contains("新测试项目").should("be.visible");
  });

  it("should navigate to project details", () => {
    cy.wait("@getProjects");

    // 点击第一个项目
    cy.contains("网站重设计").click();

    // 验证导航到项目详情页
    cy.url().should("include", "/projects/1");

    // 等待项目详情加载
    cy.wait("@getProjectDetails");

    // 验证项目详情显示
    cy.contains("网站重设计").should("be.visible");
    cy.contains("公司网站的完整重设计项目").should("be.visible");

    // 验证任务列表
    cy.contains("竞品分析").should("be.visible");
    cy.contains("设计首页原型").should("be.visible");

    // 验证项目状态信息
    cy.contains("优先级").next().contains("高").should("be.visible");
    cy.contains("状态").next().contains("进行中").should("be.visible");
  });

  it("should filter projects by status", () => {
    cy.wait("@getProjects");

    // 选择"进行中"状态过滤
    cy.get('select[data-testid="status-filter"]').select("active");

    // 验证只显示进行中的项目
    cy.get('[data-testid="project-card"]').should("have.length", 1);
    cy.contains("网站重设计").should("be.visible");
    cy.contains("移动应用开发").should("not.exist");

    // 选择"规划中"状态过滤
    cy.get('select[data-testid="status-filter"]').select("planning");

    // 验证只显示规划中的项目
    cy.get('[data-testid="project-card"]').should("have.length", 1);
    cy.contains("移动应用开发").should("be.visible");
    cy.contains("网站重设计").should("not.exist");

    // 重置过滤器
    cy.get('select[data-testid="status-filter"]').select("all");

    // 验证显示所有项目
    cy.get('[data-testid="project-card"]').should("have.length", 2);
  });

  it("should search projects by name", () => {
    cy.wait("@getProjects");

    // 搜索"网站"
    cy.get('input[data-testid="search-input"]').type("网站");

    // 验证搜索结果
    cy.get('[data-testid="project-card"]').should("have.length", 1);
    cy.contains("网站重设计").should("be.visible");

    // 清除搜索,搜索"移动"
    cy.get('input[data-testid="search-input"]').clear().type("移动");

    // 验证搜索结果
    cy.get('[data-testid="project-card"]').should("have.length", 1);
    cy.contains("移动应用开发").should("be.visible");

    // 搜索无结果的术语
    cy.get('input[data-testid="search-input"]').clear().type("不存在的项目");

    // 验证无结果提示
    cy.get('[data-testid="project-card"]').should("have.length", 0);
    cy.contains("没有找到匹配的项目").should("be.visible");
  });
});

6. 测试驱动开发(TDD)实践

现在,让我们体验 TDD 的工作流,先编写测试,再实现功能:

// src/hooks/__tests__/useDebounce.test.ts - 先写测试
import { renderHook, act } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { useDebounce } from "../useDebounce";

describe("useDebounce", () => {
  beforeEach(() => {
    vi.useFakeTimers();
  });

  afterEach(() => {
    vi.useRealTimers();
  });

  it("should return the initial value immediately", () => {
    const { result } = renderHook(() => useDebounce("initial", 500));
    expect(result.current).toBe("initial");
  });

  it("should update the value after the specified delay", () => {
    const { result, rerender } = renderHook(
      ({ value, delay }) => useDebounce(value, delay),
      { initialProps: { value: "initial", delay: 500 } }
    );

    // 初始值
    expect(result.current).toBe("initial");

    // 更新值
    rerender({ value: "updated", delay: 500 });

    // 延迟前值不变
    expect(result.current).toBe("initial");

    // 快进到延迟一半
    act(() => {
      vi.advanceTimersByTime(250);
    });

    // 值仍未更新
    expect(result.current).toBe("initial");

    // 快进超过延迟时间
    act(() => {
      vi.advanceTimersByTime(300);
    });

    // 值应该更新
    expect(result.current).toBe("updated");
  });

  it("should cancel previous debounce when value changes", () => {
    const { result, rerender } = renderHook(
      ({ value, delay }) => useDebounce(value, delay),
      { initialProps: { value: "initial", delay: 500 } }
    );

    // 更新为值1
    rerender({ value: "value1", delay: 500 });

    // 快进但不到延迟时间
    act(() => {
      vi.advanceTimersByTime(300);
    });

    // 更新为值2
    rerender({ value: "value2", delay: 500 });

    // 快进到第一次延迟后
    act(() => {
      vi.advanceTimersByTime(300);
    });

    // 值不应该是value1
    expect(result.current).not.toBe("value1");

    // 快进到第二次延迟后
    act(() => {
      vi.advanceTimersByTime(200);
    });

    // 值应该是value2
    expect(result.current).toBe("value2");
  });

  it("should handle delay changes", () => {
    const { result, rerender } = renderHook(
      ({ value, delay }) => useDebounce(value, delay),
      { initialProps: { value: "initial", delay: 500 } }
    );

    // 更新值并修改延迟
    rerender({ value: "updated", delay: 1000 });

    // 快进到旧延迟后
    act(() => {
      vi.advanceTimersByTime(600);
    });

    // 值不应该更新
    expect(result.current).toBe("initial");

    // 快进到新延迟后
    act(() => {
      vi.advanceTimersByTime(500);
    });

    // 值应该更新
    expect(result.current).toBe("updated");
  });
});
// src/hooks/useDebounce.ts - 根据测试实现功能
import { useState, useEffect } from "react";

/**
 * 创建一个防抖值,只有在指定延迟后值没有再次变化时才更新
 * @param value 要防抖的值
 * @param delay 延迟时间(毫秒)
 * @returns 防抖后的值
 */
export function useDebounce<T>(value: T, delay: number): T {
  const [debouncedValue, setDebouncedValue] = useState < T > value;

  useEffect(() => {
    // 设置定时器在指定延迟后更新
    const timer = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);

    // 清理函数:当value或delay变化时取消之前的定时器
    return () => clearTimeout(timer);
  }, [value, delay]);

  return debouncedValue;
}

7. 测试覆盖率与持续集成

测试只有在持续执行时才有价值,让我们设置 CI/CD:

# .github/workflows/test.yml - GitHub Actions配置
name: Test

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main, develop]

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v3

      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: 18
          cache: "npm"

      - name: Install dependencies
        run: npm ci

      - name: Lint
        run: npm run lint

      - name: Type check
        run: npm run typecheck

      - name: Run unit and integration tests
        run: npm run test:coverage

      - name: Upload test coverage
        uses: codecov/codecov-action@v3
        with:
          token: ${{ secrets.CODECOV_TOKEN }}

      - name: Build
        run: npm run build

      - name: Run E2E tests
        uses: cypress-io/github-action@v6
        with:
          start: npm run preview
          wait-on: "http://localhost:4173"
// package.json - 测试脚本配置
{
  "scripts": {
    "test": "vitest run",
    "test:watch": "vitest",
    "test:coverage": "vitest run --coverage",
    "test:e2e": "cypress run",
    "test:e2e:open": "cypress open",
    "typecheck": "tsc --noEmit",
    "lint": "eslint src --ext .ts,.tsx"
  }
}

下一篇预告:《【React SSR 与 SSG】服务端渲染与静态生成实战指南》

在系列的下一篇中,我们将探索如何提升 React 应用的性能与 SEO:

  • 从客户端渲染(CSR)到服务端渲染(SSR)
  • 基于 Next.js 的全栈 React 应用开发
  • 静态站点生成(SSG)与增量静态再生(ISR)
  • 服务端组件与 RSC 架构
  • 部署与缓存优化策略

随着 React 应用复杂度的提升,选择合适的渲染策略变得至关重要。下一篇,我们将帮助你做出最佳选择!

敬请期待!

关于作者

Hi,我是 hyy,一位热爱技术的全栈开发者:

  • 🚀 专注 TypeScript 全栈开发,偏前端技术栈
  • 💼 多元工作背景(跨国企业、技术外包、创业公司)
  • 📝 掘金活跃技术作者
  • 🎵 电子音乐爱好者
  • 🎮 游戏玩家
  • 💻 技术分享达人

加入我们

欢迎加入前端技术交流圈,与 10000+开发者一起:

  • 探讨前端最新技术趋势
  • 解决开发难题
  • 分享职场经验
  • 获取优质学习资源

添加方式:掘金摸鱼沸点 👈 扫码进群