端到端测试Skill e2e-testing

端到端测试是一种自动化测试技能,用于验证软件系统的完整用户工作流和集成行为。它从用户角度出发,测试关键路径如登录、支付等,确保前端与后端正确交互。适用于自动化QA过程、捕获回归、测试API集成等。关键词:端到端测试,用户工作流,自动化测试,Playwright,Cypress,测试框架,CI/CD,QA自动化。

测试 0 次安装 0 次浏览 更新于 3/18/2026

名称: e2e-testing 描述: 编写全面的端到端测试,以验证完整的用户工作流、组件之间的集成以及从用户视角的系统行为,使用Playwright、Cypress或类似框架。用于测试用户流程、验证功能集成、跨真实浏览器测试、确保UI交互正确、验证表单提交端到端、测试认证流程、捕获回归、自动化QA过程、测试API集成或构建生产部署的信心。—

端到端测试 - 测试完整用户工作流

何时使用此技能

  • 测试完整的用户工作流端到端
  • 验证前端和后端之间的集成
  • 测试关键用户旅程(注册、结账等)
  • 验证表单提交和数据持久化
  • 测试认证和授权流程
  • 捕获生产部署前的回归
  • 自动化手动QA测试过程
  • 测试跨设备的响应行为
  • 验证第三方集成正常工作
  • 通过自动化测试构建CI/CD信心
  • 测试错误处理和边缘情况
  • 确保可访问性功能正确工作

何时使用此技能

  • 验证完整的用户流程、测试集成、验证关键路径,或确保功能在生产类似环境中工作。
  • 当处理相关任务或功能时
  • 在需要此专业知识的开发过程中

使用时机: 验证完整的用户流程、测试集成、验证关键路径,或确保功能在生产类似环境中工作。

核心原则

  1. 测试用户旅程,而非实现 - 关注用户做什么
  2. 优先测试关键路径 - 快乐路径和关键收入流
  3. 稳定选择器 - 使用data-testid,而非脆弱的CSS选择器
  4. 独立测试 - 测试之间不共享状态
  5. 快速反馈 - 并行执行,智能重试

Playwright(推荐)

1. 基本设置

// 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',
  
  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: 'Mobile Chrome',
      use: { ...devices['Pixel 5'] }
    }
  ],

  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI
  }
});

2. 编写测试

// e2e/auth.spec.ts
import { test, expect } from '@playwright/test';

test.describe('认证', () => {
  test('用户可以注册', async ({ page }) => {
    await page.goto('/signup');
    
    // 填写表单
    await page.getByLabel('Email').fill('user@example.com');
    await page.getByLabel('Password').fill('SecurePass123!');
    await page.getByLabel('Confirm Password').fill('SecurePass123!');
    
    // 提交
    await page.getByRole('button', { name: 'Sign Up' }).click();
    
    // 断言成功
    await expect(page).toHaveURL('/dashboard');
    await expect(page.getByText('Welcome')).toBeVisible();
  });

  test('对无效邮箱显示错误', async ({ page }) => {
    await page.goto('/signup');
    
    await page.getByLabel('Email').fill('invalid-email');
    await page.getByLabel('Password').fill('password123');
    await page.getByRole('button', { name: 'Sign Up' }).click();
    
    // 错误消息出现
    await expect(page.getByText('Invalid email format')).toBeVisible();
    
    // 仍在注册页面
    await expect(page).toHaveURL('/signup');
  });

  test('用户可以登录', async ({ page }) => {
    await page.goto('/login');
    
    await page.getByLabel('Email').fill('existing@example.com');
    await page.getByLabel('Password').fill('password123');
    await page.getByRole('button', { name: 'Log In' }).click();
    
    await expect(page).toHaveURL('/dashboard');
  });
});

3. 页面对象模型

// ✅ 封装页面交互
// e2e/pages/LoginPage.ts
import { Page } from '@playwright/test';

export class LoginPage {
  constructor(private page: Page) {}

  async goto() {
    await this.page.goto('/login');
  }

  async login(email: string, password: string) {
    await this.page.getByLabel('Email').fill(email);
    await this.page.getByLabel('Password').fill(password);
    await this.page.getByRole('button', { name: 'Log In' }).click();
  }

  async getErrorMessage() {
    return this.page.getByRole('alert').textContent();
  }
}

// 测试中的使用
test('成功登录', async ({ page }) => {
  const loginPage = new LoginPage(page);
  
  await loginPage.goto();
  await loginPage.login('user@example.com', 'password123');
  
  await expect(page).toHaveURL('/dashboard');
});

4. 用于设置/清理的夹具

// ✅ 可重用的测试夹具
// e2e/fixtures.ts
import { test as base } from '@playwright/test';

type Fixtures = {
  authenticatedPage: Page;
};

