playwright-testing

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Playwright E2E Testing Skill

Playwright 端到端测试技能

Load with: base.md + [framework].md
For end-to-end testing of web applications with Playwright - cross-browser, fast, reliable.

加载方式:base.md + [framework].md
本文介绍如何使用Playwright进行Web应用的端到端测试——跨浏览器、快速、可靠。

Setup

环境搭建

Installation

安装

bash
undefined
bash
undefined

New project

新项目初始化

npm init playwright@latest
npm init playwright@latest

Existing project

现有项目添加依赖

npm install -D @playwright/test npx playwright install
undefined
npm install -D @playwright/test npx playwright install
undefined

Configuration

配置

typescript
// 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'],
    ['list'],
    process.env.CI ? ['github'] : ['line'],
  ],

  use: {
    baseURL: process.env.BASE_URL || 'http://localhost:3000',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
    video: 'retain-on-failure',
  },

  projects: [
    // Auth setup - runs once before all tests
    { name: 'setup', testMatch: /.*\.setup\.ts/ },

    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
      dependencies: ['setup'],
    },
    {
      name: 'firefox',
      use: { ...devices['Desktop Firefox'] },
      dependencies: ['setup'],
    },
    {
      name: 'webkit',
      use: { ...devices['Desktop Safari'] },
      dependencies: ['setup'],
    },
    // Mobile viewports
    {
      name: 'mobile-chrome',
      use: { ...devices['Pixel 5'] },
      dependencies: ['setup'],
    },
    {
      name: 'mobile-safari',
      use: { ...devices['iPhone 12'] },
      dependencies: ['setup'],
    },
  ],

  // Start dev server before tests
  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
    timeout: 120 * 1000,
  },
});

typescript
// 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'],
    ['list'],
    process.env.CI ? ['github'] : ['line'],
  ],

  use: {
    baseURL: process.env.BASE_URL || 'http://localhost:3000',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
    video: 'retain-on-failure',
  },

  projects: [
    // 全局认证前置操作 - 所有测试前运行一次
    { name: 'setup', testMatch: /.*\.setup\.ts/ },

    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
      dependencies: ['setup'],
    },
    {
      name: 'firefox',
      use: { ...devices['Desktop Firefox'] },
      dependencies: ['setup'],
    },
    {
      name: 'webkit',
      use: { ...devices['Desktop Safari'] },
      dependencies: ['setup'],
    },
    // 移动端视口
    {
      name: 'mobile-chrome',
      use: { ...devices['Pixel 5'] },
      dependencies: ['setup'],
    },
    {
      name: 'mobile-safari',
      use: { ...devices['iPhone 12'] },
      dependencies: ['setup'],
    },
  ],

  // 测试前启动开发服务器
  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
    timeout: 120 * 1000,
  },
});

Project Structure

项目结构

project/
├── e2e/
│   ├── fixtures/
│   │   ├── auth.fixture.ts      # Auth fixtures
│   │   └── test.fixture.ts      # Extended test with fixtures
│   ├── pages/
│   │   ├── base.page.ts         # Base page object
│   │   ├── login.page.ts        # Login page object
│   │   ├── dashboard.page.ts    # Dashboard page object
│   │   └── index.ts             # Export all pages
│   ├── tests/
│   │   ├── auth.spec.ts         # Auth tests
│   │   ├── dashboard.spec.ts    # Dashboard tests
│   │   └── checkout.spec.ts     # Checkout flow tests
│   ├── utils/
│   │   ├── helpers.ts           # Test helpers
│   │   └── test-data.ts         # Test data factories
│   └── auth.setup.ts            # Global auth setup
├── playwright.config.ts
└── .auth/                        # Stored auth state (gitignored)

project/
├── e2e/
│   ├── fixtures/
│   │   ├── auth.fixture.ts      # 认证夹具
│   │   └── test.fixture.ts      # 扩展测试夹具
│   ├── pages/
│   │   ├── base.page.ts         # 基础页面对象
│   │   ├── login.page.ts        # 登录页面对象
│   │   ├── dashboard.page.ts    # 控制台页面对象
│   │   └── index.ts             # 导出所有页面对象
│   ├── tests/
│   │   ├── auth.spec.ts         # 认证测试
│   │   ├── dashboard.spec.ts    # 控制台测试
│   │   └── checkout.spec.ts     # 结账流程测试
│   ├── utils/
│   │   ├── helpers.ts           # 测试辅助工具
│   │   └── test-data.ts         # 测试数据工厂
│   └── auth.setup.ts            # 全局认证前置脚本
├── playwright.config.ts
└── .auth/                        # 存储认证状态(已加入.gitignore)

Locator Strategy (Priority Order)

定位器策略(优先级排序)

