E2E 测试
Playwright
安装
bash
npm init playwright@latest
# 或
npm install -D @playwright/test
npx playwright installnpm 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
| 维度 | Playwright | Cypress |
|---|---|---|
| 浏览器 | Chromium, Firefox, WebKit | 仅 Chromium |
| 语言 | TypeScript, JS, Python, Java, .NET | JS, TS |
| 速度 | 快(原生协议) | 中等 |
| 调试 | VS Code 插件, Trace Viewer | 自带调试器 |
| 多标签 | ✅ 原生支持 | ❌ 受限 |
| 网络 Mock | page.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')
})
})
Cao Note