playwright-testing

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Testing With Playwright

基于Playwright的测试

Overview

概述

Playwright tests fail in three predictable ways: incomplete coverage (shipping without edge cases), brittle waiting (fixed timeouts that flake), and unclear mocking (defaulting to all-mocks or all-staging). This skill provides patterns for writing reliable tests under pressure.
Core principle: Complete coverage before shipping. Deterministic waits always. Strategic mocking based on test intent.
Playwright测试通常会在三个方面出现可预见的问题:测试覆盖率不足(未覆盖边缘场景就发布)、等待逻辑脆弱(固定超时时间导致测试不稳定)、模拟策略不清晰(要么全用模拟数据要么全用预发布环境)。本文提供了在压力下编写可靠测试的模式。
核心原则:发布前完成完整测试覆盖;始终使用确定性等待;根据测试意图制定策略性模拟方案。

When to Use

适用场景

Symptoms that signal you need this skill:
  • Writing e2e tests with deadline pressure to deploy
  • Tests using
    waitForSelector()
    or
    waitForTimeout()
    with fixed values
  • Uncertainty about whether to mock APIs or test against staging
  • Tests that pass locally but flake in CI
  • Edge case coverage getting deferred to "next sprint"
出现以下情况时,你需要本文的方法:
  • 在交付压力下编写端到端(e2e)测试并准备部署
  • 测试中使用带固定值的
    waitForSelector()
    waitForTimeout()
  • 不确定应该模拟API还是针对预发布(staging)环境测试
  • 测试在本地通过但在CI中不稳定
  • 边缘场景测试被推迟到“下一个迭代”

Critical Pattern: Coverage Before Shipping

关键模式:发布前完成完整覆盖

The pressure: You have working tests. Deadline is 30 minutes. Edge case checks take 15 minutes. Ship now?
The answer: No. Ship only when:
  • Happy path tests pass ✓
  • Error cases tested (network failures, timeouts, missing data)
  • Retry logic verified
  • Boundary conditions handled