Use locators that mirror how users interact with the page:
typescript
// ✅ BEST: Role-based (accessible, resilient)
page.getByRole('button', { name: 'Submit' })
page.getByRole('textbox', { name: 'Email' })
page.getByRole('link', { name: 'Sign up' })
page.getByRole('heading', { name: 'Welcome' })

// ✅ GOOD: User-facing text
page.getByLabel('Email address')
page.getByPlaceholder('Enter your email')
page.getByText('Welcome back')
page.getByTitle('Profile settings')

// ✅ GOOD: Test IDs (stable, explicit)
page.getByTestId('submit-button')
page.getByTestId('user-avatar')

// ⚠️ AVOID: CSS selectors (brittle)
page.locator('.btn-primary')
page.locator('#submit')

// ❌ NEVER: XPath (extremely brittle)
page.locator('//div[@class="container"]/button[1]')
应使用与用户操作页面方式一致的定位器:
typescript
// ✅ 最佳选择:基于角色(可访问性强、稳定性高)
page.getByRole('button', { name: '提交' })
page.getByRole('textbox', { name: '邮箱' })
page.getByRole('link', { name: '注册' })
page.getByRole('heading', { name: '欢迎' })

// ✅ 推荐:用户可见文本
page.getByLabel('邮箱地址')
page.getByPlaceholder('请输入邮箱')
page.getByText('欢迎回来')
page.getByTitle('个人设置')

// ✅ 推荐:测试ID(稳定、明确)
page.getByTestId('submit-button')
page.getByTestId('user-avatar')

// ⚠️ 尽量避免:CSS选择器(易失效)
page.locator('.btn-primary')
page.locator('#submit')

// ❌ 绝对禁止:XPath(极易失效)
page.locator('//div[@class="container"]/button[1]')

Chaining Locators

定位器链式调用

typescript
// Narrow down to specific section
const form = page.getByRole('form', { name: 'Login' });
await form.getByRole('textbox', { name: 'Email' }).fill('user@example.com');
await form.getByRole('button', { name: 'Submit' }).click();

// Filter within a list
const productCard = page.getByTestId('product-card')
  .filter({ hasText: 'Pro Plan' });
await productCard.getByRole('button', { name: 'Buy' }).click();

typescript
// 缩小到特定区域
const form = page.getByRole('form', { name: '登录' });
await form.getByRole('textbox', { name: '邮箱' }).fill('user@example.com');
await form.getByRole('button', { name: '提交' }).click();

// 在列表中筛选
const productCard = page.getByTestId('product-card')
  .filter({ hasText: '专业版' });
await productCard.getByRole('button', { name: '购买' }).click();

Page Object Model

页面对象模型

Base Page

基础页面类

typescript
// e2e/pages/base.page.ts
import { Page, Locator } from '@playwright/test';

export abstract class BasePage {
  constructor(protected page: Page) {}

  async navigate(path: string = '/') {
    await this.page.goto(path);
  }

  async waitForPageLoad() {
    await this.page.waitForLoadState('networkidle');
  }

  // Common elements
  get header() {
    return this.page.getByRole('banner');
  }

  get footer() {
    return this.page.getByRole('contentinfo');
  }

  // Common actions
  async clickNavLink(name: string) {
    await this.header.getByRole('link', { name }).click();
  }
}
typescript
// e2e/pages/base.page.ts
import { Page, Locator } from '@playwright/test';

export abstract class BasePage {
  constructor(protected page: Page) {}

  async navigate(path: string = '/') {
    await this.page.goto(path);
  }

  async waitForPageLoad() {
    await this.page.waitForLoadState('networkidle');
  }

  // 通用元素定位
  get header() {
    return this.page.getByRole('banner');
  }

  get footer() {
    return this.page.getByRole('contentinfo');
  }

  // 通用操作
  async clickNavLink(name: string) {
    await this.header.getByRole('link', { name }).click();
  }
}

Page Implementation

页面实现类

typescript
// e2e/pages/login.page.ts
import { Page, expect } from '@playwright/test';
import { BasePage } from './base.page';

export class LoginPage extends BasePage {
  readonly emailInput: Locator;
  readonly passwordInput: Locator;
  readonly submitButton: Locator;
  readonly errorMessage: Locator;

  constructor(page: Page) {
    super(page);
    this.emailInput = page.getByLabel('Email');
    this.passwordInput = page.getByLabel('Password');
    this.submitButton = page.getByRole('button', { name: 'Sign in' });
    this.errorMessage = page.getByRole('alert');
  }

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

  async login(email: string, password: string) {
    await this.emailInput.fill(email);
    await this.passwordInput.fill(password);
    await this.submitButton.click();
  }

  async expectError(message: string) {
    await expect(this.errorMessage).toContainText(message);
  }

  async expectLoggedIn() {
    await expect(this.page).toHaveURL(/.*dashboard/);
  }
}
typescript
// e2e/pages/dashboard.page.ts
import { Page, Locator, expect } from '@playwright/test';
import { BasePage } from './base.page';

