Playwright 第12章:断言与自动等待

断言是测试验证的核心。Playwright 的断言库内置了强大的自动等待机制,使测试更加稳定可靠。本章系统介绍 Playwright 的各种断言方法、自动等待原理以及超时配置策略。

自动等待机制

自动等待原理

Playwright 的断言方法内置了自动等待和重试机制。当断言条件不满足时,Playwright 会持续重试直到超时:

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

// 这个断言会持续重试直到元素可见或超时
await expect(page.locator('.dynamic-content')).toBeVisible();

// 相当于手动实现(但更简洁)
// await page.waitForSelector('.dynamic-content', { state: 'visible' });

自动等待的优势:

特性说明
自动重试默认每 100ms 检查一次,直到条件满足
智能等待自动等待元素附加、可见、稳定、启用
减少 flakiness消除手动 sleep 导致的测试不稳定
清晰错误超时时提供详细的错误信息

断言超时配置

// 全局配置(playwright.config.ts)
import { defineConfig } from '@playwright/test';
export default defineConfig({
  expect: {
    timeout: 10000, // 默认 5000ms
  },
});

// 单条断言超时
await expect(page.locator('.slow-element')).toBeVisible({
  timeout: 15000,
});

// 测试级别超时
test('慢速测试', { timeout: 60000 }, async ({ page }) => {
  await expect(page.locator('.content')).toBeVisible({ timeout: 30000 });
});

常见断言方法

可见性与状态断言

const locator = page.locator('.submit-btn');

// 元素可见
await expect(locator).toBeVisible();

// 元素隐藏(不存在于 DOM 或不可见)
await expect(locator).toBeHidden();

// 元素已附加到 DOM
await expect(locator).toBeAttached();

// 元素已从 DOM 移除
await expect(locator).not.toBeAttached();

// 元素可用(非 disabled)
await expect(locator).toBeEnabled();

// 元素禁用
await expect(locator).toBeDisabled();

// 元素可编辑
await expect(locator).toBeEditable();

// 元素为空
await expect(locator).toBeEmpty();

// 复选框已选中
await expect(locator).toBeChecked();

// 复选框未选中
await expect(locator).not.toBeChecked();

// 元素聚焦
await expect(locator).toBeFocused();

// 元素在视口中可见
await expect(locator).toBeInViewport();

文本内容断言

const locator = page.locator('.welcome-message');

// 精确匹配文本
await expect(locator).toHaveText('欢迎回来,管理员');

// 包含文本
await expect(locator).toContainText('欢迎');

// 正则匹配
await expect(locator).toHaveText(/欢迎.*管理员/);
await expect(locator).toContainText(/欢迎|Hello/);

// 忽略大小写
await expect(locator).toHaveText('欢迎回来', { ignoreCase: true });

// 匹配多个元素的文本
const items = page.locator('.list-item');
await expect(items).toHaveText(['第一项', '第二项', '第三项']);

// 使用正则匹配多个
await expect(items).toHaveText([/第一/, /第二/, /第三/]);

属性与值断言

const locator = page.locator('#username');

// 验证属性
await expect(locator).toHaveAttribute('type', 'text');
await expect(locator).toHaveAttribute('placeholder', /请输入/);

// 验证多个属性
await expect(locator).toHaveAttribute({
  type: 'text',
  name: 'username',
  required: '',
});

// 验证输入值
await expect(locator).toHaveValue('admin');

// 验证部分值
await expect(locator).toHaveValue(/admin|test/);

// 验证 CSS 属性
await expect(locator).toHaveCSS('color', 'rgb(255, 0, 0)');
await expect(locator).toHaveCSS('display', 'none');

// 验证类名
await expect(locator).toHaveClass(/active|highlight/);

数量与存在断言

const items = page.locator('.product-card');

// 精确数量
await expect(items).toHaveCount(24);

// 数量范围(使用 not)
await expect(items).not.toHaveCount(0);

// 验证列表为空
await expect(items).toHaveCount(0);

// 验证元素存在
await expect(items.first()).toBeAttached();

// 验证特定索引的元素
await expect(items.nth(2)).toContainText('特价商品');

URL 和页面断言

// 页面 URL
await expect(page).toHaveURL('/dashboard');
await expect(page).toHaveURL(/dashboard|home/);
await expect(page).toHaveURL('**/dashboard');

// 页面标题
await expect(page).toHaveTitle('管理后台');
await expect(page).toHaveTitle(/后台|管理/);

// 页面截图对比
await expect(page).toHaveScreenshot('homepage.png', {
  fullPage: true,
  maxDiffPixelRatio: 0.01,
  threshold: 0.2,
});

软断言

使用软断言

软断言(Soft Assert)允许测试在某个断言失败后继续执行,而不是立即终止:

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

