软件测试策略完全指南:单元测试、集成测试、E2E、TDD、BDD 与 CI 测试
掌握软件测试策略:使用 Jest/Vitest 进行单元测试、集成测试、Playwright/Cypress E2E 测试、TDD/BDD 工作流、测试金字塔、Mock 模式、测试覆盖率、CI 测试管道、性能测试以及快照/视觉回归测试的实战示例。
- 测试金字塔:大量单元测试 > 适量集成测试 > 少量 E2E 测试
- TDD(红-绿-重构)确保每行代码都由测试驱动,提高设计质量
- Jest/Vitest 用于单元和集成测试;Playwright/Cypress 用于 E2E 测试
- Mock 外部依赖,而非内部实现;使用 MSW 进行 API Mock
- 目标 80% 覆盖率,聚焦业务关键路径和错误处理
- CI 中按速度分层运行:单元 → 集成 → E2E,并行化加速
- 测试金字塔是平衡速度、成本和信心的最佳策略
- TDD 不仅是测试方法,更是一种设计方法论
- Vitest 是 Vite 项目的首选;Jest 是非 Vite 项目的标准
- Playwright 胜在跨浏览器和 CI 速度;Cypress 胜在开发体验
- 好的 Mock 策略让测试既可靠又易于维护
- 覆盖率是发现盲区的工具,而非质量的唯一指标
- CI 测试管道应该快速、可靠、并提供清晰的失败报告
1. 测试金字塔与测试策略
测试金字塔由 Mike Cohn 提出,是组织测试层次的经典模型。底层是大量快速的单元测试,中层是适量的集成测试,顶层是少量但覆盖完整用户流程的端到端测试。
| 层级 | 速度 | 成本 | 信心 | 占比 |
|---|---|---|---|---|
| 单元测试 | 极快 (<10ms) | 低 | 中等 | 70% |
| 集成测试 | 中等 (100ms-5s) | 中等 | 高 | 20% |
| E2E 测试 | 慢 (5s-60s) | 高 | 最高 | 10% |
测试奖杯(Testing Trophy)是 Kent C. Dodds 提出的替代模型,强调集成测试是最有价值的层级,因为它以合理的成本提供了最高的信心。在现代前端开发中,这种策略尤其适用。
2. 单元测试:Jest 与 Vitest
单元测试验证独立函数或组件的行为。测试应该快速、确定性、且不依赖外部服务。
Jest 基础示例
// math.ts
export function add(a: number, b: number): number {
return a + b;
}
export function divide(a: number, b: number): number {
if (b === 0) throw new Error("Division by zero");
return a / b;
}
export function fibonacci(n: number): number {
if (n <= 1) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
}
// math.test.ts
import { add, divide, fibonacci } from "./math";
describe("Math utilities", () => {
describe("add", () => {
it("adds two positive numbers", () => {
expect(add(2, 3)).toBe(5);
});
it("handles negative numbers", () => {
expect(add(-1, -2)).toBe(-3);
});
it("handles zero", () => {
expect(add(0, 5)).toBe(5);
});
});
describe("divide", () => {
it("divides two numbers", () => {
expect(divide(10, 2)).toBe(5);
});
it("throws on division by zero", () => {
expect(() => divide(10, 0)).toThrow("Division by zero");
});
});
describe("fibonacci", () => {
it.each([
[0, 0], [1, 1], [2, 1], [5, 5], [10, 55],
])("fibonacci(%i) === %i", (input, expected) => {
expect(fibonacci(input)).toBe(expected);
});
});
});Vitest 配置与示例
// vitest.config.ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
environment: "jsdom",
setupFiles: ["./src/test/setup.ts"],
coverage: {
provider: "v8",
reporter: ["text", "json", "html"],
thresholds: {
lines: 80,
branches: 80,
functions: 80,
statements: 80,
},
},
include: ["src/**/*.{test,spec}.{ts,tsx}"],
},
});
// src/utils/string.ts
export function slugify(text: string): string {
return text
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, "")
.replace(/[\s_]+/g, "-")
.replace(/-+/g, "-");
}
// src/utils/string.test.ts
import { describe, it, expect } from "vitest";
import { slugify } from "./string";
describe("slugify", () => {
it("converts text to kebab-case", () => {
expect(slugify("Hello World")).toBe("hello-world");
});
it("removes special characters", () => {
expect(slugify("Hello! @World#")).toBe("hello-world");
});
it("collapses multiple dashes", () => {
expect(slugify("a---b")).toBe("a-b");
});
it("trims whitespace", () => {
expect(slugify(" hello ")).toBe("hello");
});
});Jest vs Vitest 对比
| 特性 | Jest | Vitest |
|---|---|---|
| 速度 | 较快(使用 worker 并行) | 极快(原生 ESM + Vite) |
| ESM 支持 | 实验性 | 原生支持 |
| TypeScript | 需要 ts-jest / babel | 内置支持 |
| 配置 | jest.config.ts | 共用 vite.config.ts |
| 覆盖率 | Istanbul / c8 | v8 / Istanbul |
| 生态系统 | 最大(插件、匹配器) | 增长中,兼容 Jest API |
3. React 组件测试
使用 React Testing Library 测试组件的行为而非实现细节。遵循"用户如何交互"的原则编写测试。
// LoginForm.tsx
import { useState } from "react";
interface LoginFormProps {
onSubmit: (email: string, password: string) => Promise<void>;
}
export function LoginForm({ onSubmit }: LoginFormProps) {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
setLoading(true);
try {
await onSubmit(email, password);
} catch (err) {
setError(err instanceof Error ? err.message : "Login failed");
} finally {
setLoading(false);
}
};
return (
<form onSubmit={handleSubmit}>
<input
type="email" placeholder="Email"
value={email} onChange={(e) => setEmail(e.target.value)}
/>
<input
type="password" placeholder="Password"
value={password} onChange={(e) => setPassword(e.target.value)}
/>
{error && <div role="alert">{error}</div>}
<button type="submit" disabled={loading}>
{loading ? "Signing in..." : "Sign In"}
</button>
</form>
);
}
// LoginForm.test.tsx
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { LoginForm } from "./LoginForm";
describe("LoginForm", () => {
const user = userEvent.setup();
it("submits with email and password", async () => {
const mockSubmit = vi.fn().mockResolvedValue(undefined);
render(<LoginForm onSubmit={mockSubmit} />);
await user.type(screen.getByPlaceholderText("Email"), "test@example.com");
await user.type(screen.getByPlaceholderText("Password"), "secret123");
await user.click(screen.getByRole("button", { name: /sign in/i }));
await waitFor(() => {
expect(mockSubmit).toHaveBeenCalledWith("test@example.com", "secret123");
});
});
it("shows error message on failure", async () => {
const mockSubmit = vi.fn().mockRejectedValue(new Error("Invalid credentials"));
render(<LoginForm onSubmit={mockSubmit} />);
await user.type(screen.getByPlaceholderText("Email"), "bad@example.com");
await user.type(screen.getByPlaceholderText("Password"), "wrong");
await user.click(screen.getByRole("button", { name: /sign in/i }));
expect(await screen.findByRole("alert")).toHaveTextContent("Invalid credentials");
});
it("disables button while loading", async () => {
const mockSubmit = vi.fn(() => new Promise(() => {})); // never resolves
render(<LoginForm onSubmit={mockSubmit} />);
await user.type(screen.getByPlaceholderText("Email"), "test@example.com");
await user.type(screen.getByPlaceholderText("Password"), "secret123");
await user.click(screen.getByRole("button", { name: /sign in/i }));
expect(screen.getByRole("button")).toBeDisabled();
expect(screen.getByRole("button")).toHaveTextContent("Signing in...");
});
});4. 测试驱动开发(TDD)
TDD 遵循"红-绿-重构"循环:先写失败测试,再写最少代码使其通过,最后重构。这个过程确保你只写必要的代码,并且每行代码都有对应的测试。
TDD 实战:构建购物车
// Step 1: RED — Write a failing test
// cart.test.ts
import { Cart } from "./cart";
describe("Cart", () => {
let cart: Cart;
beforeEach(() => {
cart = new Cart();
});
it("starts empty", () => {
expect(cart.items).toEqual([]);
expect(cart.total).toBe(0);
});
it("adds an item", () => {
cart.add({ id: "1", name: "Widget", price: 9.99, quantity: 1 });
expect(cart.items).toHaveLength(1);
expect(cart.total).toBe(9.99);
});
it("increases quantity for duplicate items", () => {
cart.add({ id: "1", name: "Widget", price: 9.99, quantity: 1 });
cart.add({ id: "1", name: "Widget", price: 9.99, quantity: 2 });
expect(cart.items).toHaveLength(1);
expect(cart.items[0].quantity).toBe(3);
});
it("removes an item", () => {
cart.add({ id: "1", name: "Widget", price: 9.99, quantity: 1 });
cart.remove("1");
expect(cart.items).toHaveLength(0);
});
it("applies percentage discount", () => {
cart.add({ id: "1", name: "Widget", price: 100, quantity: 1 });
cart.applyDiscount({ type: "percent", value: 10 });
expect(cart.total).toBe(90);
});
});
// Step 2: GREEN — Implement minimum code
// cart.ts
interface CartItem {
id: string;
name: string;
price: number;
quantity: number;
}
interface Discount {
type: "percent" | "fixed";
value: number;
}
export class Cart {
items: CartItem[] = [];
private discount: Discount | null = null;
get total(): number {
const subtotal = this.items.reduce(
(sum, item) => sum + item.price * item.quantity, 0
);
if (!this.discount) return subtotal;
if (this.discount.type === "percent") {
return subtotal * (1 - this.discount.value / 100);
}
return Math.max(0, subtotal - this.discount.value);
}
add(item: CartItem): void {
const existing = this.items.find((i) => i.id === item.id);
if (existing) {
existing.quantity += item.quantity;
} else {
this.items.push({ ...item });
}
}
remove(id: string): void {
this.items = this.items.filter((i) => i.id !== id);
}
applyDiscount(discount: Discount): void {
this.discount = discount;
}
}5. 行为驱动开发(BDD)
BDD 使用 Given-When-Then 结构编写人人可读的测试规格。Gherkin 语法让产品经理和开发者可以共同定义验收标准。
# features/login.feature (Gherkin)
Feature: User Login
As a registered user
I want to log in to my account
So that I can access my dashboard
Scenario: Successful login
Given I am on the login page
When I enter "user@example.com" as email
And I enter "validpass123" as password
And I click the "Sign In" button
Then I should be redirected to the dashboard
And I should see "Welcome back" message
Scenario: Failed login with wrong password
Given I am on the login page
When I enter "user@example.com" as email
And I enter "wrongpass" as password
And I click the "Sign In" button
Then I should see "Invalid credentials" error
And I should remain on the login page
// step-definitions/login.steps.ts (Jest-Cucumber)
import { defineFeature, loadFeature } from "jest-cucumber";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
const feature = loadFeature("./features/login.feature");
defineFeature(feature, (test) => {
test("Successful login", ({ given, when, and, then }) => {
given("I am on the login page", () => {
render(<LoginPage />);
});
when(/I enter "(.*)" as email/, (email) => {
userEvent.type(screen.getByLabelText("Email"), email);
});
and(/I enter "(.*)" as password/, (password) => {
userEvent.type(screen.getByLabelText("Password"), password);
});
and(/I click the "(.*)" button/, (buttonText) => {
userEvent.click(screen.getByRole("button", { name: buttonText }));
});
then("I should be redirected to the dashboard", () => {
expect(window.location.pathname).toBe("/dashboard");
});
});
});6. Mock 策略与模式
Mock 的核心原则:Mock 你不拥有的东西(外部 API、数据库),不要 Mock 你拥有的东西(内部模块)。优先使用依赖注入使代码可测试。
函数 Mock 与 Spy
// Mocking modules
import { vi, describe, it, expect, beforeEach } from "vitest";
import { fetchUser } from "./api";
import { UserService } from "./user-service";
// Mock the entire api module
vi.mock("./api", () => ({
fetchUser: vi.fn(),
}));
describe("UserService", () => {
beforeEach(() => {
vi.clearAllMocks(); // Reset between tests
});
it("returns user data", async () => {
const mockUser = { id: 1, name: "Alice", email: "alice@test.com" };
vi.mocked(fetchUser).mockResolvedValue(mockUser);
const service = new UserService();
const user = await service.getUser(1);
expect(fetchUser).toHaveBeenCalledWith(1);
expect(user).toEqual(mockUser);
});
it("handles API errors gracefully", async () => {
vi.mocked(fetchUser).mockRejectedValue(new Error("Network error"));
const service = new UserService();
const user = await service.getUser(1);
expect(user).toBeNull();
});
});
// Spies — verify calls without replacing implementation
it("logs when user is fetched", async () => {
const consoleSpy = vi.spyOn(console, "log");
const service = new UserService();
await service.getUser(1);
expect(consoleSpy).toHaveBeenCalledWith("Fetching user:", 1);
consoleSpy.mockRestore();
});MSW(Mock Service Worker)
// mocks/handlers.ts
import { http, HttpResponse } from "msw";
export const handlers = [
http.get("/api/users/:id", ({ params }) => {
return HttpResponse.json({
id: Number(params.id),
name: "Alice",
email: "alice@test.com",
});
}),
http.post("/api/users", async ({ request }) => {
const body = await request.json();
return HttpResponse.json(
{ id: 42, ...body },
{ status: 201 }
);
}),
http.get("/api/users", ({ request }) => {
const url = new URL(request.url);
const page = url.searchParams.get("page") || "1";
return HttpResponse.json({
users: [{ id: 1, name: "Alice" }],
page: Number(page),
totalPages: 5,
});
}),
];
// mocks/server.ts
import { setupServer } from "msw/node";
import { handlers } from "./handlers";
export const server = setupServer(...handlers);
// test/setup.ts
import { beforeAll, afterEach, afterAll } from "vitest";
import { server } from "../mocks/server";
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());7. 集成测试
集成测试验证多个模块协同工作的正确性。与单元测试不同,集成测试使用真实(或接近真实)的依赖。
API 路由集成测试
// Testing Express/Fastify API routes with Supertest
import request from "supertest";
import { app } from "./app";
import { db } from "./database";
describe("POST /api/users", () => {
beforeAll(async () => {
await db.migrate.latest(); // Run migrations
});
afterEach(async () => {
await db("users").truncate(); // Clean between tests
});
afterAll(async () => {
await db.destroy(); // Close connection
});
it("creates a new user", async () => {
const response = await request(app)
.post("/api/users")
.send({ name: "Alice", email: "alice@test.com" })
.expect(201);
expect(response.body).toMatchObject({
id: expect.any(Number),
name: "Alice",
email: "alice@test.com",
});
// Verify database state
const user = await db("users")
.where("email", "alice@test.com").first();
expect(user).toBeDefined();
});
it("rejects duplicate email", async () => {
await request(app)
.post("/api/users")
.send({ name: "Alice", email: "alice@test.com" });
const response = await request(app)
.post("/api/users")
.send({ name: "Bob", email: "alice@test.com" })
.expect(409);
expect(response.body.error).toBe("Email already exists");
});
it("validates required fields", async () => {
const response = await request(app)
.post("/api/users")
.send({ name: "" })
.expect(400);
expect(response.body.errors).toContainEqual(
expect.objectContaining({ field: "email", message: "Required" })
);
});
});数据库集成测试(Testcontainers)
// Using Testcontainers for real database testing
import { PostgreSqlContainer } from "@testcontainers/postgresql";
import { Pool } from "pg";
describe("UserRepository", () => {
let container;
let pool: Pool;
beforeAll(async () => {
container = await new PostgreSqlContainer()
.withDatabase("testdb")
.start();
pool = new Pool({
connectionString: container.getConnectionUri(),
});
// Run migrations
await pool.query(`
CREATE TABLE users (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL
)
`);
}, 60000); // Testcontainers can take time to start
afterAll(async () => {
await pool.end();
await container.stop();
});
it("inserts and retrieves a user", async () => {
await pool.query(
"INSERT INTO users (name, email) VALUES ($1, $2)",
["Alice", "alice@test.com"]
);
const result = await pool.query(
"SELECT * FROM users WHERE email = $1",
["alice@test.com"]
);
expect(result.rows[0]).toMatchObject({
name: "Alice",
email: "alice@test.com",
});
});
});8. 端到端测试:Playwright 与 Cypress
E2E 测试模拟真实用户操作,验证从 UI 到后端的完整流程。Playwright 和 Cypress 是两大主流 E2E 框架。
Playwright 示例
// playwright.config.ts
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./e2e",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: [
["html"],
["junit", { outputFile: "results/e2e.xml" }],
],
use: {
baseURL: "http://localhost:3000",
trace: "on-first-retry",
screenshot: "only-on-failure",
},
projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
{ name: "firefox", use: { ...devices["Desktop Firefox"] } },
{ name: "webkit", use: { ...devices["Desktop Safari"] } },
{ name: "mobile", use: { ...devices["iPhone 14"] } },
],
webServer: {
command: "npm run dev",
port: 3000,
reuseExistingServer: !process.env.CI,
},
});
// e2e/checkout.spec.ts
import { test, expect } from "@playwright/test";
test.describe("Checkout Flow", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/products");
});
test("complete purchase flow", async ({ page }) => {
// Add item to cart
await page.getByRole("button", { name: "Add to Cart" }).first().click();
await expect(page.getByTestId("cart-count")).toHaveText("1");
// Go to cart
await page.getByRole("link", { name: "Cart" }).click();
await expect(page).toHaveURL(/\/cart/);
// Proceed to checkout
await page.getByRole("button", { name: "Checkout" }).click();
// Fill shipping info
await page.getByLabel("Full Name").fill("Alice Smith");
await page.getByLabel("Address").fill("123 Main St");
await page.getByLabel("City").fill("Portland");
await page.getByRole("button", { name: "Continue" }).click();
// Confirm order
await expect(page.getByText("Order Summary")).toBeVisible();
await page.getByRole("button", { name: "Place Order" }).click();
// Verify success
await expect(page.getByText("Order Confirmed")).toBeVisible();
await expect(page.getByTestId("order-number")).toBeVisible();
});
test("handles empty cart", async ({ page }) => {
await page.goto("/cart");
await expect(page.getByText("Your cart is empty")).toBeVisible();
await expect(
page.getByRole("link", { name: "Continue Shopping" })
).toBeVisible();
});
});Cypress 示例
// cypress/e2e/auth.cy.ts
describe("Authentication", () => {
beforeEach(() => {
cy.visit("/login");
});
it("logs in successfully", () => {
cy.intercept("POST", "/api/auth/login", {
statusCode: 200,
body: { token: "fake-jwt-token", user: { name: "Alice" } },
}).as("loginRequest");
cy.get("[data-testid=email-input]").type("alice@test.com");
cy.get("[data-testid=password-input]").type("password123");
cy.get("[data-testid=submit-btn]").click();
cy.wait("@loginRequest");
cy.url().should("include", "/dashboard");
cy.contains("Welcome, Alice").should("be.visible");
});
it("shows validation errors", () => {
cy.get("[data-testid=submit-btn]").click();
cy.contains("Email is required").should("be.visible");
cy.contains("Password is required").should("be.visible");
});
it("handles network errors gracefully", () => {
cy.intercept("POST", "/api/auth/login", {
forceNetworkError: true,
}).as("loginFailure");
cy.get("[data-testid=email-input]").type("alice@test.com");
cy.get("[data-testid=password-input]").type("password123");
cy.get("[data-testid=submit-btn]").click();
cy.contains("Network error").should("be.visible");
});
});Playwright vs Cypress 对比
| 特性 | Playwright | Cypress |
|---|---|---|
| 浏览器支持 | Chromium, Firefox, WebKit | Chrome, Edge, Firefox |
| 多标签/多源 | 原生支持 | 有限支持 |
| 等待策略 | 自动等待 + Web-first 断言 | 内置重试 |
| 调试 | Trace Viewer, Inspector | 时间旅行调试器 |
| 网络拦截 | 强大(route, fulfill, abort) | 强大(intercept) |
| CI 速度 | 更快(无头 + 并行上下文) | 较慢(需要 Xvfb) |
| 语言 | TS, JS, Python, C#, Java | TS, JS |
9. 测试覆盖率
覆盖率工具衡量有多少代码被测试执行到。主要指标包括:行覆盖率、分支覆盖率、函数覆盖率和语句覆盖率。
// Configure coverage in Vitest
// vitest.config.ts
export default defineConfig({
test: {
coverage: {
provider: "v8",
reporter: ["text", "json-summary", "html", "lcov"],
include: ["src/**/*.{ts,tsx}"],
exclude: [
"src/**/*.test.{ts,tsx}",
"src/**/*.d.ts",
"src/test/**",
"src/types/**",
],
thresholds: {
lines: 80,
branches: 75,
functions: 80,
statements: 80,
},
// Watermarks for color-coding in reports
watermarks: {
lines: [60, 80],
branches: [50, 75],
functions: [60, 80],
statements: [60, 80],
},
},
},
});
// Run coverage:
// vitest run --coverage
// jest --coverage
// Configure coverage in Jest
// jest.config.ts
export default {
collectCoverageFrom: [
"src/**/*.{ts,tsx}",
"!src/**/*.d.ts",
"!src/**/*.test.{ts,tsx}",
],
coverageThreshold: {
global: {
branches: 75,
functions: 80,
lines: 80,
statements: 80,
},
// Per-file thresholds for critical modules
"./src/services/payment.ts": {
branches: 95,
functions: 100,
lines: 95,
},
},
coverageReporters: ["text", "lcov", "json-summary"],
};覆盖率最佳实践
| 实践 | 说明 |
|---|---|
| 设定合理阈值 | 全局 80% 行覆盖率,关键模块 95%+ |
| 关注分支覆盖 | 分支覆盖比行覆盖更能发现缺失的边界情况 |
| 不追求 100% | 对 getter/setter 和样板代码测试没有意义 |
| CI 中强制阈值 | 覆盖率下降时 CI 失败,防止回退 |
| 报告到 PR | 使用 Codecov/Coveralls 在 PR 中展示覆盖率变化 |
10. CI/CD 测试管道
在 CI 中构建高效的测试管道是保证代码质量的关键。按速度分层运行测试,尽早获得反馈。
# .github/workflows/test.yml
name: Test Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
lint-and-typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: "npm"
- run: npm ci
- run: npm run lint
- run: npm run typecheck
unit-tests:
runs-on: ubuntu-latest
needs: lint-and-typecheck
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: "npm"
- run: npm ci
- run: npx vitest run --coverage
- uses: actions/upload-artifact@v4
with:
name: coverage-report
path: coverage/
integration-tests:
runs-on: ubuntu-latest
needs: unit-tests
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: testdb
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: "npm"
- run: npm ci
- run: npx vitest run --config vitest.integration.config.ts
env:
DATABASE_URL: postgresql://test:test@localhost:5432/testdb
e2e-tests:
runs-on: ubuntu-latest
needs: integration-tests
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: "npm"
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: npx playwright test
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: playwright-report/11. 性能测试
性能测试确保应用在负载下的响应时间和吞吐量符合要求。包括负载测试、压力测试和前端性能基准。
k6 负载测试
// load-test.js (k6)
import http from "k6/http";
import { check, sleep } from "k6";
import { Rate, Trend } from "k6/metrics";
const errorRate = new Rate("errors");
const responseTime = new Trend("response_time");
export const options = {
stages: [
{ duration: "30s", target: 20 }, // Ramp up
{ duration: "1m", target: 50 }, // Steady load
{ duration: "30s", target: 100 }, // Peak load
{ duration: "30s", target: 0 }, // Ramp down
],
thresholds: {
http_req_duration: ["p(95)<500", "p(99)<1000"],
errors: ["rate<0.05"],
http_req_failed: ["rate<0.01"],
},
};
export default function () {
// GET request
const listRes = http.get("http://localhost:3000/api/products");
check(listRes, {
"status is 200": (r) => r.status === 200,
"response time < 500ms": (r) => r.timings.duration < 500,
});
errorRate.add(listRes.status !== 200);
responseTime.add(listRes.timings.duration);
// POST request
const payload = JSON.stringify({ query: "test item" });
const searchRes = http.post(
"http://localhost:3000/api/search",
payload,
{ headers: { "Content-Type": "application/json" } }
);
check(searchRes, {
"search returns results": (r) => {
const body = JSON.parse(r.body);
return body.results && body.results.length > 0;
},
});
sleep(1);
}Lighthouse CI 前端性能
// lighthouserc.js
module.exports = {
ci: {
collect: {
url: [
"http://localhost:3000",
"http://localhost:3000/products",
"http://localhost:3000/login",
],
numberOfRuns: 3,
startServerCommand: "npm run start",
},
assert: {
assertions: {
"categories:performance": ["error", { minScore: 0.9 }],
"categories:accessibility": ["warn", { minScore: 0.9 }],
"categories:seo": ["warn", { minScore: 0.9 }],
"first-contentful-paint": ["error", { maxNumericValue: 2000 }],
"largest-contentful-paint": ["error", { maxNumericValue: 2500 }],
"cumulative-layout-shift": ["error", { maxNumericValue: 0.1 }],
"total-blocking-time": ["error", { maxNumericValue: 300 }],
},
},
upload: {
target: "temporary-public-storage",
},
},
};12. 快照测试与视觉回归
快照测试捕获输出的"快照"并在后续运行中比较差异。视觉回归测试对 UI 截图进行像素级比较,防止意外的视觉变化。
// Inline snapshot testing (Vitest/Jest)
import { describe, it, expect } from "vitest";
import { render } from "@testing-library/react";
import { Button } from "./Button";
describe("Button", () => {
it("matches snapshot", () => {
const { container } = render(
<Button variant="primary" size="lg">Click me</Button>
);
expect(container.firstChild).toMatchSnapshot();
});
it("matches inline snapshot", () => {
const { container } = render(
<Button variant="outline">Cancel</Button>
);
expect(container.innerHTML).toMatchInlineSnapshot();
// Vitest auto-fills the snapshot on first run
});
});
// Playwright visual regression
import { test, expect } from "@playwright/test";
test("homepage visual regression", async ({ page }) => {
await page.goto("/");
await expect(page).toHaveScreenshot("homepage.png", {
maxDiffPixels: 100,
});
});
test("responsive layout", async ({ page }) => {
await page.setViewportSize({ width: 375, height: 812 });
await page.goto("/");
await expect(page).toHaveScreenshot("homepage-mobile.png", {
maxDiffPixelRatio: 0.01,
});
});
test("component visual test", async ({ page }) => {
await page.goto("/storybook/button");
const button = page.getByRole("button", { name: "Primary" });
await expect(button).toHaveScreenshot("button-primary.png");
});13. 测试组织与最佳实践
良好的测试组织让测试套件易于维护和扩展。遵循 AAA(Arrange-Act-Assert)模式和 FIRST(Fast-Independent-Repeatable-Self-validating-Timely)原则。
// Recommended project structure
// src/
// components/
// Button/
// Button.tsx
// Button.test.tsx ← Unit test (co-located)
// services/
// payment.ts
// payment.test.ts ← Unit test (co-located)
// __tests__/
// integration/
// checkout.test.ts ← Integration test
// user-flow.test.ts
// e2e/
// checkout.spec.ts ← E2E test
// auth.spec.ts
// mocks/
// handlers.ts ← MSW handlers
// server.ts
// Test utilities — custom render with providers
// src/test/utils.tsx
import { render, RenderOptions } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ThemeProvider } from "./ThemeProvider";
function AllProviders({ children }: { children: React.ReactNode }) {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
return (
<QueryClientProvider client={queryClient}>
<ThemeProvider>{children}</ThemeProvider>
</QueryClientProvider>
);
}
export function renderWithProviders(
ui: React.ReactElement,
options?: Omit<RenderOptions, "wrapper">
) {
return render(ui, { wrapper: AllProviders, ...options });
}
// Usage in tests:
import { renderWithProviders } from "../test/utils";
it("renders with providers", () => {
renderWithProviders(<MyComponent />);
// All providers (React Query, Theme, etc.) are available
});测试命名规范
| 模式 | 示例 | 使用场景 |
|---|---|---|
| 行为描述 | "adds item to cart" | 单元测试、集成测试 |
| should + 动作 | "should redirect after login" | E2E 测试 |
| given-when-then | "given empty cart, when add item, then total updates" | BDD 风格 |
| 边界描述 | "handles empty string input" | 边界条件 |
| 错误描述 | "throws on invalid email" | 错误处理 |
14. 高级测试模式
Fixture 与工厂模式
// test/factories.ts — Test data factories
import { faker } from "@faker-js/faker";
interface User {
id: string;
name: string;
email: string;
role: "admin" | "user";
createdAt: Date;
}
export function createUser(overrides: Partial<User> = {}): User {
return {
id: faker.string.uuid(),
name: faker.person.fullName(),
email: faker.internet.email(),
role: "user",
createdAt: faker.date.past(),
...overrides,
};
}
export function createProduct(overrides = {}) {
return {
id: faker.string.uuid(),
name: faker.commerce.productName(),
price: Number(faker.commerce.price()),
inStock: true,
...overrides,
};
}
// Usage in tests
it("displays user profile", () => {
const admin = createUser({ role: "admin", name: "Alice" });
renderWithProviders(<UserProfile user={admin} />);
expect(screen.getByText("Alice")).toBeInTheDocument();
expect(screen.getByText("Admin")).toBeInTheDocument();
});异步测试与重试
// Polling and async assertions
import { waitFor, screen } from "@testing-library/react";
it("loads data after delay", async () => {
renderWithProviders(<AsyncDataComponent />);
// waitFor retries until assertion passes or timeout
await waitFor(
() => {
expect(screen.getByText("Data loaded")).toBeInTheDocument();
},
{ timeout: 3000, interval: 100 }
);
});
// Playwright retries with toPass
import { test, expect } from "@playwright/test";
test("waits for API to be ready", async ({ request }) => {
await expect(async () => {
const response = await request.get("/api/health");
expect(response.status()).toBe(200);
}).toPass({
intervals: [1000, 2000, 5000],
timeout: 30000,
});
});
// Vitest retry configuration
// vitest.config.ts
export default defineConfig({
test: {
retry: process.env.CI ? 2 : 0, // Retry flaky tests in CI
testTimeout: 10000,
},
});总结
一个成熟的测试策略应覆盖从单元测试到 E2E 测试的完整金字塔。TDD 和 BDD 不仅是测试方法,更是提升代码质量和团队协作的设计方法论。选择合适的工具——Jest/Vitest 用于单元测试,Playwright/Cypress 用于 E2E 测试,MSW 用于 API Mock——并在 CI 中自动化整个流程。记住:好的测试不是越多越好,而是在正确的层级测试正确的东西。
持续投入测试基础设施——自定义渲染函数、工厂函数、MSW Handler、CI 管道——会让你的测试套件更易维护。当测试成为开发流程的自然部分而非负担时,你就建立了真正的质量文化。
常见问题
What is the difference between unit testing, integration testing, and E2E testing?
Unit tests verify individual functions or components in isolation, running fast with mocked dependencies. Integration tests check how multiple modules work together with real dependencies like databases or APIs. End-to-end (E2E) tests simulate real user workflows through the entire application stack. The test pyramid recommends many unit tests, fewer integration tests, and minimal E2E tests for optimal cost-to-confidence ratio.
What is Test-Driven Development (TDD) and how does it work?
TDD follows a Red-Green-Refactor cycle: first write a failing test that defines desired behavior (Red), then write the minimum code to make it pass (Green), then refactor for quality while keeping tests green (Refactor). TDD ensures every line of production code is driven by a test, leading to better design, higher coverage, and fewer regressions.
When should I use Jest vs Vitest for JavaScript testing?
Vitest is recommended for Vite-based projects as it shares the same config and transform pipeline, runs significantly faster with native ESM support, and has a Jest-compatible API. Jest remains the standard for non-Vite projects, has the largest ecosystem of plugins and matchers, and is well-supported in Create React App and Next.js. Both support mocking, snapshots, code coverage, and parallel execution.
How do Playwright and Cypress compare for E2E testing?
Playwright supports Chromium, Firefox, and WebKit with native multi-tab, multi-origin, and network interception. It runs faster with auto-wait and parallel browser contexts. Cypress has an excellent developer experience with time-travel debugging, real-time reloading, and a visual test runner, but only supports Chromium-based browsers and Chrome natively. Choose Playwright for cross-browser coverage and CI speed; choose Cypress for developer experience and interactive debugging.
What is BDD and how does it differ from TDD?
Behavior-Driven Development (BDD) extends TDD by writing tests in natural language (Given-When-Then) that both developers and stakeholders can understand. While TDD focuses on technical correctness from a developer perspective, BDD focuses on business behavior and acceptance criteria. BDD tools like Cucumber and Jest-Cucumber translate Gherkin feature files into executable test steps.
What test coverage percentage should I aim for?
Aim for 80% line and branch coverage as a practical target. 100% coverage is rarely worth the effort as it leads to brittle tests on trivial code. Focus coverage on business-critical paths, complex algorithms, and error handling rather than getters/setters or boilerplate. Use coverage tools (Istanbul/c8/v8) to identify untested code paths, not as a quality metric alone — high coverage does not guarantee meaningful tests.
How do I set up testing in a CI/CD pipeline?
In CI, run unit tests first (fastest feedback), then integration tests, then E2E tests. Use parallelization to split test suites across workers. Cache node_modules and build artifacts between steps. Set coverage thresholds to fail the pipeline if coverage drops. Store test reports and artifacts (screenshots, videos) for debugging. Use GitHub Actions, GitLab CI, or CircleCI with matrix builds for cross-environment testing.
What are the best practices for mocking in tests?
Mock external dependencies (APIs, databases, file system) but avoid mocking internal implementation details. Use dependency injection to make code testable. Prefer spies over mocks when you only need to verify calls. Reset mocks between tests to prevent state leakage. Use MSW (Mock Service Worker) for API mocking in both tests and development. Keep mocks close to their real behavior to avoid false confidence.