export const test = base.extend<Fixtures>({
  authenticatedPage: async ({ page }, use) => {
    // 设置:测试前登录
    await page.goto('/login');
    await page.getByLabel('Email').fill('test@example.com');
    await page.getByLabel('Password').fill('password123');
    await page.getByRole('button', { name: 'Log In' }).click();
    await page.waitForURL('/dashboard');
    
    // 运行测试
    await use(page);
    
    // 清理:测试后登出
    await page.getByRole('button', { name: 'Log Out' }).click();
  }
});

// 使用
test('作为认证用户创建帖子', async ({ authenticatedPage }) => {
  await authenticatedPage.goto('/posts/new');
  await authenticatedPage.getByLabel('Title').fill('My Post');
  await authenticatedPage.getByRole('button', { name: 'Publish' }).click();
  
  await expect(authenticatedPage.getByText('Post published')).toBeVisible();
});

5. 等待策略

// ✅ 等待条件,而非任意超时
test('搜索功能', async ({ page }) => {
  await page.goto('/search');
  
  // 输入搜索
  await page.getByPlaceholder('Search...').fill('playwright');
  
  // ❌ 糟糕 - 任意超时
  await page.waitForTimeout(2000);
  
  // ✅ 良好 - 等待特定条件
  await page.waitForLoadState('networkidle');
  
  // ✅ 更好 - 等待特定元素
  await page.waitForSelector('[data-testid="search-results"]');
  
  // ✅ 最佳 - 隐式等待与断言
  await expect(page.getByTestId('search-results')).toBeVisible();
  
  const results = page.getByTestId('result-item');
  await expect(results).toHaveCount(10);
});

// ✅ 等待API调用
test('数据正确加载', async ({ page }) => {
  // 等待特定API调用
  const responsePromise = page.waitForResponse(
    (response) => response.url().includes('/api/users') && response.status() === 200
  );
  
  await page.goto('/users');
  
  const response = await responsePromise;
  const data = await response.json();
  
  expect(data.users).toHaveLength(5);
});

6. 测试表单与交互

test('结账流程', async ({ page }) => {
  await page.goto('/cart');
  
  // 点击结账按钮
  await page.getByRole('button', { name: 'Checkout' }).click();
  
  // 填写运送信息
  await page.getByLabel('Full Name').fill('John Doe');
  await page.getByLabel('Address').fill('123 Main St');
  await page.getByLabel('City').fill('San Francisco');
  await page.getByLabel('State').selectOption('CA');
  await page.getByLabel('ZIP Code').fill('94102');
  
  await page.getByRole('button', { name: 'Continue' }).click();
  
  // 填写支付信息(使用测试卡)
  await page.frameLocator('iframe[title="Payment"]')
    .getByPlaceholder('Card number')
    .fill('4242424242424242');
  
  await page.frameLocator('iframe[title="Payment"]')
    .getByPlaceholder('MM / YY')
    .fill('12/25');
  
  await page.frameLocator('iframe[title="Payment"]')
    .getByPlaceholder('CVC')
    .fill('123');
  
  // 提交订单
  await page.getByRole('button', { name: 'Place Order' }).click();
  
  // 验证成功
  await expect(page.getByText('Order confirmed')).toBeVisible();
  await expect(page).toHaveURL(/\/orders\/\d+/);
});

7. 测试文件上传

test('上传个人资料图片', async ({ page }) => {
  await page.goto('/profile/edit');
  
  // 上传文件
  const fileInput = page.getByLabel('Profile Picture');
  await fileInput.setInputFiles('fixtures/avatar.png');
  
  // 等待上传完成
  await expect(page.getByText('Upload complete')).toBeVisible();
  
  await page.getByRole('button', { name: 'Save' }).click();
  
  // 验证新头像显示
  const avatar = page.getByRole('img', { name: 'Profile picture' });
  await expect(avatar).toHaveAttribute('src', /avatar\.png/);
});

8. 视觉回归测试

test('主页看起来正确', async ({ page }) => {
  await page.goto('/');
  
  // 截取屏幕截图并比较
  await expect(page).toHaveScreenshot('homepage.png');
});

test('按钮悬停状态', async ({ page }) => {
  await page.goto('/');
  
  const button = page.getByRole('button', { name: 'Get Started' });
  await button.hover();
  
  await expect(button).toHaveScreenshot('button-hover.png');
});

Cypress 替代方案

1. Cypress 设置

// cypress/e2e/auth.cy.ts
describe('认证', () => {
  beforeEach(() => {
    cy.visit('/login');
  });

  it('允许用户登录', () => {
    cy.get('[data-testid="email"]').type('user@example.com');
    cy.get('[data-testid="password"]').type('password123');
    cy.get('[data-testid="submit"]').click();
    
    cy.url().should('include', '/dashboard');
    cy.contains('Welcome back').should('be.visible');
  });

  it('对错误密码显示错误', () => {
    cy.get('[data-testid="email"]').type('user@example.com');
    cy.get('[data-testid="password"]').type('wrongpassword');
    cy.get('[data-testid="submit"]').click();
    
    cy.contains('Invalid credentials').should('be.visible');
  });
});

2. Cypress 命令

// cypress/support/commands.ts
declare global {
  namespace Cypress {
    interface Chainable {
      login(email: string, password: string): Chainable<void>;
      createPost(title: string, content: string): Chainable<void>;
    }
  }
}

Cypress.Commands.add('login', (email, password) => {
  cy.visit('/login');
  cy.get('[data-testid="email"]').type(email);
  cy.get('[data-testid="password"]').type(password);
  cy.get('[data-testid="submit"]').click();
  cy.url().should('include', '/dashboard');
});

Cypress.Commands.add('createPost', (title, content) => {
  cy.visit('/posts/new');
  cy.get('[data-testid="title"]').type(title);
  cy.get('[data-testid="content"]').type(content);
  cy.get('[data-testid="publish"]').click();
});

// 使用
it('创建帖子', () => {
  cy.login('user@example.com', 'password123');
  cy.createPost('My Post', 'Post content here');
  cy.contains('Post published').should('be.visible');
});

最佳实践

1. 使用稳定选择器

// ❌ 脆弱 - 样式改变时失效
await page.locator('.btn-primary.large').click();
await page.locator('div > div > button:nth-child(2)').click();

// ✅ 语义 - 使用可访问角色
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByRole('textbox', { name: 'Email' }).fill('test@example.com');

// ✅ 测试ID - 显式测试钩子
await page.getByTestId('submit-button').click();
await page.getByTestId('email-input').fill('test@example.com');

// 在组件中:
<button data-testid="submit-button">Submit</button>
<input data-testid="email-input" type="email" />

2. 独立测试

// ❌ 测试相互依赖
test('创建用户', async ({ page }) => {
  // 创建用户,ID存储在全局
  globalUserId = await createUser();
});

test('更新用户', async ({ page }) => {
  // 如果前一个测试未运行,则失败
  await updateUser(globalUserId);
});

// ✅ 每个测试自包含
test('更新用户', async ({ page }) => {
  // 仅为此测试创建用户
  const user = await createTestUser();
  
  await page.goto(`/users/${user.id}/edit`);
  await page.getByLabel('Name').fill('New Name');
  await page.getByRole('button', { name: 'Save' }).click();
  
  await expect(page.getByText('Updated successfully')).toBeVisible();
  
  // 清理
  await deleteTestUser(user.id);
});

3. 测试数据管理

// ✅ 用于测试数据的工厂函数
export function createTestUser(overrides = {}) {
  return {
    email: `test-${Date.now()}@example.com`,
    password: 'password123',
    name: 'Test User',
    ...overrides
  };
}

// 使用
test('创建多个用户', async ({ page }) => {
  const user1 = createTestUser({ name: 'Alice' });
  const user2 = createTestUser({ name: 'Bob' });
  
  // 每个都有唯一邮箱,由于时间戳
});

// ✅ 数据库播种
test.beforeEach(async ({ page }) => {
  // 使用测试数据播种数据库
  await db.users.deleteMany();
  await db.users.createMany([
    createTestUser({ email: 'alice@test.com' }),
    createTestUser({ email: 'bob@test.com' })
  ]);
});

4. 模拟外部服务

// ✅ 模拟API响应
test('处理API错误', async ({ page }) => {
  // 拦截和模拟API调用
  await page.route('**/api/users', (route) => {
    route.fulfill({
      status: 500,
      body: JSON.stringify({ error: 'Server error' })
    });
  });
  
  await page.goto('/users');
  
  await expect(page.getByText('Failed to load users')).toBeVisible();
});

// ✅ 模拟成功响应
test('显示用户列表', async ({ page }) => {
  await page.route('**/api/users', (route) => {
    route.fulfill({
      status: 200,
      body: JSON.stringify({
        users: [
          { id: 1, name: 'Alice' },
          { id: 2, name: 'Bob' }
        ]
      })
    });
  });
  
  await page.goto('/users');
  
  await expect(page.getByText('Alice')).toBeVisible();
  await expect(page.getByText('Bob')).toBeVisible();
});

E2E 测试检查表

测试覆盖:
□ 关键用户路径测试(注册、登录、购买)
□ 快乐路径覆盖
□ 错误场景测试
□ 边缘情况包括
□ 移动视口测试

测试质量:
□ 稳定选择器(data-testid、角色)
□ 无任意超时
□ 无测试依赖
□ 对异步操作的正确等待
□ 清晰的测试描述

性能:
□ 测试并行运行
□ 快速测试数据创建
□ 最小化不必要等待
□ 模拟的strategic使用
□ 测试后清理

CI/CD:
□ 每个PR运行测试
□ 失败阻止合并
□ 失败时截图
□ 测试报告生成
□ 识别和修复不稳定的测试

资源


记住:E2E测试验证用户实际体验。保持它们专注于关键路径、稳定且快速。