test('软断言示例', async ({ page }) => {
  await page.goto('/profile');

  // 使用软断言(失败不终止测试)
  await expect.soft(page.locator('.avatar')).toBeVisible();
  await expect.soft(page.locator('.username')).toHaveText('admin');
  await expect.soft(page.locator('.email')).toHaveValue('[email protected]');

  // 所有软断言执行完毕后检查是否有失败
  // 如果有失败,测试会在最后终止
});

软断言的应用场景

test('表单验证 - 多字段检查', async ({ page }) => {
  await page.goto('/register');
  await page.click('.submit-btn');

  // 检查所有验证错误消息
  await expect.soft(page.locator('.error-name')).toBeVisible();
  await expect.soft(page.locator('.error-email')).toBeVisible();
  await expect.soft(page.locator('.error-password')).toBeVisible();
  await expect.soft(page.locator('.error-terms')).toBeVisible();

  // 验证错误数量
  await expect(page.locator('.error-message')).toHaveCount(4);
  // 如果这里执行到,说明所有软断言都通过了
});

test('批量数据验证', async ({ page }) => {
  await page.goto('/data-table');

  const rows = page.locator('table tr');
  const rowCount = await rows.count();

  // 验证每一行数据
  for (let i = 1; i < rowCount; i++) {
    const row = rows.nth(i);
    await expect.soft(row.locator('.id')).toMatch(/^\d+$/);
    await expect.soft(row.locator('.name')).not.toBeEmpty();
    await expect.soft(row.locator('.status')).toContainText(/active|inactive/);
  }
});

否定断言

const locator = page.locator('.error-message');

// 元素不可见
await expect(locator).not.toBeVisible();

// 元素不包含文本
await expect(locator).not.toContainText('错误');

// 元素没有特定属性
await expect(locator).not.toHaveAttribute('disabled', '');

// 数量不匹配
await expect(page.locator('.item')).not.toHaveCount(0);

// URL 不包含
await expect(page).not.toHaveURL('/error');

// 组合使用
await expect(locator).not.toBeVisible({ timeout: 2000 });

自定义等待条件

使用 expect.poll()

test('自定义轮询断言', async ({ page }) => {
  await page.goto('/dashboard');

  // 轮询直到条件满足
  await expect.poll(async () => {
    const count = await page.locator('.notification').count();
    return count;
  }, {
    timeout: 10000,
    message: '等待通知出现',
    intervals: [1000, 2000, 3000],
  }).toBeGreaterThan(0);

  // 轮询 API 响应
  await expect.poll(async () => {
    const response = await page.request.get('/api/status');
    const data = await response.json();
    return data.status;
  }, {
    timeout: 30000,
  }).toBe('completed');
});

使用 expect.toPass()

test('复杂条件断言', async ({ page }) => {
  await page.goto('/analytics');

  // 反复执行直到通过或超时
  await expect(async () => {
    await page.click('.refresh-btn');
    await page.waitForTimeout(1000);

    const chartData = await page.evaluate(() => {
      return window.__chartData__;
    });

    expect(chartData).toBeDefined();
    expect(chartData.points.length).toBeGreaterThan(0);
    expect(chartData.latestValue).toBeGreaterThan(100);
  }).toPass({
    timeout: 30000,
    intervals: [2000, 3000, 5000],
  });
});

断言最佳实践

优先使用定位器断言

// 推荐:使用定位器断言(自动等待)
await expect(page.locator('.success')).toBeVisible();

// 避免:手动等待后判断
await page.waitForTimeout(2000);
const isVisible = await page.locator('.success').isVisible();
expect(isVisible).toBe(true);

合理设置超时

// 快速操作使用短超时
await expect(page.locator('.fast-element')).toBeVisible({ timeout: 2000 });

// 慢速操作使用长超时
await expect(page.locator('.lazy-content')).toBeVisible({ timeout: 30000 });

// 全局超时设置
// playwright.config.ts
expect: {
  timeout: 10000,
},

错误信息优化

// 添加自定义错误消息
await expect(page.locator('.welcome'), '登录后应显示欢迎信息').toBeVisible();

// 在断言前截图以便调试
test('调试失败的断言', async ({ page }) => {
  await page.goto('/login');
  await page.fill('#username', 'admin');
  await page.fill('#password', 'wrong');
  await page.click('button[type="submit"]');

  try {
    await expect(page.locator('.welcome')).toBeVisible({ timeout: 3000 });
  } catch (error) {
    await page.screenshot({ path: 'debug-screenshot.png', fullPage: true });
    throw error;
  }
});

总结

Playwright 的断言系统最强大的特性是自动等待机制,它消除了传统测试中大量令人头疼的手动等待和重试逻辑。建议优先使用 expect(locator).toBeVisible() 等带有自动等待的断言方法,而非 isVisible() 等被动检查。软断言适用于需要收集多个验证结果的场景。合理配置断言超时时间,在稳定性和执行效率之间找到平衡点。