Skip to content

E2E 测试 ​

Playwright ​

安装 ​

bash
npm init playwright@latest
# 或
npm install -D @playwright/test
npx playwright install
npm init playwright@latest
# 或
npm install -D @playwright/test
npx playwright install

配置 ​

ts
// playwright.config.ts
import { defineConfig } from '@playwright/test'

export default defineConfig({
  testDir: './e2e',
  timeout: 30000,
  retries: 1,
  use: {
    baseURL: 'http://localhost:5173',
    screenshot: 'only-on-failure',
    video: 'retain-on-failure',
  },
  projects: [
    { name: 'chromium', use: { browserName: 'chromium' } },
    { name: 'firefox', use: { browserName: 'firefox' } },
    { name: 'webkit', use: { browserName: 'webkit' } },
  ],
})
// playwright.config.ts
import { defineConfig } from '@playwright/test'

export default defineConfig({
  testDir: './e2e',
  timeout: 30000,
  retries: 1,
  use: {
    baseURL: 'http://localhost:5173',
    screenshot: 'only-on-failure',
    video: 'retain-on-failure',
  },
  projects: [
    { name: 'chromium', use: { browserName: 'chromium' } },
    { name: 'firefox', use: { browserName: 'firefox' } },
    { name: 'webkit', use: { browserName: 'webkit' } },
  ],
})

基础用法 ​

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

test('user can login', async ({ page }) => {
  await page.goto('/login')

  // 填写表单
  await page.fill('[data-testid="username"]', 'admin')
  await page.fill('[data-testid="password"]', 'password123')
  await page.click('[data-testid="submit"]')

  // 等待跳转
  await page.waitForURL('/dashboard')

  // 验证结果
  await expect(page.locator('h1')).toHaveText('Dashboard')
  await expect(page.locator('.welcome')).toContainText('Welcome, admin')
})

test('shows error on invalid login', async ({ page }) => {
  await page.goto('/login')
  await page.fill('[data-testid="username"]', 'invalid')
  await page.fill('[data-testid="password"]', 'wrong')
  await page.click('[data-testid="submit"]')

  await expect(page.locator('.error-message')).toBeVisible()
  await expect(page.locator('.error-message')).toContainText('Invalid credentials')
})
// e2e/login.spec.ts
import { test, expect } from '@playwright/test'

test('user can login', async ({ page }) => {
  await page.goto('/login')

  // 填写表单
  await page.fill('[data-testid="username"]', 'admin')
  await page.fill('[data-testid="password"]', 'password123')
  await page.click('[data-testid="submit"]')

  // 等待跳转
  await page.waitForURL('/dashboard')

  // 验证结果
  await expect(page.locator('h1')).toHaveText('Dashboard')
  await expect(page.locator('.welcome')).toContainText('Welcome, admin')
})

test('shows error on invalid login', async ({ page }) => {
  await page.goto('/login')
  await page.fill('[data-testid="username"]', 'invalid')
  await page.fill('[data-testid="password"]', 'wrong')
  await page.click('[data-testid="submit"]')

  await expect(page.locator('.error-message')).toBeVisible()
  await expect(page.locator('.error-message')).toContainText('Invalid credentials')
})

接口 Mock ​

ts
test('handles empty state', async ({ page }) => {
  // Mock API 返回空数据
  await page.route('**/api/items', async route => {
    await route.fulfill({ json: [] })
  })

  await page.goto('/items')
  await expect(page.locator('.empty-state')).toBeVisible()
  await expect(page.locator('.empty-state')).toContainText('No items found')
})

test('handles API error', async ({ page }) => {
  await page.route('**/api/items', async route => {
    await route.fulfill({ status: 500 })
  })

  await page.goto('/items')
  await expect(page.locator('.error-message')).toBeVisible()
})
test('handles empty state', async ({ page }) => {
  // Mock API 返回空数据
  await page.route('**/api/items', async route => {
    await route.fulfill({ json: [] })
  })

  await page.goto('/items')
  await expect(page.locator('.empty-state')).toBeVisible()
  await expect(page.locator('.empty-state')).toContainText('No items found')
})

test('handles API error', async ({ page }) => {
  await page.route('**/api/items', async route => {
    await route.fulfill({ status: 500 })
  })

  await page.goto('/items')
  await expect(page.locator('.error-message')).toBeVisible()
})

高级用法 ​

ts
// 测试文件上传
test('upload file', async ({ page }) => {
  await page.goto('/upload')
  const fileInput = page.locator('input[type="file"]')
  await fileInput.setInputFiles({
    name: 'test.pdf',
    mimeType: 'application/pdf',
    buffer: Buffer.from('test content'),
  })
  await page.click('button[type="submit"]')
  await expect(page.locator('.success')).toBeVisible()
})