export class DashboardPage extends BasePage {
  readonly welcomeHeading: Locator;
  readonly userMenu: Locator;
  readonly logoutButton: Locator;

  constructor(page: Page) {
    super(page);
    this.welcomeHeading = page.getByRole('heading', { name: /welcome/i });
    this.userMenu = page.getByTestId('user-menu');
    this.logoutButton = page.getByRole('button', { name: 'Logout' });
  }

  async goto() {
    await this.navigate('/dashboard');
  }

  async logout() {
    await this.userMenu.click();
    await this.logoutButton.click();
  }

  async expectWelcome(name: string) {
    await expect(this.welcomeHeading).toContainText(name);
  }
}
typescript
// e2e/pages/login.page.ts
import { Page, expect } from '@playwright/test';
import { BasePage } from './base.page';

export class LoginPage extends BasePage {
  readonly emailInput: Locator;
  readonly passwordInput: Locator;
  readonly submitButton: Locator;
  readonly errorMessage: Locator;

  constructor(page: Page) {
    super(page);
    this.emailInput = page.getByLabel('邮箱');
    this.passwordInput = page.getByLabel('密码');
    this.submitButton = page.getByRole('button', { name: '登录' });
    this.errorMessage = page.getByRole('alert');
  }

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

  async login(email: string, password: string) {
    await this.emailInput.fill(email);
    await this.passwordInput.fill(password);
    await this.submitButton.click();
  }

  async expectError(message: string) {
    await expect(this.errorMessage).toContainText(message);
  }

  async expectLoggedIn() {
    await expect(this.page).toHaveURL(/.*dashboard/);
  }
}
typescript
// e2e/pages/dashboard.page.ts
import { Page, Locator, expect } from '@playwright/test';
import { BasePage } from './base.page';

export class DashboardPage extends BasePage {
  readonly welcomeHeading: Locator;
  readonly userMenu: Locator;
  readonly logoutButton: Locator;

  constructor(page: Page) {
    super(page);
    this.welcomeHeading = page.getByRole('heading', { name: /welcome/i });
    this.userMenu = page.getByTestId('user-menu');
    this.logoutButton = page.getByRole('button', { name: '退出登录' });
  }

  async goto() {
    await this.navigate('/dashboard');
  }

  async logout() {
    await this.userMenu.click();
    await this.logoutButton.click();
  }

  async expectWelcome(name: string) {
    await expect(this.welcomeHeading).toContainText(name);
  }
}

Export All Pages

导出所有页面对象

typescript
// e2e/pages/index.ts
export { BasePage } from './base.page';
export { LoginPage } from './login.page';
export { DashboardPage } from './dashboard.page';

typescript
// e2e/pages/index.ts
export { BasePage } from './base.page';
export { LoginPage } from './login.page';
export { DashboardPage } from './dashboard.page';

Authentication

认证处理

Global Auth Setup

全局认证前置脚本

typescript
// e2e/auth.setup.ts
import { test as setup, expect } from '@playwright/test';
import path from 'path';

const authFile = path.join(__dirname, '../.auth/user.json');

setup('authenticate', async ({ page }) => {
  // Go to login page
  await page.goto('/login');

  // Login with test credentials
  await page.getByLabel('Email').fill(process.env.TEST_USER_EMAIL!);
  await page.getByLabel('Password').fill(process.env.TEST_USER_PASSWORD!);
  await page.getByRole('button', { name: 'Sign in' }).click();

  // Wait for auth to complete
  await expect(page).toHaveURL(/.*dashboard/);

  // Save auth state for reuse
  await page.context().storageState({ path: authFile });
});
typescript
// e2e/auth.setup.ts
import { test as setup, expect } from '@playwright/test';
import path from 'path';

const authFile = path.join(__dirname, '../.auth/user.json');

setup('完成认证', async ({ page }) => {
  // 跳转到登录页
  await page.goto('/login');

  // 使用测试账号登录
  await page.getByLabel('邮箱').fill(process.env.TEST_USER_EMAIL!);
  await page.getByLabel('密码').fill(process.env.TEST_USER_PASSWORD!);
  await page.getByRole('button', { name: '登录' }).click();

  // 等待认证完成
  await expect(page).toHaveURL(/.*dashboard/);

  // 保存认证状态供后续测试复用
  await page.context().storageState({ path: authFile });
});

Using Auth in Tests

在测试中复用认证状态

typescript
// playwright.config.ts
export default defineConfig({
  projects: [
    { name: 'setup', testMatch: /.*\.setup\.ts/ },
    {
      name: 'chromium',
      use: {
        ...devices['Desktop Chrome'],
        storageState: '.auth/user.json',
      },
      dependencies: ['setup'],
    },
  ],
});
typescript
// playwright.config.ts
export default defineConfig({
  projects: [
    { name: 'setup', testMatch: /.*\.setup\.ts/ },
    {
      name: 'chromium',
      use: {
        ...devices['Desktop Chrome'],
        storageState: '.auth/user.json',
      },
      dependencies: ['setup'],
    },
  ],
});

