第九篇:【前端自动化测试】从零搭建 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+开发者一起:
- 探讨前端最新技术趋势
- 解决开发难题
- 分享职场经验
- 获取优质学习资源
添加方式:掘金摸鱼沸点 👈 扫码进群
评论记录:
回复评论: