E2E 测试概述
端到端(E2E)测试模拟真实用户操作,验证整个应用的正确性。Claude Code 可以帮你配置 Playwright 进行 E2E 测试。
E2E 测试范围
完整的用户流程:从登录到完成业务操作,验证前端、后端和数据库的集成。
Playwright 配置
项目初始化
bash
帮我创建一个 Playwright E2E 测试配置。javascript
// playwright.config.js
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", { outputFolder: "playwright-report" }],
["json", { outputFile: "playwright-results.json" }],
],
use: {
baseURL: process.env.BASE_URL || "http://localhost:3000",
trace: "on-first-retry",
screenshot: "only-on-failure",
video: "retain-on-failure",
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
{
name: "firefox",
use: { ...devices["Desktop Firefox"] },
},
{
name: "webkit",
use: { ...devices["Desktop Safari"] },
},
{
name: "Mobile Chrome",
use: { ...devices["Pixel 5"] },
},
{
name: "Mobile Safari",
use: { ...devices["iPhone 12"] },
},
],
webServer: process.env.CI
? {
command: "npm run start",
url: "http://localhost:3000",
reuseExistingServer: false,
timeout: 120 * 1000,
}
: undefined,
});登录 E2E 测试
javascript
// e2e/auth/login.spec.js
import { test, expect } from "@playwright/test";
import { faker } from "@faker-js/faker";
test.describe("登录功能", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/login");
});
test("应该成功登录", async ({ page }) => {
// 填写登录表单
await page.fill('input[name="email"]', "test@example.com");
await page.fill('input[name="password"]', "Password123!");
// 点击登录按钮
await page.click('button[type="submit"]');
// 等待登录成功
await expect(page).toHaveURL("/dashboard");
await expect(page.locator("h1")).toContainText("欢迎回来");
});
test("应该显示错误提示 - 错误密码", async ({ page }) => {
await page.fill('input[name="email"]', "test@example.com");
await page.fill('input[name="password"]', "WrongPassword!");
await page.click('button[type="submit"]');
await expect(page.locator(".error-message")).toContainText("邮箱或密码错误");
await expect(page).toHaveURL("/login");
});
test("应该显示错误提示 - 不存在的用户", async ({ page }) => {
await page.fill('input[name="email"]', "nonexistent@example.com");
await page.fill('input[name="password"]', "Password123!");
await page.click('button[type="submit"]');
await expect(page.locator(".error-message")).toContainText("用户不存在");
});
test("表单验证 - 邮箱格式错误", async ({ page }) => {
await page.fill('input[name="email"]', "invalid-email");
await page.fill('input[name="password"]', "Password123!");
await page.click('button[type="submit"]');
await expect(page.locator('input[name="email"]')).toHaveAttribute(
"aria-invalid",
"true"
);
});
test("表单验证 - 密码为空", async ({ page }) => {
await page.fill('input[name="email"]', "test@example.com");
await page.click('button[type="submit"]');
await expect(page.locator('input[name="password"]')).toHaveAttribute(
"aria-invalid",
"true"
);
});
test("应该支持记住登录状态", async ({ page }) => {
await page.fill('input[name="email"]', "test@example.com");
await page.fill('input[name="password"]', "Password123!");
await page.check('input[name="remember"]');
await page.click('button[type="submit"]');
await expect(page).toHaveURL("/dashboard");
// 关闭浏览器,重新打开应该保持登录状态
await page.context().storageState({ path: "./auth-state.json" });
});
});注册 E2E 测试
javascript
// e2e/auth/register.spec.js
import { test, expect } from "@playwright/test";
import { faker } from "@faker-js/faker";
test.describe("注册功能", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/register");
});
test("应该成功注册新用户", async ({ page }) => {
const userData = {
username: faker.internet.username(),
email: faker.internet.email(),
password: "Password123!",
};
await page.fill('input[name="username"]', userData.username);
await page.fill('input[name="email"]', userData.email);
await page.fill('input[name="password"]', userData.password);
await page.fill('input[name="confirmPassword"]', userData.password);
await page.check('input[name="terms"]');
await page.click('button[type="submit"]');
// 等待注册成功,跳转到验证邮箱页面
await expect(page).toHaveURL("/verify-email");
await expect(page.locator("h1")).toContainText("验证邮箱");
});
test("密码不匹配时应该显示错误", async ({ page }) => {
await page.fill('input[name="username"]', faker.internet.username());
await page.fill('input[name="email"]', faker.internet.email());
await page.fill('input[name="password"]', "Password123!");
await page.fill('input[name="confirmPassword"]', "DifferentPassword!");
await page.click('button[type="submit"]');
await expect(page.locator(".error-message")).toContainText("密码不匹配");
});
test("用户名已存在时应该显示错误", async ({ page }) => {
// 使用一个已存在的用户名
await page.fill('input[name="username"]', "existinguser");
await page.fill('input[name="email"]', faker.internet.email());
await page.fill('input[name="password"]', "Password123!");
await page.fill('input[name="confirmPassword"]', "Password123!");
await page.click('button[type="submit"]');
await expect(page.locator(".error-message")).toContainText("用户名已被使用");
});
});完整用户流程测试
博客文章管理流程
javascript
// e2e/blog/article-management.spec.js
import { test, expect } from "@playwright/test";
import { faker } from "@faker-js/faker";
test.describe("文章管理完整流程", () => {
let authToken;
test.beforeAll(async ({ browser }) => {
// 创建测试用户并登录
const context = await browser.newContext();
const page = await context.newPage();
// 注册
await page.goto("/register");
await page.fill('input[name="username"]', faker.internet.username());
await page.fill('input[name="email"]', faker.internet.email());
await page.fill('input[name="password"]', "Password123!");
await page.fill('input[name="confirmPassword"]', "Password123!");
await page.check('input[name="terms"]');
await page.click('button[type="submit"]');
// 保存认证状态
authToken = await context.storageState();
await context.close();
});
test("创建 -> 编辑 -> 发布 -> 删除 文章", async ({ page }) => {
// 使用已认证的状态
const context = await browser.newContext({
storageState: authToken,
});
page.context().setAuthToken?.(authToken);
await page.goto("/dashboard");
// 1. 创建新文章
await page.click('button:has-text("新建文章")');
await expect(page).toHaveURL("/editor/new");
const title = faker.lorem.sentence();
const content = faker.lorem.paragraphs(3);
await page.fill('input[name="title"]', title);
await page.fill(".editor-content", content);
await page.fill('input[name="tags"]', "JavaScript,React");
await page.click('button:has-text("保存草稿")');
await expect(page.locator(".success-message")).toContainText("草稿已保存");
// 2. 验证文章出现在草稿列表
await page.goto("/dashboard/posts?status=draft");
await expect(page.locator("article").first()).toContainText(title);
// 3. 编辑文章
await page.click(`article:has-text("${title}") >> text=编辑`);
await expect(page).toHaveURL(/\/editor\/\w+/);
const newTitle = title + " - 更新版";
await page.fill('input[name="title"]', newTitle);
await page.click('button:has-text("保存更新")');
// 4. 发布文章
await page.click('button:has-text("发布")');
await expect(page.locator(".success-message")).toContainText("发布成功");
await expect(page.locator(".post-status")).toContainText("已发布");
// 5. 验证文章出现在博客列表
await page.goto("/blog");
await expect(page.locator(".post-card")).toContainText(newTitle);
// 6. 验证文章详情页
await page.click(`.post-card:has-text("${newTitle}")`);
await expect(page.locator("h1")).toContainText(newTitle);
await expect(page.locator(".post-content")).toContainText(content);
// 7. 删除文章
await page.click('button:has-text("删除")');
await page.click('button:has-text("确认删除")');
await expect(page.locator(".success-message")).toContainText("删除成功");
await page.goto("/blog");
await expect(page.locator(".post-card")).not.toContainText(newTitle);
});
});购物车流程
javascript
// e2e/shopping/cart.spec.js
import { test, expect } from "@playwright/test";
test.describe("购物车功能", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/");
await page.click('a:has-text("登录")');
await page.fill('input[name="email"]', "shopper@example.com");
await page.fill('input[name="password"]', "Password123!");
await page.click('button[type="submit"]');
});
test("添加商品到购物车", async ({ page }) => {
// 浏览商品
await page.goto("/products");
// 点击第一个商品
await page.click(".product-card:first-child");
await expect(page).toHaveURL(/\/products\/\w+/);
// 添加到购物车
const productName = await page.locator("h1").textContent();
await page.click('button:has-text("加入购物车")');
// 验证购物车更新
await expect(page.locator(".cart-badge")).toContainText("1");
// 打开购物车
await page.click(".cart-icon");
await expect(page.locator(".cart-item")).toContainText(productName);
});
test("修改商品数量", async ({ page }) => {
await page.goto("/cart");
// 增加数量
await page.click('.cart-item:first-child >> button:has-text("+")');
await expect(page.locator('.cart-item:first-child input[type="number"]')).toHaveValue(
"2"
);
// 减少数量
await page.click('.cart-item:first-child >> button:has-text("-")');
await expect(page.locator('.cart-item:first-child input[type="number"]')).toHaveValue(
"1"
);
});
test("删除商品", async ({ page }) => {
await page.goto("/cart");
const initialCount = await page.locator(".cart-item").count();
await page.hover(".cart-item:first-child");
await page.click('.cart-item:first-child >> button:has-text("删除")');
await expect(page.locator(".cart-item")).toHaveCount(initialCount - 1);
});
test("结算流程", async ({ page }) => {
await page.goto("/cart");
// 确认购物车有商品
await expect(page.locator(".cart-item")).not.toHaveCount(0);
// 点击结算
await page.click('button:has-text("去结算")');
await expect(page).toHaveURL("/checkout");
// 选择地址
await page.click('input[name="address"][value="saved-address"]');
// 选择支付方式
await page.click('input[name="payment"][value="alipay"]');
// 提交订单
await page.click('button:has-text("提交订单")');
// 验证跳转到支付页面
await expect(page).toHaveURL(/\/order\/\w+\/payment/);
});
});组件交互测试
模态框测试
javascript
// e2e/components/modal.spec.js
import { test, expect } from "@playwright/test";
test.describe("模态框组件", () => {
test("点击按钮应该打开模态框", async ({ page }) => {
await page.goto("/dashboard");
await page.click('button:has-text("新建项目")');
await expect(page.locator(".modal-overlay")).toBeVisible();
await expect(page.locator(".modal-title")).toContainText("新建项目");
});
test("点击关闭按钮应该关闭模态框", async ({ page }) => {
await page.goto("/dashboard");
await page.click('button:has-text("新建项目")');
await expect(page.locator(".modal-overlay")).toBeVisible();
await page.click('.modal-overlay >> button:has-text("取消")');
await expect(page.locator(".modal-overlay")).not.toBeVisible();
});
test("点击遮罩层应该关闭模态框", async ({ page }) => {
await page.goto("/dashboard");
await page.click('button:has-text("新建项目")');
await expect(page.locator(".modal-overlay")).toBeVisible();
await page.click(".modal-overlay", { position: { x: 10, y: 10 } });
await expect(page.locator(".modal-overlay")).not.toBeVisible();
});
test("按 ESC 键应该关闭模态框", async ({ page }) => {
await page.goto("/dashboard");
await page.click('button:has-text("新建项目")');
await expect(page.locator(".modal-overlay")).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.locator(".modal-overlay")).not.toBeVisible();
});
test("表单提交后应该关闭模态框", async ({ page }) => {
await page.goto("/dashboard");
await page.click('button:has-text("新建项目")');
await page.fill('input[name="projectName"]', "我的新项目");
await page.click('.modal-actions >> button:has-text("创建")');
await expect(page.locator(".modal-overlay")).not.toBeVisible();
await expect(page.locator(".success-message")).toContainText("创建成功");
});
});表单测试
javascript
// e2e/components/form.spec.js
import { test, expect } from "@playwright/test";
test.describe("表单组件", () => {
test("实时验证应该正常工作", async ({ page }) => {
await page.goto("/profile/edit");
const emailInput = page.locator('input[name="email"]');
// 输入无效邮箱
await emailInput.fill("invalid-email");
await emailInput.blur();
await expect(page.locator('input[name="email"] + .error')).toContainText(
"请输入有效的邮箱地址"
);
// 输入有效邮箱后错误消失
await emailInput.fill("valid@example.com");
await expect(page.locator('input[name="email"] + .error')).not.toBeVisible();
});
test("密码强度指示应该实时更新", async ({ page }) => {
await page.goto("/register");
const passwordInput = page.locator('input[name="password"]');
const strengthIndicator = page.locator(".password-strength");
await passwordInput.fill("weak");
await expect(strengthIndicator).toHaveClass(/strength-weak/);
await passwordInput.fill("Medium123!");
await expect(strengthIndicator).toHaveClass(/strength-medium/);
await passwordInput.fill("StrongPassword123!");
await expect(strengthIndicator).toHaveClass(/strength-strong/);
});
});CI 集成
GitHub Actions
yaml
# .github/workflows/e2e.yml
name: E2E Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
e2e:
timeout-minutes: 30
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Install Playwright Browsers
run: npx playwright install --with-deps chromium
- name: Run Playwright tests
run: npx playwright test
- name: Upload Playwright Report
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/
retention-days: 30
- name: Upload Test Results
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-results
path: playwright-results.jsonE2E 测试最佳实践
E2E 测试耗时较长,应该只覆盖核心用户流程,使用测试账号隔离数据,确保测试的稳定性。
总结
使用 Claude Code 配置 E2E 测试:
- 使用 Playwright 实现跨浏览器测试
- 编写完整的用户流程测试
- 测试组件交互和表单验证
- 配置 CI/CD 自动化执行
- 生成详细的测试报告
- 遵循 E2E 测试最佳实践