Tests Without Auth

无需认证的测试

typescript
// e2e/tests/public.spec.ts
import { test } from '@playwright/test';

// Override to skip auth
test.use({ storageState: { cookies: [], origins: [] } });

test('homepage loads for anonymous users', async ({ page }) => {
  await page.goto('/');
  await expect(page.getByRole('heading', { name: 'Welcome' })).toBeVisible();
});

typescript
// e2e/tests/public.spec.ts
import { test } from '@playwright/test';

// 覆盖配置,跳过认证状态
test.use({ storageState: { cookies: [], origins: [] } });

test('匿名用户可正常访问首页', async ({ page }) => {
  await page.goto('/');
  await expect(page.getByRole('heading', { name: '欢迎' })).toBeVisible();
});

Writing Tests

编写测试用例

Basic Test Structure

基础测试结构

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

test.describe('Authentication', () => {
  test.beforeEach(async ({ page }) => {
    // Skip stored auth for login tests
    await page.context().clearCookies();
  });

  test('successful login redirects to dashboard', async ({ page }) => {
    const loginPage = new LoginPage(page);

    await loginPage.goto();
    await loginPage.login('user@example.com', 'password123');
    await loginPage.expectLoggedIn();
  });

  test('invalid credentials show error', async ({ page }) => {
    const loginPage = new LoginPage(page);

    await loginPage.goto();
    await loginPage.login('wrong@example.com', 'wrongpass');
    await loginPage.expectError('Invalid email or password');
  });

  test('empty form shows validation errors', async ({ page }) => {
    const loginPage = new LoginPage(page);

    await loginPage.goto();
    await loginPage.submitButton.click();

    await expect(page.getByText('Email is required')).toBeVisible();
    await expect(page.getByText('Password is required')).toBeVisible();
  });
});
typescript
// e2e/tests/auth.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages';

test.describe('认证功能', () => {
  test.beforeEach(async ({ page }) => {
    // 登录测试需清除已保存的认证状态
    await page.context().clearCookies();
  });

  test('登录成功后跳转到控制台', async ({ page }) => {
    const loginPage = new LoginPage(page);

    await loginPage.goto();
    await loginPage.login('user@example.com', 'password123');
    await loginPage.expectLoggedIn();
  });

  test('无效凭证会显示错误提示', async ({ page }) => {
    const loginPage = new LoginPage(page);

    await loginPage.goto();
    await loginPage.login('wrong@example.com', 'wrongpass');
    await loginPage.expectError('邮箱或密码无效');
  });

  test('空表单提交会显示验证错误', async ({ page }) => {
    const loginPage = new LoginPage(page);

    await loginPage.goto();
    await loginPage.submitButton.click();

    await expect(page.getByText('邮箱为必填项')).toBeVisible();
    await expect(page.getByText('密码为必填项')).toBeVisible();
  });
});

User Flow Tests

用户流程测试

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

test.describe('Checkout Flow', () => {
  test('complete purchase flow', async ({ page }) => {
    // 1. Browse products
    await page.goto('/products');
    await page.getByTestId('product-card')
      .filter({ hasText: 'Pro Plan' })
      .getByRole('button', { name: 'Add to cart' })
      .click();

    // 2. View cart
    await page.getByRole('link', { name: 'Cart' }).click();
    await expect(page.getByText('Pro Plan')).toBeVisible();
    await expect(page.getByTestId('cart-total')).toContainText('$29.99');

    // 3. Checkout
    await page.getByRole('button', { name: 'Checkout' }).click();

    // 4. Fill payment (use Stripe test card)
    const stripeFrame = page.frameLocator('iframe[name*="stripe"]');
    await stripeFrame.getByPlaceholder('Card number').fill('4242424242424242');
    await stripeFrame.getByPlaceholder('MM / YY').fill('12/30');
    await stripeFrame.getByPlaceholder('CVC').fill('123');

    // 5. Complete purchase
    await page.getByRole('button', { name: 'Pay now' }).click();

    // 6. Verify success
    await expect(page).toHaveURL(/.*success/);
    await expect(page.getByRole('heading', { name: 'Thank you' })).toBeVisible();
  });
});

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

