2
0

e2e-testing-standards.md 18 KB

E2E 测试规范

版本信息

版本 日期 描述 作者
1.0 2026-01-03 创建E2E测试规范文档 James (Claude Code)

概述

本文档定义了端到端(E2E)测试的标准和最佳实践,用于验证完整的用户流程和系统功能。

适用范围

  • Web应用: web/tests/e2e/ - 基于Playwright的Web应用E2E测试
  • 小程序应用: mini/tests/e2e/ - 基于Taro/DOT的小程序E2E测试(如需要)

测试框架栈

Web应用E2E测试

  • Playwright: E2E测试框架
  • 支持浏览器: Chromium、Firefox、WebKit、Mobile Chrome、Mobile Safari

测试文件组织规范

目录结构

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/ 目录 <数据类型>.jsontest-data.ts
测试工具 utils/ 目录 <用途>.ts

Playwright 配置规范

基本配置

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,
  },
});

E2E测试类型

1. 页面级E2E测试

目的: 验证用户在页面上的完整交互流程

位置: 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();
  });
});

2. API兼容性测试

目的: 验证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();
  });
});

Page Object Model

使用Page Object模式

封装页面交互逻辑,提高测试可维护性:

管理后台示例

// 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);
  }
}

测试Setup工具

// 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

// 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(/正则表达式/);

API响应格式断言

// 包装响应格式
{
  "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

最佳实践

1. 使用test.describe分组

test.describe('用户管理', () => {
  test.describe('创建用户', () => {
    test('应该成功创建用户', async ({ page }) => {
      // ...
    });
  });
});

2. 使用beforeEach/beforeAll设置前置条件

test.beforeEach(async ({ adminLoginPage }) => {
  await adminLoginPage.goto();
});

test.beforeAll(async ({ request }) => {
  // 执行一次,如登录获取token
});

3. 使用test.skip合理跳过测试

test('获取广告详情', async ({ request }) => {
  if (!userToken) {
    test.skip(true, '缺少认证token,请先创建测试用户');
  }
  // ...
});

4. 使用console.debug调试

if (loginResponse.status() === 200) {
  console.log('✅ 登录成功,获取到token');
} else {
  const error = await loginResponse.json();
  console.error('❌ 登录失败:', error);
}

5. 验证失败后查看页面结构

# E2E测试失败时先查看页面结构
cat test-results/**/error-context.md

覆盖率要求

测试类型 最低要求 目标要求
关键用户流程 100% 100%
主要用户流程 80% 90%
次要用户流程 60% 80%

常见问题

1. 测试超时

问题: 测试超时失败

解决:

test.setTimeout(60000); // 增加超时时间
await page.waitForLoadState('networkidle'); // 等待网络空闲

2. 元素找不到

问题: 找不到页面元素

解决:

// 使用显式等待
await page.waitForSelector('.my-element', { timeout: 5000 });

// 使用data-testid
await page.locator('[data-testid="submit-button"]').click();

3. 认证失败

问题: API返回401

解决:

  • 确保数据库中有测试用户
  • 检查tenantId是否正确
  • 验证token是否正确传递

相关文档

更新日志

日期 版本 描述
2026-01-03 1.0 初始版本,基于史诗010 E2E测试经验创建

文档状态: 正式版 下次评审: 2026-02-03