playwright-testing
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseTesting 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 or
waitForSelector()with fixed valueswaitForTimeout() - 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 with 5-second default:
page.waitForSelector()- 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 });问题: 像默认5秒这样的固定超时:
page.waitForSelector()- 在快速系统上造成不必要的等待
- 在慢速系统上因超时时间过短导致测试不稳定
- 隐藏实际问题(你到底在等什么?)
- 让人倾向于推迟重构(“我以后再修”)
实现模式:
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
常见错误与自我合理化
| Rationalization | Reality | Action |
|---|---|---|
| "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 (preferred) over for:
page.locator()page.$()- 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%,同时提升了对预发布环境集成的信心