test.describe('结账流程', () => {
  test('完成完整购买流程', async ({ page }) => {
    // 1. 浏览商品
    await page.goto('/products');
    await page.getByTestId('product-card')
      .filter({ hasText: '专业版套餐' })
      .getByRole('button', { name: '加入购物车' })
      .click();

    // 2. 查看购物车
    await page.getByRole('link', { name: '购物车' }).click();
    await expect(page.getByText('专业版套餐')).toBeVisible();
    await expect(page.getByTestId('cart-total')).toContainText('$29.99');

    // 3. 进入结账页
    await page.getByRole('button', { name: '结账' }).click();

    // 4. 填写支付信息(使用Stripe测试卡号)
    const stripeFrame = page.frameLocator('iframe[name*="stripe"]');
    await stripeFrame.getByPlaceholder('卡号').fill('4242424242424242');
    await stripeFrame.getByPlaceholder('MM / YY').fill('12/30');
    await stripeFrame.getByPlaceholder('CVC').fill('123');

    // 5. 完成支付
    await page.getByRole('button', { name: '立即支付' }).click();

    // 6. 验证支付成功
    await expect(page).toHaveURL(/.*success/);
    await expect(page.getByRole('heading', { name: '感谢您的购买' })).toBeVisible();
  });
});

Assertions

断言

Web-First Assertions (Auto-Wait)

优先使用Web原生断言(自动等待)

typescript
// ✅ These wait and retry automatically
await expect(page.getByRole('button')).toBeVisible();
await expect(page.getByRole('button')).toBeEnabled();
await expect(page.getByRole('button')).toHaveText('Submit');
await expect(page).toHaveURL('/dashboard');
await expect(page).toHaveTitle(/Dashboard/);

// ❌ Avoid manual waits
await page.waitForTimeout(3000);  // NEVER do this
typescript
// ✅ 这些断言会自动等待并重试
await expect(page.getByRole('button')).toBeVisible();
await expect(page.getByRole('button')).toBeEnabled();
await expect(page.getByRole('button')).toHaveText('提交');
await expect(page).toHaveURL('/dashboard');
await expect(page).toHaveTitle(/控制台/);

// ❌ 避免手动等待
await page.waitForTimeout(3000);  // 绝对不要这样做

Soft Assertions

软断言

typescript
// Continue test even if assertion fails
await expect.soft(page.getByTestId('price')).toHaveText('$29.99');
await expect.soft(page.getByTestId('stock')).toHaveText('In Stock');

// Fail at end if any soft assertions failed
typescript
// 即使断言失败,测试仍会继续执行
await expect.soft(page.getByTestId('price')).toHaveText('$29.99');
await expect.soft(page.getByTestId('stock')).toHaveText('有货');

// 所有断言执行完成后,若存在失败则标记测试失败

Common Assertions

常用断言

typescript
// Visibility
await expect(locator).toBeVisible();
await expect(locator).toBeHidden();
await expect(locator).toBeAttached();

// Text content
await expect(locator).toHaveText('exact text');
await expect(locator).toContainText('partial');
await expect(locator).toHaveValue('input value');

// State
await expect(locator).toBeEnabled();
await expect(locator).toBeDisabled();
await expect(locator).toBeChecked();
await expect(locator).toBeFocused();

// Count
await expect(locator).toHaveCount(5);

// Page
await expect(page).toHaveURL('/dashboard');
await expect(page).toHaveTitle('Dashboard | App');
await expect(page).toHaveScreenshot('dashboard.png');

typescript
// 可见性
await expect(locator).toBeVisible();
await expect(locator).toBeHidden();
await expect(locator).toBeAttached();

// 文本内容
await expect(locator).toHaveText('精确文本');
await expect(locator).toContainText('部分文本');
await expect(locator).toHaveValue('输入框值');

// 元素状态
await expect(locator).toBeEnabled();
await expect(locator).toBeDisabled();
await expect(locator).toBeChecked();
await expect(locator).toBeFocused();

// 数量
await expect(locator).toHaveCount(5);

// 页面状态
await expect(page).toHaveURL('/dashboard');
await expect(page).toHaveTitle('控制台 | 应用');
await expect(page).toHaveScreenshot('dashboard.png');

Mocking & Network

模拟与网络控制

Mock API Responses

模拟API响应

typescript
test('shows error when API fails', async ({ page }) => {
  // Mock API to return error
  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('displays user data from API', async ({ page }) => {
  // Mock successful response
  await page.route('**/api/users', (route) => {
    route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify([
        { id: 1, name: 'John Doe', email: 'john@example.com' },
        { id: 2, name: 'Jane Doe', email: 'jane@example.com' },
      ]),
    });
  });

  await page.goto('/users');
  await expect(page.getByText('John Doe')).toBeVisible();
  await expect(page.getByText('Jane Doe')).toBeVisible();
});
typescript
test('API失败时显示错误提示', async ({ page }) => {
  // 模拟API返回错误
  await page.route('**/api/users', (route) => {
    route.fulfill({
      status: 500,
      body: JSON.stringify({ error: '服务器错误' }),
    });
  });

  await page.goto('/users');
  await expect(page.getByText('加载用户失败')).toBeVisible();
});

