端到端测试实战:Playwright自动化测试指南

前言

各位前端小伙伴,不知道你们有没有这样的经历:每次改完代码,都要手动打开浏览器点点点,测试各种功能是否正常。

如果项目复杂,这个过程简直是噩梦!

我曾经为了测试一个表单功能,连续点了半小时鼠标,手都快抽筋了。后来发现了Playwright,才知道原来测试还可以这样玩!

什么是Playwright?

Playwright是一个由Microsoft开发的端到端测试框架。它可以自动化测试浏览器行为,模拟真实用户操作。相比Cypress,Playwright有以下优势:

  1. 跨浏览器支持:支持Chrome、Firefox、Safari、Edge
  2. 多语言支持:JavaScript/TypeScript、Python、Java、C#
  3. 自动等待:智能等待元素出现,无需手动设置timeout
  4. 网络拦截:可以拦截和修改网络请求
  5. 移动端测试:支持模拟移动端设备

快速上手

安装依赖

npm install -D playwright
npx playwright install

编写第一个测试

创建tests/login.spec.ts:

import { test, expect } from '@playwright/test'

test('login test', async ({ page }) => {
  await page.goto('https://example.com/login')
  
  await page.fill('#username', 'testuser')
  await page.fill('#password', 'testpass')
  await page.click('button[type="submit"]')
  
  await expect(page).toHaveURL('https://example.com/dashboard')
  await expect(page.locator('text=Welcome, testuser')).toBeVisible()
})

运行测试

npx playwright test

核心概念

Page对象

page是Playwright的核心对象,代表一个浏览器页面:

await page.goto(url)           // 导航到URL
await page.click(selector)     // 点击元素
await page.fill(selector, text) // 填写表单
await page.type(selector, text) // 模拟键盘输入
await page.waitForSelector(selector) // 等待元素出现
await page.screenshot({ path: 'screenshot.png' }) // 截图

Locator定位器

Locator是Playwright推荐的元素定位方式,支持多种选择器:

page.locator('text=Submit')          // 文本选择器
page.locator('button')               // 标签选择器
page.locator('#username')            // ID选择器
page.locator('.btn-primary')         // 类选择器
page.locator('[data-testid="submit"]') // 属性选择器
page.locator('//button[@type="submit"]') // XPath选择器

自动等待

Playwright会自动等待元素可操作,无需手动设置等待:

// Playwright会自动等待按钮可点击
await page.click('button[type="submit"]')

// 自动等待文本出现
await expect(page.locator('text=Success')).toBeVisible()

实战示例

测试登录流程

import { test, expect } from '@playwright/test'

test.describe('Login functionality', () => {
  test('should login with valid credentials', async ({ page }) => {
    await page.goto('/login')
    
    await page.fill('#email', 'user@example.com')
    await page.fill('#password', 'password123')
    await page.click('button:has-text("Login")')
    
    await expect(page).toHaveURL('/dashboard')
    await expect(page.locator('.user-name')).toHaveText('Welcome, User')
  })
  
  test('should show error for invalid credentials', async ({ page }) => {
    await page.goto('/login')
    
    await page.fill('#email', 'wrong@example.com')
    await page.fill('#password', 'wrongpassword')
    await page.click('button:has-text("Login")')
    
    await expect(page.locator('.error-message')).toBeVisible()
    await expect(page.locator('.error-message')).toHaveText('Invalid credentials')
  })
})

测试表单验证

test('form validation', async ({ page }) => {
  await page.goto('/register')
  
  await page.click('button:has-text("Submit")')
  
  await expect(page.locator('#email-error')).toHaveText('Email is required')
  await expect(page.locator('#password-error')).toHaveText('Password is required')
  
  await page.fill('#email', 'invalid-email')
  await page.click('button:has-text("Submit")')
  
  await expect(page.locator('#email-error')).toHaveText('Invalid email format')
})

网络拦截

test('should mock API response', async ({ page }) => {
  await page.route('/api/users', async (route) => {
    const json = [{ id: 1, name: 'Mock User' }]
    await route.fulfill({ json })
  })
  
  await page.goto('/users')
  
  await expect(page.locator('text=Mock User')).toBeVisible()
})

移动端测试

import { test, expect, devices } from '@playwright/test'

test.use({
  ...devices['iPhone 14'],
})

test('mobile login', async ({ page }) => {
  await page.goto('/login')
  
  await page.fill('#email', 'user@example.com')
  await page.fill('#password', 'password')
  await page.click('button:has-text("Login")')
  
  await expect(page).toHaveURL('/dashboard')
})

