| 版本 | 日期 | 描述 | 作者 |
|---|---|---|---|
| 1.0 | 2026-01-03 | 创建E2E测试规范文档 | James (Claude Code) |
本文档定义了端到端(E2E)测试的标准和最佳实践,用于验证完整的用户流程和系统功能。
web/tests/e2e/ - 基于Playwright的Web应用E2E测试mini/tests/e2e/ - 基于Taro/DOT的小程序E2E测试(如需要)web/tests/e2e/
├── playwright.config.ts # Playwright配置 (testDir: '.')
├── global-setup.ts # 全局测试前置设置
├── global-teardown.ts # 全局测试后置清理
├── fixtures/ # 测试数据fixtures
│ ├── test-users.json # 测试用户数据
│ └── test-data.ts # 测试数据工厂
├── pages/ # Page Object Model
│ └── admin/ # 管理后台页面对象
│ ├── login.page.ts
│ └── dashboard.page.ts
├── specs/ # 页面级E2E测试 (按功能分组)
│ └── admin/
│ ├── login.spec.ts
│ ├── dashboard.spec.ts
│ └── users.spec.ts
├── *.spec.ts # API兼容性测试等 (放根目录)
└── utils/ # 测试工具函数
└── test-setup.ts
| 文件类型 | 放置位置 | 命名规范 |
|---|---|---|
| 页面级E2E测试 | specs/ 子目录 |
<功能>/<页面>.spec.ts |
| API兼容性测试 | e2e/ 根目录 |
<功能名>-api.spec.ts |
| Page Object | pages/ 目录 |
<功能>/<页面>.page.ts |
| 测试fixtures | fixtures/ 目录 |
<数据类型>.json 或 test-data.ts |
| 测试工具 | utils/ 目录 |
<用途>.ts |
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
// 测试目录:当前目录,同时扫描specs/和根目录的.spec.ts文件
testDir: '.',
// 并行执行
fullyParallel: true,
// CI环境下禁止only测试
forbidOnly: !!process.env.CI,
// 重试次数
retries: process.env.CI ? 2 : 0,
// 并发worker数
workers: process.env.CI ? 1 : undefined,
// 报告器
reporter: [
['html'], // HTML报告
['list'], // 控制台列表
['junit', { outputFile: 'test-results/junit.xml' }] // JUnit XML报告
],
// 默认配置
use: {
baseURL: process.env.E2E_BASE_URL || 'http://localhost:8080',
trace: 'on-first-retry', // 第一次重试时记录trace
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'] },
},
],
// Web服务器配置
webServer: {
command: 'npm run dev',
url: 'http://localhost:8080',
reuseExistingServer: !process.env.CI,
timeout: 120000,
},
});
目的: 验证用户在页面上的完整交互流程
位置: specs/<功能>/<页面>.spec.ts
示例: 管理后台登录流程测试
import { test, expect } from '../../utils/test-setup';
test.describe('登录页面 E2E 测试', () => {
test.beforeEach(async ({ adminLoginPage }) => {
await adminLoginPage.goto();
});
test('成功登录', async ({ adminLoginPage, dashboardPage }) => {
await adminLoginPage.login('admin', 'admin123');
await dashboardPage.expectToBeVisible();
await expect(dashboardPage.pageTitle).toHaveText('仪表盘');
});
test('登录失败 - 错误密码', async ({ adminLoginPage }) => {
await adminLoginPage.login('admin', 'wrongpassword');
await expect(adminLoginPage.errorToast).toBeVisible();
});
});
目的: 验证API端点路径和响应结构兼容性
位置: e2e/<功能名>-api.spec.ts
示例: 统一广告API兼容性测试
import { test, expect } from '@playwright/test';
test.describe('统一广告API兼容性测试', () => {
const baseUrl = process.env.API_BASE_URL || 'http://localhost:8080';
let userToken: string;
test.beforeAll(async ({ request }) => {
// 登录获取token
const loginResponse = await request.post(`${baseUrl}/api/v1/auth/login?tenantId=1`, {
data: { username: 'admin', password: 'admin123' }
});
const loginData = await loginResponse.json();
userToken = loginData.token;
});
test('GET /api/v1/advertisements - 获取广告列表', async ({ request }) => {
const response = await request.get(`${baseUrl}/api/v1/advertisements`, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
expect(response.status()).toBe(200);
const result = await response.json();
expect(result).toHaveProperty('code', 200);
expect(result.data).toHaveProperty('list');
expect(Array.isArray(result.data.list)).toBeTruthy();
});
});
封装页面交互逻辑,提高测试可维护性:
// pages/admin/login.page.ts
import { Page, expect } from '@playwright/test';
export class AdminLoginPage {
readonly page: Page;
readonly usernameInput = this.page.locator('input[name="username"]');
readonly passwordInput = this.page.locator('input[name="password"]');
readonly submitButton = this.page.locator('button[type="submit"]');
readonly errorToast = this.page.locator('[role="alert"]');
readonly successToast = this.page.locator('.toast-success');
constructor(page: Page) {
this.page = page;
}
async goto() {
await this.page.goto('/admin/login');
await this.expectToBeVisible();
}
async expectToBeVisible() {
await expect(this.page).toHaveTitle(/管理后台登录/);
await expect(this.usernameInput).toBeVisible();
}
async login(username: string, password: string) {
await this.usernameInput.fill(username);
await this.passwordInput.fill(password);
await this.submitButton.click();
}
clone(page: Page) {
return new AdminLoginPage(page);
}
}
// pages/tenant/tenant-login.page.ts
import { Page, Locator, expect } from '@playwright/test';
/**
* 租户后台登录页面对象
* 路径: /tenant/login
* 认证方式: 超级管理员登录(username=admin, password=admin123)
*/
export class TenantLoginPage {
readonly page: Page;
readonly usernameInput: Locator;
readonly passwordInput: Locator;
readonly submitButton: Locator;
readonly pageTitle: Locator;
readonly initializingText: Locator;
constructor(page: Page) {
this.page = page;
this.usernameInput = page.getByPlaceholder('请输入用户名');
this.passwordInput = page.getByPlaceholder('请输入密码');
this.submitButton = page.getByRole('button', { name: '登录' });
this.pageTitle = page.getByRole('heading', { name: /租户.*登录|登录/i });
this.initializingText = page.getByText('应用初始化中');
}
async goto() {
await this.page.goto('/tenant/login');
// 等待应用初始化完成
try {
await expect(this.initializingText).not.toBeVisible({ timeout: 30000 });
} catch {
// 如果初始化文本没有出现,继续
}
// 等待登录表单可见
await expect(this.pageTitle).toBeVisible({ timeout: 30000 });
}
async login(username: string, password: string) {
// 确保应用已初始化
try {
await expect(this.initializingText).not.toBeVisible({ timeout: 10000 });
} catch {
// 继续尝试
}
// 等待输入框可见
await expect(this.usernameInput).toBeVisible({ timeout: 10000 });
await expect(this.passwordInput).toBeVisible({ timeout: 10000 });
await this.usernameInput.fill(username);
await this.passwordInput.fill(password);
await this.submitButton.click();
await this.page.waitForLoadState('networkidle');
}
async expectLoginSuccess() {
// 登录成功后应该重定向到租户控制台
await expect(this.page).toHaveURL(/\/tenant\/dashboard/);
}
async expectLoginError() {
// 登录失败应该显示错误提示
const errorToast = this.page.locator('[data-sonner-toast][data-type="error"]');
await expect(errorToast).toBeVisible();
}
clone(newPage: Page): TenantLoginPage {
return new TenantLoginPage(newPage);
}
}
// pages/tenant/tenant-advertisement.page.ts
import { Page, Locator, expect } from '@playwright/test';
/**
* 租户后台广告管理页面对象
* 路径: /tenant/unified-advertisements
*/
export class TenantAdvertisementPage {
readonly page: Page;
readonly pageTitle: Locator;
readonly createButton: Locator;
readonly searchInput: Locator;
readonly tableRows: Locator;
readonly modalTitle: Locator;
readonly titleInput: Locator;
readonly submitButton: Locator;
readonly deleteDialog: Locator;
readonly deleteConfirmButton: Locator;
constructor(page: Page) {
this.page = page;
// 列表页元素
this.pageTitle = this.page.getByRole('heading', { name: /广告管理/i });
this.createButton = this.page.getByTestId('create-unified-advertisement-button');
this.searchInput = this.page.getByTestId('search-input');
this.tableRows = this.page.locator('tbody tr');
// 表单元素
this.modalTitle = this.page.getByTestId('modal-title');
this.titleInput = this.page.getByTestId('title-input');
this.submitButton = this.page.locator('[data-testid="create-submit-button"], [data-testid="update-submit-button"]');
// 删除对话框元素
this.deleteDialog = this.page.getByTestId('delete-dialog');
this.deleteConfirmButton = this.page.getByTestId('confirm-delete-button');
}
async goto() {
await this.page.goto('/tenant/unified-advertisements');
await this.page.waitForLoadState('networkidle');
}
async clickCreate() {
await this.createButton.click();
await this.page.waitForTimeout(300);
}
async search(keyword: string) {
await this.searchInput.fill(keyword);
await this.page.waitForTimeout(500); // 等待搜索防抖
}
async fillForm(data: { title: string; code?: string; url?: string }) {
if (data.title) await this.titleInput.fill(data.title);
// ... 其他字段
}
async submitForm() {
await this.submitButton.click();
await this.page.waitForTimeout(500);
}
async clickDelete(id: number) {
const deleteButton = this.page.getByTestId(`delete-button-${id}`);
await deleteButton.click();
await this.page.waitForTimeout(300);
}
async confirmDelete() {
await this.deleteConfirmButton.click();
await this.page.waitForTimeout(500);
}
async expectModalVisible(visible: boolean = true) {
if (visible) {
await expect(this.modalTitle).toBeVisible();
} else {
await expect(this.modalTitle).not.toBeVisible();
}
}
clone(newPage: Page): TenantAdvertisementPage {
return new TenantAdvertisementPage(newPage);
}
}
// utils/test-setup.ts
import { test as base } from '@playwright/test';
import { AdminLoginPage } from '../pages/admin/login.page';
import { DashboardPage } from '../pages/admin/dashboard.page';
type AdminFixtures = {
adminLoginPage: AdminLoginPage;
dashboardPage: DashboardPage;
};
export const test = base.extend<AdminFixtures>({
adminLoginPage: async ({ page }, use) => {
const loginPage = new AdminLoginPage(page);
await use(loginPage);
},
dashboardPage: async ({ page }, use) => {
const dashboardPage = new DashboardPage(page);
await use(dashboardPage);
},
});
在多租户系统中,用户端API需要认证来确定租户上下文:
// 用户端API:使用 authMiddleware (多租户认证)
const loginResponse = await request.post(`${baseUrl}/api/v1/auth/login?tenantId=1`, {
data: { username: 'admin', password: 'admin123' }
});
// 管理员API:使用 tenantAuthMiddleware (超级管理员专用)
const adminResponse = await request.get(`${baseUrl}/api/v1/admin/unified-advertisements`, {
headers: { 'Authorization': `Bearer ${authToken}` }
});
E2E测试需要数据库中有测试数据:
-- 创建测试租户
INSERT INTO tenant_mt (id, name, code, status, created_at, updated_at)
VALUES (1, '测试租户', 'test-tenant', 1, NOW(), NOW());
-- 创建测试用户 (密码: admin123,需bcrypt加密)
INSERT INTO users_mt (id, tenant_id, username, password, registration_source, is_disabled, is_deleted, created_at, updated_at)
VALUES (1, 1, 'admin', '$2b$10$x3t2kofPmACnk6y6lfL6ouU836LBEuZE9BinQ3ZzA4Xd04izyY42K', 'web', 0, 0, NOW(), NOW());
// fixtures/test-users.json
{
"admin": {
"username": "admin",
"password": "admin123",
"email": "admin@example.com",
"role": "admin"
},
"regularUser": {
"username": "testuser",
"password": "test123",
"email": "testuser@example.com",
"role": "user"
}
}
// 状态码断言
expect(response.status()).toBe(200);
// 数组断言
expect(Array.isArray(data)).toBeTruthy();
// 属性断言
expect(ad).toHaveProperty('id');
expect(ad).toHaveProperty('title', '预期标题');
// 类型断言
expect(typeof ad.id).toBe('number');
// 可见性断言
await expect(element).toBeVisible();
await expect(text).toHaveText(/正则表达式/);
// 包装响应格式
{
"code": 200,
"message": "success",
"data": {
"list": [],
"total": 0,
"page": 1,
"pageSize": 10
}
}
// 断言
expect(result).toHaveProperty('code', 200);
expect(result).toHaveProperty('data');
expect(result.data).toHaveProperty('list');
expect(result.data).toHaveProperty('total');
# 运行所有E2E测试
cd web && pnpm test:e2e
# 运行特定测试文件
pnpm test:e2e unified-ad
# 运行Chromium测试
pnpm test:e2e:chromium
# 使用UI模式运行
pnpm test:e2e:ui
# 调试模式
pnpm test:e2e:debug
# 列出所有测试
pnpm exec playwright test --config=tests/e2e/playwright.config.ts --list
# API基础URL
export API_BASE_URL=http://localhost:8080
# 测试用户
export TEST_USERNAME=admin
export TEST_PASSWORD=admin123
export TEST_TENANT_ID=1
test.describe('用户管理', () => {
test.describe('创建用户', () => {
test('应该成功创建用户', async ({ page }) => {
// ...
});
});
});
test.beforeEach(async ({ adminLoginPage }) => {
await adminLoginPage.goto();
});
test.beforeAll(async ({ request }) => {
// 执行一次,如登录获取token
});
test('获取广告详情', async ({ request }) => {
if (!userToken) {
test.skip(true, '缺少认证token,请先创建测试用户');
}
// ...
});
if (loginResponse.status() === 200) {
console.log('✅ 登录成功,获取到token');
} else {
const error = await loginResponse.json();
console.error('❌ 登录失败:', error);
}
# E2E测试失败时先查看页面结构
cat test-results/**/error-context.md
| 测试类型 | 最低要求 | 目标要求 |
|---|---|---|
| 关键用户流程 | 100% | 100% |
| 主要用户流程 | 80% | 90% |
| 次要用户流程 | 60% | 80% |
问题: 测试超时失败
解决:
test.setTimeout(60000); // 增加超时时间
await page.waitForLoadState('networkidle'); // 等待网络空闲
问题: 找不到页面元素
解决:
// 使用显式等待
await page.waitForSelector('.my-element', { timeout: 5000 });
// 使用data-testid
await page.locator('[data-testid="submit-button"]').click();
问题: API返回401
解决:
| 日期 | 版本 | 描述 |
|---|---|---|
| 2026-01-03 | 1.0 | 初始版本,基于史诗010 E2E测试经验创建 |
文档状态: 正式版 下次评审: 2026-02-03