test('显示API返回的用户数据', async ({ page }) => {
  // 模拟API返回成功响应
  await page.route('**/api/users', (route) => {
    route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify([
        { id: 1, name: 'John Doe', email: 'john@example.com' },
        { id: 2, name: 'Jane Doe', email: 'jane@example.com' },
      ]),
    });
  });

  await page.goto('/users');
  await expect(page.getByText('John Doe')).toBeVisible();
  await expect(page.getByText('Jane Doe')).toBeVisible();
});

Wait for API Calls

等待API调用完成

typescript
test('submits form and shows success', async ({ page }) => {
  await page.goto('/contact');

  // Fill form
  await page.getByLabel('Name').fill('John');
  await page.getByLabel('Email').fill('john@example.com');
  await page.getByLabel('Message').fill('Hello!');

  // Wait for API call on submit
  const responsePromise = page.waitForResponse('**/api/contact');
  await page.getByRole('button', { name: 'Send' }).click();

  const response = await responsePromise;
  expect(response.status()).toBe(200);

  await expect(page.getByText('Message sent!')).toBeVisible();
});

typescript
test('提交表单后显示成功提示', async ({ page }) => {
  await page.goto('/contact');

  // 填写表单
  await page.getByLabel('姓名').fill('John');
  await page.getByLabel('邮箱').fill('john@example.com');
  await page.getByLabel('留言').fill('你好!');

  // 监听API调用
  const responsePromise = page.waitForResponse('**/api/contact');
  await page.getByRole('button', { name: '发送' }).click();

  const response = await responsePromise;
  expect(response.status()).toBe(200);

  await expect(page.getByText('留言已发送!')).toBeVisible();
});

Visual Testing

可视化测试

typescript
// Full page screenshot
await expect(page).toHaveScreenshot('homepage.png');

// Element screenshot
await expect(page.getByTestId('chart')).toHaveScreenshot('chart.png');

// With options
await expect(page).toHaveScreenshot('dashboard.png', {
  maxDiffPixels: 100,
  mask: [page.getByTestId('timestamp')], // Ignore dynamic content
});

typescript
// 整页截图对比
await expect(page).toHaveScreenshot('homepage.png');

// 元素截图对比
await expect(page.getByTestId('chart')).toHaveScreenshot('chart.png');

// 自定义选项
await expect(page).toHaveScreenshot('dashboard.png', {
  maxDiffPixels: 100,
  mask: [page.getByTestId('timestamp')], // 忽略动态内容
});

CI/CD Integration

CI/CD集成

GitHub Actions

GitHub Actions配置

yaml
undefined
yaml
undefined

.github/workflows/e2e.yml

.github/workflows/e2e.yml

name: E2E Tests
on: push: branches: [main] pull_request: branches: [main]
jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4
  - uses: actions/setup-node@v4
    with:
      node-version: 20
      cache: 'npm'

  - name: Install dependencies
    run: npm ci

  - name: Install Playwright browsers
    run: npx playwright install --with-deps chromium

  - name: Run E2E tests
    run: npx playwright test --project=chromium
    env:
      BASE_URL: ${{ secrets.STAGING_URL }}
      TEST_USER_EMAIL: ${{ secrets.TEST_USER_EMAIL }}
      TEST_USER_PASSWORD: ${{ secrets.TEST_USER_PASSWORD }}

  - uses: actions/upload-artifact@v4
    if: failure()
    with:
      name: playwright-report
      path: playwright-report/
      retention-days: 7
undefined
name: 端到端测试
on: push: branches: [main] pull_request: branches: [main]
jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4
  - uses: actions/setup-node@v4
    with:
      node-version: 20
      cache: 'npm'

  - name: 安装依赖
    run: npm ci

  - name: 安装Playwright浏览器
    run: npx playwright install --with-deps chromium

  - name: 运行端到端测试
    run: npx playwright test --project=chromium
    env:
      BASE_URL: ${{ secrets.STAGING_URL }}
      TEST_USER_EMAIL: ${{ secrets.TEST_USER_EMAIL }}
      TEST_USER_PASSWORD: ${{ secrets.TEST_USER_PASSWORD }}

  - uses: actions/upload-artifact@v4
    if: failure()
    with:
      name: playwright-report
      path: playwright-report/
      retention-days: 7
undefined

Run Specific Tests

运行指定测试

bash
undefined
bash
undefined

Run all tests

运行所有测试

npx playwright test
npx playwright test

Run specific file

运行指定测试文件

npx playwright test e2e/tests/auth.spec.ts
npx playwright test e2e/tests/auth.spec.ts

Run tests with tag

运行带有指定标签的测试

npx playwright test --grep @critical
npx playwright test --grep @critical

Run in headed mode (debug)

以有界面模式运行(调试用)

npx playwright test --headed
npx playwright test --headed