截图和录屏

test('should capture screenshot', async ({ page }) => {
  await page.goto('/home')
  await page.screenshot({ 
    path: 'homepage.png',
    fullPage: true 
  })
})

test('should record video', async ({ page }, testInfo) => {
  await page.goto('/checkout')
  await page.click('#buy-now')
  
  // 测试失败时自动录屏
  testInfo.video?.path()
})

高级用法

Page Object模式

// pages/LoginPage.ts
export class LoginPage {
  constructor(private page: Page) {}
  
  async goto() {
    await this.page.goto('/login')
  }
  
  async login(email: string, password: string) {
    await this.page.fill('#email', email)
    await this.page.fill('#password', password)
    await this.page.click('button:has-text("Login")')
  }
  
  async getErrorMessage() {
    return this.page.locator('.error-message').textContent()
  }
}

// test
test('login with page object', async ({ page }) => {
  const loginPage = new LoginPage(page)
  await loginPage.goto()
  await loginPage.login('user@example.com', 'password')
  
  await expect(page).toHaveURL('/dashboard')
})

并行测试

Playwright默认支持并行测试,可以通过配置调整:

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

export default defineConfig({
  workers: 4, // 4个并行工作进程
  testMatch: '**/*.spec.ts',
})

测试报告

# 生成HTML报告
npx playwright test --reporter=html

# 生成JSON报告
npx playwright test --reporter=json

最佳实践

1. 使用data-testid属性

<!-- ✅ 推荐 -->
<button data-testid="submit-btn">Submit</button>

<!-- ❌ 不推荐 -->
<button class="btn btn-primary">Submit</button>
await page.click('[data-testid="submit-btn"]')

2. 保持测试独立

test.describe('User management', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('/login')
    await page.fill('#email', 'admin@example.com')
    await page.fill('#password', 'admin')
    await page.click('button:has-text("Login")')
  })
  
  test('create user', async ({ page }) => {
    // 测试代码
  })
  
  test('delete user', async ({ page }) => {
    // 测试代码
  })
})

3. 避免依赖执行顺序

// ❌ 错误:delete测试依赖create测试
test('create user', async ({ page }) => {})
test('delete user', async ({ page }) => {})

// ✅ 正确:每个测试都是独立的
test('create user', async ({ page }) => {
  await createUser()
})

test('delete user', async ({ page }) => {
  await createUser() // 每个测试自己创建数据
  await deleteUser()
})

4. 使用fixtures

// fixtures.ts
import { test as base } from '@playwright/test'

type MyFixtures = {
  authenticatedPage: Page
}

export const test = base.extend<MyFixtures>({
  authenticatedPage: async ({ page }, use) => {
    await page.goto('/login')
    await page.fill('#email', 'user@example.com')
    await page.fill('#password', 'password')
    await page.click('button:has-text("Login")')
    await use(page)
  }
})

// 使用fixture
test('dashboard', async ({ authenticatedPage }) => {
  await authenticatedPage.goto('/dashboard')
})

常见误区

误区1:使用硬编码的等待时间

// ❌ 错误
await page.click('button')
await page.waitForTimeout(1000) // 不推荐

// ✅ 正确:Playwright会自动等待
await page.click('button')
await expect(page.locator('text=Success')).toBeVisible()

误区2:测试过多细节

// ❌ 测试实现细节
test('should call API when button is clicked', async ({ page }) => {
  // 不要测试内部实现
})

// ✅ 测试用户行为
test('should show results when search button is clicked', async ({ page }) => {
  await page.fill('#search', 'keyword')
  await page.click('button:has-text("Search")')
  await expect(page.locator('.results')).toBeVisible()
})

误区3:测试环境不稳定

确保测试环境是隔离的、可重复的:

  • 使用Mock数据
  • 每次测试后清理数据
  • 使用测试专用的数据库

总结

Playwright是一个强大的端到端测试工具,它可以模拟真实用户操作,帮助我们发现UI层面的bug。记住:

  1. 测试应该模拟真实用户行为
  2. 使用Locator定位元素
  3. 利用自动等待特性
  4. 保持测试独立和可重复

现在,告别手动测试,让Playwright帮你完成重复性工作吧!你会发现,原来测试也可以这么轻松!

最后提醒:端到端测试虽然强大,但不要过度使用。单元测试+集成测试+少量端到端测试是最佳组合!

Logo

北京人形旗下天工造物具身智能开源社区,聚焦具身天工与慧思开物两大平台

更多推荐