// 测试响应式
test('responsive layout', async ({ page }) => {
  // 移动端
  await page.setViewportSize({ width: 375, height: 667 })
  await page.goto('/')
  await expect(page.locator('.mobile-menu')).toBeVisible()

  // 桌面端
  await page.setViewportSize({ width: 1920, height: 1080 })
  await expect(page.locator('.desktop-nav')).toBeVisible()
})
// 测试文件上传
test('upload file', async ({ page }) => {
  await page.goto('/upload')
  const fileInput = page.locator('input[type="file"]')
  await fileInput.setInputFiles({
    name: 'test.pdf',
    mimeType: 'application/pdf',
    buffer: Buffer.from('test content'),
  })
  await page.click('button[type="submit"]')
  await expect(page.locator('.success')).toBeVisible()
})

// 测试响应式
test('responsive layout', async ({ page }) => {
  // 移动端
  await page.setViewportSize({ width: 375, height: 667 })
  await page.goto('/')
  await expect(page.locator('.mobile-menu')).toBeVisible()

  // 桌面端
  await page.setViewportSize({ width: 1920, height: 1080 })
  await expect(page.locator('.desktop-nav')).toBeVisible()
})

Cypress ​

基础用法 ​

js
// cypress/e2e/todo.cy.js
describe('Todo App', () => {
  beforeEach(() => {
    cy.visit('/todos')
  })

  it('adds a new todo', () => {
    cy.get('[data-testid="new-todo"]').type('Buy milk{enter}')
    cy.get('.todo-list li').should('have.length', 1)
    cy.get('.todo-list li').first().should('have.text', 'Buy milk')
  })

  it('completes a todo', () => {
    cy.get('[data-testid="new-todo"]').type('Buy milk{enter}')
    cy.get('.todo-list li .toggle').click()
    cy.get('.todo-list li').first().should('have.class', 'completed')
  })

  it('filters todos', () => {
    cy.get('[data-testid="new-todo"]').type('Task A{enter}')
    cy.get('[data-testid="new-todo"]').type('Task B{enter}')
    cy.get('.todo-list li .toggle').first().click()
    cy.contains('Active').click()
    cy.get('.todo-list li').should('have.length', 1)
  })
})
// cypress/e2e/todo.cy.js
describe('Todo App', () => {
  beforeEach(() => {
    cy.visit('/todos')
  })

  it('adds a new todo', () => {
    cy.get('[data-testid="new-todo"]').type('Buy milk{enter}')
    cy.get('.todo-list li').should('have.length', 1)
    cy.get('.todo-list li').first().should('have.text', 'Buy milk')
  })

  it('completes a todo', () => {
    cy.get('[data-testid="new-todo"]').type('Buy milk{enter}')
    cy.get('.todo-list li .toggle').click()
    cy.get('.todo-list li').first().should('have.class', 'completed')
  })

  it('filters todos', () => {
    cy.get('[data-testid="new-todo"]').type('Task A{enter}')
    cy.get('[data-testid="new-todo"]').type('Task B{enter}')
    cy.get('.todo-list li .toggle').first().click()
    cy.contains('Active').click()
    cy.get('.todo-list li').should('have.length', 1)
  })
})

Playwright vs Cypress ​

维度PlaywrightCypress
浏览器Chromium, Firefox, WebKit仅 Chromium
语言TypeScript, JS, Python, Java, .NETJS, TS
速度快(原生协议)中等
调试VS Code 插件, Trace Viewer自带调试器
多标签✅ 原生支持❌ 受限
网络 Mockpage.route()cy.intercept()
组件测试✅ 支持✅ 支持
并行✅ 内置❌ 需 Dashboard

E2E 最佳实践 ​

ts
// 1. 使用 data-testid 选择器(避免依赖 CSS 类名)
<button data-testid="submit">提交</button>
page.locator('[data-testid="submit"]')

// 2. 测试关键用户流程,而非每个细节
// 注册 → 登录 → 创建内容 → 验证 → 登出

// 3. 保持测试独立
test.beforeEach(async ({ page }) => {
  // 每个测试前重置状态
  await page.evaluate(() => localStorage.clear())
})

// 4. 合理使用 Fixtures
test.describe('authenticated', () => {
  test.use({ storageState: 'e2e/.auth/user.json' })

  test('visits dashboard', async ({ page }) => {
    await page.goto('/dashboard')
    await expect(page.locator('h1')).toHaveText('Dashboard')
  })
})
// 1. 使用 data-testid 选择器(避免依赖 CSS 类名)
<button data-testid="submit">提交</button>
page.locator('[data-testid="submit"]')

// 2. 测试关键用户流程,而非每个细节
// 注册 → 登录 → 创建内容 → 验证 → 登出

// 3. 保持测试独立
test.beforeEach(async ({ page }) => {
  // 每个测试前重置状态
  await page.evaluate(() => localStorage.clear())
})

// 4. 合理使用 Fixtures
test.describe('authenticated', () => {
  test.use({ storageState: 'e2e/.auth/user.json' })

  test('visits dashboard', async ({ page }) => {
    await page.goto('/dashboard')
    await expect(page.locator('h1')).toHaveText('Dashboard')
  })
})