Run specific browser

指定浏览器运行

npx playwright test --project=chromium
npx playwright test --project=chromium

Debug mode

调试模式

npx playwright test --debug
npx playwright test --debug

Show HTML report

查看HTML测试报告

npx playwright show-report

---
npx playwright show-report

---

Test Data

测试数据

Factories

数据工厂

typescript
// e2e/utils/test-data.ts
import { faker } from '@faker-js/faker';

export const createUser = (overrides = {}) => ({
  email: faker.internet.email(),
  password: faker.internet.password({ length: 12 }),
  name: faker.person.fullName(),
  ...overrides,
});

export const createProduct = (overrides = {}) => ({
  name: faker.commerce.productName(),
  price: faker.commerce.price({ min: 10, max: 100 }),
  description: faker.commerce.productDescription(),
  ...overrides,
});
typescript
// e2e/utils/test-data.ts
import { faker } from '@faker-js/faker';

export const createUser = (overrides = {}) => ({
  email: faker.internet.email(),
  password: faker.internet.password({ length: 12 }),
  name: faker.person.fullName(),
  ...overrides,
});

export const createProduct = (overrides = {}) => ({
  name: faker.commerce.productName(),
  price: faker.commerce.price({ min: 10, max: 100 }),
  description: faker.commerce.productDescription(),
  ...overrides,
});

Environment Variables

环境变量

bash
undefined
bash
undefined

.env.test

.env.test

BASE_URL=http://localhost:3000 TEST_USER_EMAIL=test@example.com TEST_USER_PASSWORD=testpassword123

---
BASE_URL=http://localhost:3000 TEST_USER_EMAIL=test@example.com TEST_USER_PASSWORD=testpassword123

---

Debugging

调试

Trace Viewer

追踪查看器

typescript
// Enable in config for failures
use: {
  trace: 'on-first-retry',
}

// View traces
npx playwright show-trace trace.zip
typescript
// 在配置中开启失败重试时的追踪记录
use: {
  trace: 'on-first-retry',
}

// 查看追踪记录
npx playwright show-trace trace.zip

Debug Mode

调试模式

bash
undefined
bash
undefined

Step through test

逐步执行测试

npx playwright test --debug
npx playwright test --debug

Pause at specific point

在测试代码中添加暂停

await page.pause(); // In test code
undefined
await page.pause(); // 测试运行到此处会暂停
undefined

VS Code Extension

VS Code扩展

Install "Playwright Test for VS Code" for:
  • Run tests from editor
  • Debug with breakpoints
  • Pick locators visually
  • Watch mode

安装“Playwright Test for VS Code”扩展,可实现:
  • 在编辑器中直接运行测试
  • 断点调试
  • 可视化选择定位器
  • 监听模式

Dead Link Detection (REQUIRED)

反模式

Every project MUST include dead link detection tests. Run these on every deployment.
  • 硬编码等待 - 应使用自动等待的断言替代
  • CSS/XPath选择器 - 应使用角色/文本/测试ID定位器
  • 测试第三方网站 - 应模拟外部依赖
  • 测试间共享状态 - 每个测试必须独立隔离
  • 遗漏await - 启用ESLint规则
    no-floating-promises
  • 基于时间的不稳定测试 - 模拟日期/时间
  • 测试实现细节 - 应测试用户可见的行为
  • 超大测试文件 - 按功能/页面拆分测试

Link Validator Test

快速参考

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

const PAGES_TO_CHECK = ['/', '/about', '/pricing', '/blog', '/contact'];

test.describe('Dead Link Detection', () => {
  for (const pagePath of PAGES_TO_CHECK) {
    test(`no dead links on ${pagePath}`, async ({ page, request }) => {
      await page.goto(pagePath);

      // Get all links on the page
      const links = await page.locator('a[href]').all();
      const hrefs = await Promise.all(
        links.map(link => link.getAttribute('href'))
      );

      // Filter to internal and absolute external links
      const uniqueLinks = [...new Set(hrefs.filter(Boolean))] as string[];

      for (const href of uniqueLinks) {
        // Skip mailto, tel, and anchor links
        if (href.startsWith('mailto:') || href.startsWith('tel:') || href.startsWith('#')) {
          continue;
        }

        // Build full URL
        const url = href.startsWith('http') ? href : new URL(href, page.url()).href;

        // Check link status
        const response = await request.get(url, {
          timeout: 10000,
          ignoreHTTPSErrors: true,
        });

        expect(
          response.ok(),
          `Dead link found on ${pagePath}: ${href} returned ${response.status()}`
        ).toBeTruthy();
      }
    });
  }
});
bash
undefined

Comprehensive Link Crawler

初始化

typescript
// e2e/tests/site-links.spec.ts
import { test, expect, Page, APIRequestContext } from '@playwright/test';

