首页 最新 热门 推荐

  • 首页
  • 最新
  • 热门
  • 推荐

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

  • 25-04-18 11:00
  • 4735
  • 6533
juejin.cn

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

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

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

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

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

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

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

bash
代码解读
复制代码
/\ / \ / \ / E2E \ 少量端到端测试 /--------\ / \ / 集成测试 \ 适量集成测试 /--------------\ / \ / 单元测试 \ 大量单元测试 /------------------ \

测试配置与环境搭建:

bash
代码解读
复制代码
# 安装核心测试依赖 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
js
代码解读
复制代码
// 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, }, }, });
js
代码解读
复制代码
// 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. 单元测试:基础构建块

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

工具函数测试
js
代码解读
复制代码
// 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) + "..."; }
js
代码解读
复制代码
// 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 测试
js
代码解读
复制代码
// src/hooks/useLocalStorage.ts - 自定义Hook import { useState, useEffect } from 'react'; function useLocalStorage(key: string, initialValue: T) { // 状态初始化 const [storedValue, setStoredValue] = useState(() => { 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;
js
代码解读
复制代码
// 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 组件的渲染和交互:

tsx
代码解读
复制代码
// 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";
tsx
代码解读
复制代码
// 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 meButton>); expect( screen.getByRole("button", { name: /click me/i }) ).toBeInTheDocument(); }); it("should handle onClick event", () => { const handleClick = vi.fn(); render(<Button onClick={handleClick}>Click meButton>); fireEvent.click(screen.getByRole("button")); expect(handleClick).toHaveBeenCalledTimes(1); }); it("should be disabled when disabled prop is true", () => { render(<Button disabled>Disabled buttonButton>); expect(screen.getByRole("button")).toBeDisabled(); }); it("should show loading state", () => { render(<Button isLoading>Click meButton>); 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>}>SearchButton> ); 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>}>NextButton> ); 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 buttonButton>); expect(screen.getByRole("button")).toHaveClass("w-full"); }); it("should apply the correct variant styles", () => { const { rerender } = render(<Button variant="primary">PrimaryButton>); expect(screen.getByRole("button")).toHaveClass("bg-blue-600"); rerender(<Button variant="secondary">SecondaryButton>); expect(screen.getByRole("button")).toHaveClass("bg-gray-200"); rerender(<Button variant="danger">DangerButton>); expect(screen.getByRole("button")).toHaveClass("bg-red-600"); rerender(<Button variant="ghost">GhostButton>); expect(screen.getByRole("button")).toHaveClass("bg-transparent"); }); it("should apply the correct size styles", () => { const { rerender } = render(<Button size="small">SmallButton>); expect(screen.getByRole("button")).toHaveClass("py-1"); rerender(<Button size="medium">MediumButton>); expect(screen.getByRole("button")).toHaveClass("py-2"); rerender(<Button size="large">LargeButton>); expect(screen.getByRole("button")).toHaveClass("py-3"); }); it("should merge className prop with default classes", () => { render(<Button className="custom-class">With custom classButton>); const button = screen.getByRole("button"); expect(button).toHaveClass("custom-class"); expect(button).toHaveClass("bg-blue-600"); // Default primary class }); });

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

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

tsx
代码解读
复制代码
// 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> ); }
tsx
代码解读
复制代码
// 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: "[email protected]" }, }); 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: "[email protected]", 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: "[email protected]" }, }); 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: "[email protected]" }, }); fireEvent.change(screen.getByLabelText(/密码/i), { target: { value: "password123" }, }); fireEvent.click(screen.getByRole("button", { name: /登录/i })); // 验证通用错误消息 await waitFor(() => { expect( screen.getByText(/登录失败,请检查您的凭据并重试/i) ).toBeInTheDocument(); }); }); });

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

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

js
代码解读
复制代码
// cypress/e2e/auth.cy.js - 端到端测试 describe("Authentication Flow", () => { beforeEach(() => { // 重置API模拟和应用状态 cy.intercept("POST", "/api/auth/login", { statusCode: 200, body: { user: { id: "1", name: "测试用户", email: "[email protected]", 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("[email protected]"); cy.get('input[name="password"]').type("password123"); cy.get('button[type="submit"]').click(); // 确保API请求已发送 cy.wait("@loginRequest").its("request.body").should("deep.include", { email: "[email protected]", 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("[email protected]"); 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("[email protected]"); 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("[email protected]"); 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"); }); });
js
代码解读
复制代码
// 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 的工作流,先编写测试,再实现功能:

ts
代码解读
复制代码
// 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"); }); });
ts
代码解读
复制代码
// src/hooks/useDebounce.ts - 根据测试实现功能 import { useState, useEffect } from "react"; /** * 创建一个防抖值,只有在指定延迟后值没有再次变化时才更新 * @param value 要防抖的值 * @param delay 延迟时间(毫秒) * @returns 防抖后的值 */ export function useDebounce(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:

yaml
代码解读
复制代码
# .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"
json
代码解读
复制代码
// 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+开发者一起:

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

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

注:本文转载自juejin.cn的小钰能吃三碗饭的文章"https://juejin.cn/post/7493824873639460879"。版权归原作者所有,此博客不拥有其著作权,亦不承担相应法律责任。如有侵权,请联系我们删除。
复制链接
复制链接
相关推荐
发表评论
登录后才能发表评论和回复 注册

/ 登录

评论记录:

未查询到任何数据!
回复评论:

分类栏目

后端 (14832) 前端 (14280) 移动开发 (3760) 编程语言 (3851) Java (3904) Python (3298) 人工智能 (10119) AIGC (2810) 大数据 (3499) 数据库 (3945) 数据结构与算法 (3757) 音视频 (2669) 云原生 (3145) 云平台 (2965) 前沿技术 (2993) 开源 (2160) 小程序 (2860) 运维 (2533) 服务器 (2698) 操作系统 (2325) 硬件开发 (2491) 嵌入式 (2955) 微软技术 (2769) 软件工程 (2056) 测试 (2865) 网络空间安全 (2948) 网络与通信 (2797) 用户体验设计 (2592) 学习和成长 (2593) 搜索 (2744) 开发工具 (7108) 游戏 (2829) HarmonyOS (2935) 区块链 (2782) 数学 (3112) 3C硬件 (2759) 资讯 (2909) Android (4709) iOS (1850) 代码人生 (3043) 阅读 (2841)

热门文章

104
前端
关于我们 隐私政策 免责声明 联系我们
Copyright © 2020-2025 蚁人论坛 (iYenn.com) All Rights Reserved.
Scroll to Top