Why order matters: Incomplete test coverage creates hidden bugs. Deferring edge cases means deploying untested failure paths. Users hit them first.
No exceptions:
  • Not "we'll add them next sprint" (you won't)
  • Not "edge cases are unlikely" (they happen in production)
  • Not "happy path tests are good enough" (they're not)
压力场景: 你的测试已经能运行,交付截止时间还有30分钟,而边缘场景检查需要15分钟。现在就发布?
答案: 不。只有满足以下所有条件才能发布:
  • 主流程测试通过 ✓
  • 错误场景已测试(网络故障、超时、数据缺失)
  • 重试逻辑已验证
  • 边界条件已处理
顺序的重要性: 不完整的测试覆盖率会留下隐藏bug。推迟边缘场景测试意味着部署未经验证的故障路径,而用户会首先遇到这些问题。
无例外:
  • 不要说“我们下一个迭代再补”(你不会的)
  • 不要说“边缘场景不太可能发生”(它们在生产环境中一定会出现)
  • 不要说“主流程测试足够了”(远远不够)

Waiting Strategy: Condition-Based Not Time-Based

等待策略:基于条件而非时间

The problem: Fixed timeouts like
page.waitForSelector()
with 5-second default:
  • Slow on fast systems (unnecessary waits)
  • Flaky on slow systems (timeout too quick)
  • Hide actual problems (what were you waiting for?)
  • Tempt deferred refactoring ("I'll fix later")
The pattern:
typescript
// ❌ BAD: Fixed timeout, hides what you're waiting for
await page.waitForSelector('.loading', { timeout: 5000 });
await page.click('button');

// ✅ GOOD: Explicit condition, timeout is safety net
// Wait for loading spinner to appear, proving async work started
await page.locator('.loading').waitFor({ state: 'visible' });
// Wait for it to disappear, proving async work completed
await page.locator('.loading').waitFor({ state: 'hidden' });
await page.click('button');

// ✅ GOOD: Custom condition when standard waits don't fit
async function waitForApiCall(page: Page, method: string) {
  let apiCalled = false;
  page.on('response', (response) => {
    if (response.request().method() === method) {
      apiCalled = true;
    }
  });
  // Keep checking until API was called
  await page.waitForFunction(() => apiCalled);
}
Why this matters: Condition-based waits reveal what you're testing for. They're faster on fast systems, more reliable on slow systems, and catch timing issues immediately instead of at timeout.
Apply to:
  • DOM state changes (visible/hidden/attached)
  • API calls (network interception)
  • Data updates (text content changes)
  • Form readiness (buttons enabled/disabled)
Critical: Don't defer condition-based waits to "future refactoring." Tests with fixed timeouts will:
  • Pass locally (fast machine)
  • Flake in CI (under load)
  • Remain flaky indefinitely (later refactoring never happens)
Action: Condition-based waits take 2 extra lines. Write them now. Not "later." Not "as you touch the file." Not "when bugs appear." Now.
Timeout as safety net only:
typescript
// Reasonable defaults: 5s for navigation, 10s for complex async
await page.goto(url, { waitUntil: 'networkidle', timeout: 30000 });
await page.locator('[data-testid]').waitFor({ timeout: 10000 });
问题:
page.waitForSelector()
默认5秒这样的固定超时:
  • 在快速系统上造成不必要的等待
  • 在慢速系统上因超时时间过短导致测试不稳定
  • 隐藏实际问题(你到底在等什么?)
  • 让人倾向于推迟重构(“我以后再修”)
实现模式:
typescript
// ❌ 错误:固定超时,隐藏等待目标
await page.waitForSelector('.loading', { timeout: 5000 });
await page.click('button');

// ✅ 正确:明确条件,超时仅作为安全兜底
// 等待加载 spinner 出现,证明异步任务已启动
await page.locator('.loading').waitFor({ state: 'visible' });
// 等待加载 spinner 消失,证明异步任务已完成
await page.locator('.loading').waitFor({ state: 'hidden' });
await page.click('button');

// ✅ 正确:标准等待不适用时使用自定义条件
async function waitForApiCall(page: Page, method: string) {
  let apiCalled = false;
  page.on('response', (response) => {
    if (response.request().method() === method) {
      apiCalled = true;
    }
  });
  // 持续检查直到API被调用
  await page.waitForFunction(() => apiCalled);
}
重要性: 基于条件的等待能明确测试目标。在快速系统上更快,在慢速系统上更可靠,能立即捕捉时序问题而非等到超时。
适用场景:
  • DOM状态变化(显示/隐藏/挂载)
  • API调用(网络拦截)
  • 数据更新(文本内容变化)
  • 表单就绪状态(按钮启用/禁用)
关键提醒: 不要把基于条件的等待推迟到“未来重构”。使用固定超时的测试会:
  • 在本地通过(机器性能好)
  • 在CI中不稳定(负载高)
  • 永远不稳定(后续重构永远不会发生)
行动: 基于条件的等待只需多写2行代码。现在就写,不是“以后”,不是“修改文件时”,不是“出现bug时”,就是现在。
仅将超时作为安全兜底:
typescript
// 合理默认值:导航等待5秒,复杂异步操作等待10秒
await page.goto(url, { waitUntil: 'networkidle', timeout: 30000 });
await page.locator('[data-testid]').waitFor({ timeout: 10000 });

Mocking Strategy: Intent-Based, Not All-Or-Nothing

模拟策略:基于意图而非全有或全无

The problem: Two extremes that both fail:
  • All mocks: Tests pass but don't catch integration bugs (mocks lie about real behavior)
  • All staging: Tests flake due to infrastructure instability (staging is real but unreliable)
The pattern: Hybrid by test intent
typescript
// INTENT: Test UI logic, not API integration
// → Mock the API, test DOM updates
test('displays user data when loaded', async ({ page }) => {
  await page.route('/api/user', route => {
    route.abort(); // Simulate network failure
  });
  await page.goto('/profile');
  await expect(page.locator('.error-message')).toContainText('Failed to load');
});

// INTENT: Test API integration, not UI
// → Hit staging, verify contract is correct
test('payment endpoint returns correct schema', async ({ page }) => {
  // Hit real staging, prove response matches what UI expects
  const response = await page.request.post(
    `${process.env.STAGING_API}/payment`,
    { data: { amount: 100 } }
  );
  expect(response.ok()).toBeTruthy();
  const json = await response.json();
  expect(json).toHaveProperty('transactionId');
  expect(json).toHaveProperty('status');
});

// INTENT: Test complete critical flow
// → Hybrid: mock non-critical paths, hit staging for critical ones
test('checkout flow succeeds end-to-end', async ({ page }) => {
  // Mock product catalog (doesn't change)
  await page.route('/api/products', route => {
    route.continue({ response: mockProducts });
  });
  // Hit real staging for payment (critical + mature)
  // Hit real staging for order confirmation (critical + stable)
  // Results in fast + reliable + safe tests
});
Decision tree:
  • UI logic tests (99% of your tests) → Mock APIs, test UI response
  • Contract tests (1-2% of tests) → Hit staging for critical integrations
  • Flaky staging? → Mock more, test UI resilience instead
  • Coverage gaps? → Add mock scenarios for error cases staging doesn't trigger easily
问题: 两种极端方式都会失败:
  • 全模拟: 测试通过但无法发现集成bug(模拟数据与真实行为不符)
  • 全预发布环境: 测试因基础设施不稳定而失败(预发布环境真实但不可靠)
实现模式: 根据测试意图采用混合策略
typescript
// 测试意图:测试UI逻辑,而非API集成
// → 模拟API,测试DOM更新
test('加载时显示用户数据', async ({ page }) => {
  await page.route('/api/user', route => {
    route.abort(); // 模拟网络故障
  });
  await page.goto('/profile');
  await expect(page.locator('.error-message')).toContainText('加载失败');
});

// 测试意图:测试API集成,而非UI
// → 调用预发布环境,验证契约正确性
test('支付接口返回正确的 schema', async ({ page }) => {
  // 调用真实预发布环境,验证响应符合UI预期
  const response = await page.request.post(
    `${process.env.STAGING_API}/payment`,
    { data: { amount: 100 } }
  );
  expect(response.ok()).toBeTruthy();
  const json = await response.json();
  expect(json).toHaveProperty('transactionId');
  expect(json).toHaveProperty('status');
});

// 测试意图:测试完整的关键流程
// → 混合策略:模拟非关键路径,调用预发布环境测试关键路径
test('结账流程端到端成功', async ({ page }) => {
  // 模拟产品目录(内容不会变化)
  await page.route('/api/products', route => {
    route.continue({ response: mockProducts });
  });
  // 调用真实预发布环境测试支付(关键且成熟)
  // 调用真实预发布环境测试订单确认(关键且稳定)
  // 最终实现快速、可靠且安全的测试
});
决策树:
  • UI逻辑测试(占测试总量的99%)→ 模拟API,测试UI响应
  • 契约测试(占测试总量的1-2%)→ 针对关键集成调用预发布环境
  • 预发布环境不稳定? → 增加模拟,转而测试UI的容错能力
  • 存在覆盖率缺口? → 增加模拟场景,覆盖预发布环境难以触发的错误情况

Common Mistakes & Rationalizations

常见错误与自我合理化

RationalizationRealityAction
"I'll defer edge case tests to next sprint"You won't. Edge cases always ship untested.Ship with complete coverage or don't ship. No exceptions.
"Fixed timeouts are good enough"They work locally, flake in CI. Condition-based is not harder.Use condition-based waits. Not "later." Now.
"I'll refactor to condition-based waits as I go"You won't. Fixed timeouts stay forever. CI flakes forever.Write condition-based waits first. Not "next time." This time.
"Manual testing covers edge cases"It doesn't. Manual testing doesn't prevent regression.Automated edge case tests are mandatory. Both matter.
"Mocking everything is pragmatic"Pragmatism means working tests. All-mocks hide integration bugs.Test critical paths against staging. Mock the rest by intent.
"My setup is too complex to refactor now"It's not. Condition-based wait = 2 lines. Complexity is pretense.Write correct waits first. Change behavior for shipping, not convenience.
自我合理化真实情况行动方案
"我把边缘场景测试推迟到下一个迭代"你不会的。边缘场景最终会无测试发布。完成完整覆盖后再发布,否则不发布。无例外。
"固定超时已经足够了"它们在本地有效,但在CI中不稳定。基于条件的等待并不难。使用基于条件的等待。不是“以后”,就是现在。
"我会逐步重构为基于条件的等待"你不会的。固定超时会一直存在,CI会一直不稳定。一开始就写基于条件的等待。不是“下次”,就是这次。
"手动测试覆盖了边缘场景"并没有。手动测试无法防止回归。自动化边缘场景测试是必须的。两者都很重要。
"全模拟是务实的做法"务实意味着测试真正有效。全模拟会隐藏集成bug。针对关键路径测试预发布环境,其余部分根据意图模拟。
"我的设置太复杂,现在无法重构"其实不然。基于条件的等待只需2行代码。复杂只是借口。先写正确的等待逻辑。为了发布而调整行为,而非为了省事。

Red Flags - STOP

危险信号 - 立即停止

Stop and start over if you're saying:
  • "I'll fix the timeouts when they flake"
  • "Edge cases can ship, we'll harden later"
  • "Good enough for now"
  • "This setup is different"
  • "I'm being pragmatic"
All of these mean: Incomplete test coverage + brittle waits will ship. Delete and rewrite with complete coverage + condition-based waits.
如果你有以下想法,请停止当前工作并重新开始:
  • "等超时导致测试不稳定时我再修复"
  • "边缘场景可以先发布,以后再完善"
  • "现在这样就够了"
  • "我的情况不一样"
  • "我这是务实"
所有这些想法都意味着:未完成的测试覆盖 + 脆弱的等待逻辑会被发布。删除现有代码,重新编写完整覆盖 + 基于条件等待的测试。

Implementation

实践建议

Use
page.locator()
(preferred) over
page.$()
for:
  • Built-in waiting
  • Better error messages
  • Clear intent in code
typescript
// Find element and wait for it to be visible
await page.locator('[data-testid="submit"]').waitFor({ state: 'visible' });
// Find and click in one step (waits for visibility first)
await page.locator('[data-testid="submit"]').click();
Route APIs with clear intent:
typescript
// All error cases for this path
await page.route('/api/checkout/**', route => {
  if (Math.random() > 0.8) route.abort(); // 20% failure rate
  else route.continue();
});
Intercept network to verify contracts:
typescript
const requests: any[] = [];
await page.on('request', (request) => {
  if (request.url().includes('/api')) {
    requests.push({
      url: request.url(),
      method: request.method(),
      postData: request.postData(),
    });
  }
});
// After test actions
expect(requests).toContainEqual(
  expect.objectContaining({ method: 'POST', url: expect.stringContaining('/payment') })
);
优先使用
page.locator()
而非
page.$()
,因为:
  • 内置等待逻辑
  • 错误信息更清晰
  • 代码意图更明确
typescript
// 找到元素并等待其可见
await page.locator('[data-testid="submit"]').waitFor({ state: 'visible' });
// 一步完成查找和点击(先等待元素可见)
await page.locator('[data-testid="submit"]').click();
以明确意图路由API:
typescript
// 该路径的所有错误场景
await page.route('/api/checkout/**', route => {
  if (Math.random() > 0.8) route.abort(); // 20%的失败率
  else route.continue();
});
拦截网络请求以验证契约:
typescript
const requests: any[] = [];
await page.on('request', (request) => {
  if (request.url().includes('/api')) {
    requests.push({
      url: request.url(),
      method: request.method(),
      postData: request.postData(),
    });
  }
});
// 测试操作完成后
expect(requests).toContainEqual(
  expect.objectContaining({ method: 'POST', url: expect.stringContaining('/payment') })
);

Real-World Impact

实际效果

From applying these patterns to the zenobi.us e2e suite:
  • Fixed timeout flake rate dropped from 8% to 0.3% (condition-based waits)
  • Edge case coverage increased from 42% to 94% (pre-ship completeness)
  • Test maintenance dropped 60% (clearer intent, fewer mysterious failures)
  • Mocking strategy reduced CI time by 35% while increasing staging integration confidence
在zenobi.us的端到端测试套件中应用这些模式后:
  • 固定超时导致的测试不稳定率从8%降至0.3%(基于条件的等待)
  • 边缘场景覆盖率从42%提升至94%(发布前完成完整覆盖)
  • 测试维护工作量减少60%(意图更清晰,神秘故障更少)
  • 模拟策略将CI运行时间缩短35%,同时提升了对预发布环境集成的信心