interface LinkResult {
  url: string;
  status: number;
  foundOn: string;
}

async function checkAllLinks(
  page: Page,
  request: APIRequestContext,
  startUrl: string
): Promise<LinkResult[]> {
  const visited = new Set<string>();
  const results: LinkResult[] = [];
  const toVisit = [startUrl];
  const baseUrl = new URL(startUrl).origin;

  while (toVisit.length > 0) {
    const currentUrl = toVisit.pop()!;
    if (visited.has(currentUrl)) continue;
    visited.add(currentUrl);

    try {
      await page.goto(currentUrl);
      const links = await page.locator('a[href]').all();

      for (const link of links) {
        const href = await link.getAttribute('href');
        if (!href || href.startsWith('#') || href.startsWith('mailto:') || href.startsWith('tel:')) {
          continue;
        }

        const fullUrl = href.startsWith('http') ? href : new URL(href, currentUrl).href;

        // Check link
        const response = await request.get(fullUrl, {
          timeout: 10000,
          ignoreHTTPSErrors: true,
        });

        results.push({
          url: fullUrl,
          status: response.status(),
          foundOn: currentUrl,
        });

        // Add internal links to queue
        if (fullUrl.startsWith(baseUrl) && !visited.has(fullUrl)) {
          toVisit.push(fullUrl);
        }
      }
    } catch (error) {
      results.push({
        url: currentUrl,
        status: 0,
        foundOn: 'navigation',
      });
    }
  }

  return results;
}

test('no dead links on entire site', async ({ page, request, baseURL }) => {
  const results = await checkAllLinks(page, request, baseURL!);
  const deadLinks = results.filter(r => r.status >= 400 || r.status === 0);

  if (deadLinks.length > 0) {
    console.error('Dead links found:');
    deadLinks.forEach(link => {
      console.error(`  ${link.url} (${link.status}) - found on ${link.foundOn}`);
    });
  }

  expect(deadLinks, `Found ${deadLinks.length} dead links`).toHaveLength(0);
});
npm init playwright@latest

Image Link Validation

运行测试

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

test('no broken images on homepage', async ({ page, request }) => {
  await page.goto('/');

  const images = await page.locator('img[src]').all();

  for (const img of images) {
    const src = await img.getAttribute('src');
    if (!src) continue;

    const url = src.startsWith('http') ? src : new URL(src, page.url()).href;

    // Skip data URLs
    if (url.startsWith('data:')) continue;

    const response = await request.get(url);
    expect(
      response.ok(),
      `Broken image: ${src}`
    ).toBeTruthy();

    // Verify it's actually an image
    const contentType = response.headers()['content-type'];
    expect(
      contentType?.startsWith('image/'),
      `${src} is not an image (${contentType})`
    ).toBeTruthy();
  }
});
npx playwright test npx playwright test --headed npx playwright test --project=chromium npx playwright test --grep @smoke

CI Integration for Link Checking

调试

yaml
undefined
npx playwright test --debug npx playwright show-report npx playwright show-trace trace.zip

.github/workflows/link-check.yml

生成测试代码

name: Link Check
on: schedule: - cron: '0 6 * * 1' # Weekly on Monday push: branches: [main]
jobs: link-check: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 - run: npm ci - run: npx playwright install chromium - run: npx playwright test e2e/tests/links.spec.ts --project=chromium env: BASE_URL: ${{ secrets.PRODUCTION_URL }}

---
npx playwright codegen localhost:3000
undefined

Anti-Patterns

package.json脚本配置

  • Hardcoded waits - Use auto-waiting assertions instead
  • CSS/XPath selectors - Use role/text/testid locators
  • Testing third-party sites - Mock external dependencies
  • Shared state between tests - Each test must be isolated
  • Missing awaits - Use ESLint rule
    no-floating-promises
  • Flaky time-based tests - Mock dates/times
  • Testing implementation details - Test user-visible behavior
  • Huge test files - Split by feature/page

json
{
  "scripts": {
    "test:e2e": "playwright test",
    "test:e2e:headed": "playwright test --headed",
    "test:e2e:debug": "playwright test --debug",
    "test:e2e:report": "playwright show-report",
    "test:e2e:codegen": "playwright codegen"
  }
}

Quick Reference

bash
undefined

Install

npm init playwright@latest

Run tests

npx playwright test npx playwright test --headed npx playwright test --project=chromium npx playwright test --grep @smoke

Debug

npx playwright test --debug npx playwright show-report npx playwright show-trace trace.zip

Generate tests

npx playwright codegen localhost:3000
undefined

Package.json Scripts

json
{
  "scripts": {
    "test:e2e": "playwright test",
    "test:e2e:headed": "playwright test --headed",
    "test:e2e:debug": "playwright test --debug",
    "test:e2e:report": "playwright show-report",
    "test:e2e:codegen": "playwright codegen"
  }
}