@assertrx/dino 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/README.md +293 -0
  2. package/extensions/dino.ts +70 -0
  3. package/extensions/mabl-mcp.ts +306 -0
  4. package/extensions/playwright-mcp.ts +142 -0
  5. package/package.json +47 -0
  6. package/prompts/create-pr.md +110 -0
  7. package/prompts/generate-bdd-from-url.md +65 -0
  8. package/prompts/generate-pom-from-url-cli.md +48 -0
  9. package/prompts/generate-pom-from-url.md +45 -0
  10. package/prompts/generate-spec-from-feature.md +64 -0
  11. package/prompts/heal-playwright-failures.md +106 -0
  12. package/prompts/setup-playwright.md +81 -0
  13. package/scripts/start-dino.cmd +8 -0
  14. package/scripts/start-dino.mjs +226 -0
  15. package/scripts/start-dino.ps1 +9 -0
  16. package/scripts/start-dino.sh +10 -0
  17. package/skills/acli/SKILL.md +292 -0
  18. package/skills/bdd-gherkin/SKILL.md +552 -0
  19. package/skills/build-review-interface/SKILL.md +96 -0
  20. package/skills/error-analysis/SKILL.md +164 -0
  21. package/skills/eval-audit/SKILL.md +183 -0
  22. package/skills/evaluate-rag/SKILL.md +177 -0
  23. package/skills/generate-synthetic-data/SKILL.md +131 -0
  24. package/skills/mabl-mcp/SKILL.md +79 -0
  25. package/skills/playwright-cli/SKILL.md +246 -0
  26. package/skills/playwright-cli/advanced-workflows.md +679 -0
  27. package/skills/playwright-cli/core-commands.md +375 -0
  28. package/skills/playwright-cli/device-emulation.md +475 -0
  29. package/skills/playwright-cli/request-mocking.md +373 -0
  30. package/skills/playwright-cli/running-custom-code.md +553 -0
  31. package/skills/playwright-cli/screenshots-and-media.md +457 -0
  32. package/skills/playwright-cli/session-management.md +425 -0
  33. package/skills/playwright-cli/storage-and-auth.md +487 -0
  34. package/skills/playwright-cli/test-generation.md +336 -0
  35. package/skills/playwright-cli/tracing-and-debugging.md +501 -0
  36. package/skills/playwright-core/SKILL.md +101 -0
  37. package/skills/playwright-core/accessibility.md +1517 -0
  38. package/skills/playwright-core/angular.md +1086 -0
  39. package/skills/playwright-core/api-testing.md +1623 -0
  40. package/skills/playwright-core/assertions-and-waiting.md +703 -0
  41. package/skills/playwright-core/auth-flows.md +1081 -0
  42. package/skills/playwright-core/authentication.md +1409 -0
  43. package/skills/playwright-core/browser-apis.md +673 -0
  44. package/skills/playwright-core/browser-extensions.md +319 -0
  45. package/skills/playwright-core/canvas-and-webgl.md +494 -0
  46. package/skills/playwright-core/clock-and-time-mocking.md +427 -0
  47. package/skills/playwright-core/common-pitfalls.md +1318 -0
  48. package/skills/playwright-core/component-testing.md +1179 -0
  49. package/skills/playwright-core/configuration.md +731 -0
  50. package/skills/playwright-core/crud-testing.md +945 -0
  51. package/skills/playwright-core/debugging.md +827 -0
  52. package/skills/playwright-core/drag-and-drop.md +992 -0
  53. package/skills/playwright-core/electron-testing.md +622 -0
  54. package/skills/playwright-core/error-and-edge-cases.md +1181 -0
  55. package/skills/playwright-core/error-index.md +1903 -0
  56. package/skills/playwright-core/file-operations.md +749 -0
  57. package/skills/playwright-core/file-upload-download.md +1012 -0
  58. package/skills/playwright-core/fixtures-and-hooks.md +1072 -0
  59. package/skills/playwright-core/flaky-tests.md +883 -0
  60. package/skills/playwright-core/forms-and-validation.md +1056 -0
  61. package/skills/playwright-core/i18n-and-localization.md +622 -0
  62. package/skills/playwright-core/iframes-and-shadow-dom.md +488 -0
  63. package/skills/playwright-core/locator-strategy.md +588 -0
  64. package/skills/playwright-core/locators.md +743 -0
  65. package/skills/playwright-core/mobile-and-responsive.md +1669 -0
  66. package/skills/playwright-core/multi-context-and-popups.md +581 -0
  67. package/skills/playwright-core/multi-user-and-collaboration.md +477 -0
  68. package/skills/playwright-core/network-mocking.md +1395 -0
  69. package/skills/playwright-core/nextjs.md +1013 -0
  70. package/skills/playwright-core/performance-testing.md +608 -0
  71. package/skills/playwright-core/react.md +1082 -0
  72. package/skills/playwright-core/search-and-filter.md +1366 -0
  73. package/skills/playwright-core/security-testing.md +624 -0
  74. package/skills/playwright-core/service-workers-and-pwa.md +524 -0
  75. package/skills/playwright-core/test-architecture.md +569 -0
  76. package/skills/playwright-core/test-data-management.md +1209 -0
  77. package/skills/playwright-core/test-organization.md +947 -0
  78. package/skills/playwright-core/third-party-integrations.md +754 -0
  79. package/skills/playwright-core/trace-analysis.md +161 -0
  80. package/skills/playwright-core/visual-regression.md +1027 -0
  81. package/skills/playwright-core/vue.md +1162 -0
  82. package/skills/playwright-core/websockets-and-realtime.md +623 -0
  83. package/skills/playwright-core/when-to-mock.md +827 -0
  84. package/skills/playwright-pom/SKILL.md +94 -0
  85. package/skills/playwright-pom/page-object-model.md +921 -0
  86. package/skills/playwright-pom/pom-vs-fixtures-vs-helpers.md +937 -0
  87. package/skills/validate-evaluator/SKILL.md +215 -0
  88. package/skills/write-judge-prompt/SKILL.md +144 -0
  89. package/themes/dino.json +66 -0
@@ -0,0 +1,1318 @@
1
+ # Common Pitfalls
2
+
3
+ > **When to use**: When learning Playwright, reviewing tests for common mistakes, or onboarding new team members to your test suite.
4
+
5
+ The 20 most common Playwright mistakes, ordered by how frequently they appear in real codebases. Each pitfall includes the symptom, root cause, and a complete fix.
6
+
7
+ ---
8
+
9
+ ## Pitfall 1: Using `page.waitForTimeout()` Instead of Assertions
10
+
11
+ **Symptom**: Tests are slow and flaky. They pass on fast machines but fail on slow CI runners.
12
+
13
+ **Why it happens**: Developers port habits from Selenium or Cypress where explicit waits were necessary. In Playwright, auto-retrying assertions handle timing automatically.
14
+
15
+ **Fix**: Replace every `waitForTimeout` with a web-first assertion or an explicit wait for a specific condition.
16
+
17
+ **TypeScript**
18
+ ```typescript
19
+ import { test, expect } from '@playwright/test';
20
+
21
+ // BAD
22
+ test('bad: arbitrary wait', async ({ page }) => {
23
+ await page.goto('/dashboard');
24
+ await page.getByRole('button', { name: 'Load' }).click();
25
+ await page.waitForTimeout(3000);
26
+ await expect(page.getByTestId('chart')).toBeVisible();
27
+ });
28
+
29
+ // GOOD
30
+ test('good: auto-retrying assertion', async ({ page }) => {
31
+ await page.goto('/dashboard');
32
+ await page.getByRole('button', { name: 'Load' }).click();
33
+ await expect(page.getByTestId('chart')).toBeVisible();
34
+ });
35
+
36
+ // GOOD — when you need to wait for a specific network event
37
+ test('good: wait for response', async ({ page }) => {
38
+ await page.goto('/dashboard');
39
+ const responsePromise = page.waitForResponse('**/api/chart-data');
40
+ await page.getByRole('button', { name: 'Load' }).click();
41
+ await responsePromise;
42
+ await expect(page.getByTestId('chart')).toBeVisible();
43
+ });
44
+ ```
45
+
46
+ **JavaScript**
47
+ ```javascript
48
+ const { test, expect } = require('@playwright/test');
49
+
50
+ // BAD
51
+ test('bad: arbitrary wait', async ({ page }) => {
52
+ await page.goto('/dashboard');
53
+ await page.getByRole('button', { name: 'Load' }).click();
54
+ await page.waitForTimeout(3000);
55
+ await expect(page.getByTestId('chart')).toBeVisible();
56
+ });
57
+
58
+ // GOOD
59
+ test('good: auto-retrying assertion', async ({ page }) => {
60
+ await page.goto('/dashboard');
61
+ await page.getByRole('button', { name: 'Load' }).click();
62
+ await expect(page.getByTestId('chart')).toBeVisible();
63
+ });
64
+ ```
65
+
66
+ **The only acceptable use of `waitForTimeout`**: Debugging with `page.pause()` unavailable, or simulating real user "think time" in performance tests. Never in production test code.
67
+
68
+ ---
69
+
70
+ ## Pitfall 2: Not Awaiting Async Operations
71
+
72
+ **Symptom**: Tests pass unpredictably. Assertions run before actions complete. Error messages reference detached frames or closed pages.
73
+
74
+ **Why it happens**: Every Playwright API call is async. Missing a single `await` causes the next line to execute before the previous action finishes.
75
+
76
+ **Fix**: Always `await` every Playwright call. Enable the `@typescript-eslint/no-floating-promises` ESLint rule.
77
+
78
+ **TypeScript**
79
+ ```typescript
80
+ import { test, expect } from '@playwright/test';
81
+
82
+ // BAD — missing await on click, assertion runs before navigation
83
+ test('bad: missing await', async ({ page }) => {
84
+ await page.goto('/login');
85
+ await page.getByLabel('Email').fill('user@test.com');
86
+ await page.getByLabel('Password').fill('password');
87
+ page.getByRole('button', { name: 'Sign in' }).click(); // MISSING AWAIT
88
+ await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
89
+ });
90
+
91
+ // GOOD
92
+ test('good: all actions awaited', async ({ page }) => {
93
+ await page.goto('/login');
94
+ await page.getByLabel('Email').fill('user@test.com');
95
+ await page.getByLabel('Password').fill('password');
96
+ await page.getByRole('button', { name: 'Sign in' }).click();
97
+ await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
98
+ });
99
+ ```
100
+
101
+ **JavaScript**
102
+ ```javascript
103
+ const { test, expect } = require('@playwright/test');
104
+
105
+ // BAD
106
+ test('bad: missing await', async ({ page }) => {
107
+ await page.goto('/login');
108
+ page.getByRole('button', { name: 'Submit' }).click(); // MISSING AWAIT
109
+ await expect(page.getByText('Success')).toBeVisible();
110
+ });
111
+
112
+ // GOOD
113
+ test('good: all actions awaited', async ({ page }) => {
114
+ await page.goto('/login');
115
+ await page.getByRole('button', { name: 'Submit' }).click();
116
+ await expect(page.getByText('Success')).toBeVisible();
117
+ });
118
+ ```
119
+
120
+ **Prevention**: Add this ESLint config to catch floating promises at compile time:
121
+ ```json
122
+ {
123
+ "rules": {
124
+ "@typescript-eslint/no-floating-promises": "error"
125
+ }
126
+ }
127
+ ```
128
+
129
+ ---
130
+
131
+ ## Pitfall 3: Using CSS Selectors Instead of Role-Based Locators
132
+
133
+ **Symptom**: Tests break whenever a CSS class name, DOM structure, or component library changes. Tests are hard to read because selectors look like `.btn-primary > span:nth-child(2)`.
134
+
135
+ **Why it happens**: Developers use what they know from jQuery or DevTools. CSS selectors are implementation details that change frequently.
136
+
137
+ **Fix**: Use Playwright's built-in locators that target accessible roles, labels, text, and test IDs.
138
+
139
+ **TypeScript**
140
+ ```typescript
141
+ import { test, expect } from '@playwright/test';
142
+
143
+ // BAD — brittle CSS selectors
144
+ test('bad: CSS selectors', async ({ page }) => {
145
+ await page.goto('/settings');
146
+ await page.locator('.form-group:nth-child(3) input.form-control').fill('new value');
147
+ await page.locator('button.btn.btn-primary.submit-btn').click();
148
+ await expect(page.locator('.alert.alert-success')).toBeVisible();
149
+ });
150
+
151
+ // GOOD — role-based locators
152
+ test('good: accessible locators', async ({ page }) => {
153
+ await page.goto('/settings');
154
+ await page.getByLabel('Display name').fill('new value');
155
+ await page.getByRole('button', { name: 'Save' }).click();
156
+ await expect(page.getByRole('alert')).toHaveText('Settings saved');
157
+ });
158
+ ```
159
+
160
+ **JavaScript**
161
+ ```javascript
162
+ const { test, expect } = require('@playwright/test');
163
+
164
+ // BAD
165
+ test('bad: CSS selectors', async ({ page }) => {
166
+ await page.locator('.form-group:nth-child(3) input').fill('new value');
167
+ await page.locator('button.btn-primary').click();
168
+ });
169
+
170
+ // GOOD
171
+ test('good: accessible locators', async ({ page }) => {
172
+ await page.getByLabel('Display name').fill('new value');
173
+ await page.getByRole('button', { name: 'Save' }).click();
174
+ });
175
+ ```
176
+
177
+ **Locator priority** (most resilient to least):
178
+ 1. `getByRole()` -- accessible role + name
179
+ 2. `getByLabel()` -- form fields by label text
180
+ 3. `getByPlaceholder()` -- inputs by placeholder
181
+ 4. `getByText()` -- visible text content
182
+ 5. `getByTestId()` -- stable `data-testid` attribute
183
+ 6. CSS/XPath selectors -- last resort only
184
+
185
+ ---
186
+
187
+ ## Pitfall 4: Asserting on `isVisible()` Return Value Instead of `expect().toBeVisible()`
188
+
189
+ **Symptom**: Test passes when the element is not visible. Assertion is silently wrong. Flaky under timing pressure.
190
+
191
+ **Why it happens**: `isVisible()` returns a boolean at one point in time -- no retry. If the element has not appeared yet, it returns `false` and `expect(false).toBe(true)` fails immediately without waiting.
192
+
193
+ **Fix**: Always use `expect(locator).toBeVisible()`, which auto-retries until the element appears or the timeout expires.
194
+
195
+ **TypeScript**
196
+ ```typescript
197
+ import { test, expect } from '@playwright/test';
198
+
199
+ // BAD — resolves once, no retry
200
+ test('bad: isVisible check', async ({ page }) => {
201
+ await page.goto('/dashboard');
202
+ const visible = await page.getByTestId('widget').isVisible();
203
+ expect(visible).toBe(true); // Fails immediately if widget hasn't rendered yet
204
+ });
205
+
206
+ // GOOD — auto-retries for up to 5 seconds
207
+ test('good: toBeVisible assertion', async ({ page }) => {
208
+ await page.goto('/dashboard');
209
+ await expect(page.getByTestId('widget')).toBeVisible();
210
+ });
211
+ ```
212
+
213
+ **JavaScript**
214
+ ```javascript
215
+ const { test, expect } = require('@playwright/test');
216
+
217
+ // BAD
218
+ test('bad: isVisible check', async ({ page }) => {
219
+ const visible = await page.getByTestId('widget').isVisible();
220
+ expect(visible).toBe(true);
221
+ });
222
+
223
+ // GOOD
224
+ test('good: toBeVisible assertion', async ({ page }) => {
225
+ await expect(page.getByTestId('widget')).toBeVisible();
226
+ });
227
+ ```
228
+
229
+ This applies to all "resolve once" methods: `isVisible()`, `isEnabled()`, `isChecked()`, `textContent()`, `getAttribute()`, `inputValue()`. Always use the `expect(locator)` web-first assertion counterpart.
230
+
231
+ ---
232
+
233
+ ## Pitfall 5: Sharing Mutable State Between Parallel Tests
234
+
235
+ **Symptom**: Tests pass when run alone but fail in the full suite. Order-dependent failures. "Duplicate key" errors.
236
+
237
+ **Why it happens**: Module-level variables, shared database rows, or `beforeAll`-created state is mutated by one test and seen by another running in parallel.
238
+
239
+ **Fix**: Use test-scoped fixtures with unique data. Never store mutable state in module-level variables.
240
+
241
+ **TypeScript**
242
+ ```typescript
243
+ import { test as base, expect } from '@playwright/test';
244
+
245
+ // BAD — module-level mutable state shared across parallel tests
246
+ let userId: string;
247
+
248
+ test.beforeAll(async ({ request }) => {
249
+ const res = await request.post('/api/users', {
250
+ data: { email: 'shared@test.com' },
251
+ });
252
+ userId = (await res.json()).id; // Every parallel worker overwrites this
253
+ });
254
+
255
+ test('bad: uses shared state', async ({ page }) => {
256
+ await page.goto(`/users/${userId}`); // userId may be from another worker
257
+ });
258
+
259
+ // GOOD — test-scoped fixture with unique data per test
260
+ export const test = base.extend<{ testUser: { id: string; email: string } }>({
261
+ testUser: async ({ request }, use) => {
262
+ const email = `user-${Date.now()}-${Math.random().toString(36).slice(2)}@test.com`;
263
+ const res = await request.post('/api/users', { data: { email } });
264
+ const user = await res.json();
265
+
266
+ await use({ id: user.id, email });
267
+
268
+ await request.delete(`/api/users/${user.id}`);
269
+ },
270
+ });
271
+
272
+ test('good: isolated data per test', async ({ page, testUser }) => {
273
+ await page.goto(`/users/${testUser.id}`);
274
+ await expect(page.getByText(testUser.email)).toBeVisible();
275
+ });
276
+ ```
277
+
278
+ **JavaScript**
279
+ ```javascript
280
+ const { test: base, expect } = require('@playwright/test');
281
+
282
+ // GOOD
283
+ const test = base.extend({
284
+ testUser: async ({ request }, use) => {
285
+ const email = `user-${Date.now()}-${Math.random().toString(36).slice(2)}@test.com`;
286
+ const res = await request.post('/api/users', { data: { email } });
287
+ const user = await res.json();
288
+
289
+ await use({ id: user.id, email });
290
+
291
+ await request.delete(`/api/users/${user.id}`);
292
+ },
293
+ });
294
+
295
+ test('good: isolated data per test', async ({ page, testUser }) => {
296
+ await page.goto(`/users/${testUser.id}`);
297
+ await expect(page.getByText(testUser.email)).toBeVisible();
298
+ });
299
+
300
+ module.exports = { test };
301
+ ```
302
+
303
+ ---
304
+
305
+ ## Pitfall 6: Not Using `baseURL` (Hardcoding Full URLs)
306
+
307
+ **Symptom**: Tests break when switching environments (local, staging, production). URL strings are duplicated everywhere.
308
+
309
+ **Why it happens**: Developers start with `page.goto('http://localhost:3000/login')` and never refactor.
310
+
311
+ **Fix**: Set `baseURL` in `playwright.config` and use relative paths in all tests.
312
+
313
+ **TypeScript**
314
+ ```typescript
315
+ // playwright.config.ts
316
+ import { defineConfig } from '@playwright/test';
317
+
318
+ export default defineConfig({
319
+ use: {
320
+ baseURL: process.env.BASE_URL || 'http://localhost:3000',
321
+ },
322
+ });
323
+ ```
324
+
325
+ ```typescript
326
+ import { test, expect } from '@playwright/test';
327
+
328
+ // BAD
329
+ test('bad: hardcoded URL', async ({ page }) => {
330
+ await page.goto('http://localhost:3000/login');
331
+ });
332
+
333
+ // GOOD
334
+ test('good: relative URL', async ({ page }) => {
335
+ await page.goto('/login');
336
+ });
337
+ ```
338
+
339
+ **JavaScript**
340
+ ```javascript
341
+ // playwright.config.js
342
+ const { defineConfig } = require('@playwright/test');
343
+
344
+ module.exports = defineConfig({
345
+ use: {
346
+ baseURL: process.env.BASE_URL || 'http://localhost:3000',
347
+ },
348
+ });
349
+ ```
350
+
351
+ ```javascript
352
+ const { test, expect } = require('@playwright/test');
353
+
354
+ // BAD
355
+ test('bad: hardcoded URL', async ({ page }) => {
356
+ await page.goto('http://localhost:3000/login');
357
+ });
358
+
359
+ // GOOD
360
+ test('good: relative URL', async ({ page }) => {
361
+ await page.goto('/login');
362
+ });
363
+ ```
364
+
365
+ Run against different environments by setting the variable: `BASE_URL=https://staging.example.com npx playwright test`
366
+
367
+ ---
368
+
369
+ ## Pitfall 7: Using `page.$()` Instead of `page.locator()`
370
+
371
+ **Symptom**: Element handle is `null`. Stale element errors. No auto-waiting.
372
+
373
+ **Why it happens**: `page.$()` and `page.$$()` are ElementHandle APIs from Puppeteer. They resolve once and return a handle that can go stale. Locators are Playwright's replacement -- they are lazy and re-evaluate on every action.
374
+
375
+ **Fix**: Always use `page.locator()`, `page.getByRole()`, or other locator methods.
376
+
377
+ **TypeScript**
378
+ ```typescript
379
+ import { test, expect } from '@playwright/test';
380
+
381
+ // BAD — ElementHandle API, resolves once, can go stale
382
+ test('bad: page.$ usage', async ({ page }) => {
383
+ await page.goto('/products');
384
+ const button = await page.$('.add-to-cart'); // null if not found, stale if DOM changes
385
+ if (button) {
386
+ await button.click();
387
+ }
388
+ const count = await page.$$('.cart-item');
389
+ expect(count.length).toBe(1); // No auto-retry
390
+ });
391
+
392
+ // GOOD — locator API, lazy evaluation, auto-waiting
393
+ test('good: locator usage', async ({ page }) => {
394
+ await page.goto('/products');
395
+ await page.getByRole('button', { name: 'Add to cart' }).first().click();
396
+ await expect(page.getByTestId('cart-item')).toHaveCount(1); // Auto-retries
397
+ });
398
+ ```
399
+
400
+ **JavaScript**
401
+ ```javascript
402
+ const { test, expect } = require('@playwright/test');
403
+
404
+ // BAD
405
+ test('bad: page.$ usage', async ({ page }) => {
406
+ const button = await page.$('.add-to-cart');
407
+ if (button) await button.click();
408
+ });
409
+
410
+ // GOOD
411
+ test('good: locator usage', async ({ page }) => {
412
+ await page.getByRole('button', { name: 'Add to cart' }).first().click();
413
+ });
414
+ ```
415
+
416
+ ---
417
+
418
+ ## Pitfall 8: Not Handling Navigation After Form Submission
419
+
420
+ **Symptom**: Test fails with "Target page, context or browser has been closed" or assertions fail because the page navigated away before the assertion ran.
421
+
422
+ **Why it happens**: Clicking a submit button triggers a full page navigation. The assertion runs against the old page that is being unloaded.
423
+
424
+ **Fix**: Wait for the navigation to complete before asserting on the new page.
425
+
426
+ **TypeScript**
427
+ ```typescript
428
+ import { test, expect } from '@playwright/test';
429
+
430
+ // BAD — assertion runs before navigation completes
431
+ test('bad: no navigation handling', async ({ page }) => {
432
+ await page.goto('/login');
433
+ await page.getByLabel('Email').fill('user@test.com');
434
+ await page.getByLabel('Password').fill('password');
435
+ await page.getByRole('button', { name: 'Sign in' }).click();
436
+ // Page is navigating — this may fail with "Execution context was destroyed"
437
+ await expect(page.getByRole('heading')).toHaveText('Dashboard');
438
+ });
439
+
440
+ // GOOD — wait for URL to change, then assert
441
+ test('good: waitForURL after navigation', async ({ page }) => {
442
+ await page.goto('/login');
443
+ await page.getByLabel('Email').fill('user@test.com');
444
+ await page.getByLabel('Password').fill('password');
445
+ await page.getByRole('button', { name: 'Sign in' }).click();
446
+ await page.waitForURL('/dashboard');
447
+ await expect(page.getByRole('heading')).toHaveText('Dashboard');
448
+ });
449
+
450
+ // GOOD — alternative: use expect().toHaveURL() which auto-retries
451
+ test('good: toHaveURL assertion', async ({ page }) => {
452
+ await page.goto('/login');
453
+ await page.getByLabel('Email').fill('user@test.com');
454
+ await page.getByLabel('Password').fill('password');
455
+ await page.getByRole('button', { name: 'Sign in' }).click();
456
+ await expect(page).toHaveURL(/.*dashboard/);
457
+ await expect(page.getByRole('heading')).toHaveText('Dashboard');
458
+ });
459
+ ```
460
+
461
+ **JavaScript**
462
+ ```javascript
463
+ const { test, expect } = require('@playwright/test');
464
+
465
+ // GOOD
466
+ test('good: waitForURL after navigation', async ({ page }) => {
467
+ await page.goto('/login');
468
+ await page.getByLabel('Email').fill('user@test.com');
469
+ await page.getByLabel('Password').fill('password');
470
+ await page.getByRole('button', { name: 'Sign in' }).click();
471
+ await page.waitForURL('/dashboard');
472
+ await expect(page.getByRole('heading')).toHaveText('Dashboard');
473
+ });
474
+ ```
475
+
476
+ ---
477
+
478
+ ## Pitfall 9: Testing Against `localhost` in CI Without `webServer` Config
479
+
480
+ **Symptom**: Tests fail in CI with `ECONNREFUSED` on `localhost:3000`. Works locally because the dev server is already running.
481
+
482
+ **Why it happens**: CI runners start with a clean environment. No dev server is running unless you explicitly start one.
483
+
484
+ **Fix**: Use the `webServer` config option to start your app automatically.
485
+
486
+ **TypeScript**
487
+ ```typescript
488
+ // playwright.config.ts
489
+ import { defineConfig } from '@playwright/test';
490
+
491
+ export default defineConfig({
492
+ use: {
493
+ baseURL: 'http://localhost:3000',
494
+ },
495
+
496
+ webServer: {
497
+ command: 'npm run start',
498
+ url: 'http://localhost:3000',
499
+ // Reuse existing server locally (faster), start fresh in CI
500
+ reuseExistingServer: !process.env.CI,
501
+ // Give the server time to start
502
+ timeout: 120_000,
503
+ // Capture server output for debugging startup failures
504
+ stdout: 'pipe',
505
+ stderr: 'pipe',
506
+ },
507
+ });
508
+ ```
509
+
510
+ **JavaScript**
511
+ ```javascript
512
+ // playwright.config.js
513
+ const { defineConfig } = require('@playwright/test');
514
+
515
+ module.exports = defineConfig({
516
+ use: {
517
+ baseURL: 'http://localhost:3000',
518
+ },
519
+ webServer: {
520
+ command: 'npm run start',
521
+ url: 'http://localhost:3000',
522
+ reuseExistingServer: !process.env.CI,
523
+ timeout: 120_000,
524
+ stdout: 'pipe',
525
+ stderr: 'pipe',
526
+ },
527
+ });
528
+ ```
529
+
530
+ For multiple servers (frontend + backend), pass an array:
531
+ ```typescript
532
+ webServer: [
533
+ { command: 'npm run start:api', url: 'http://localhost:4000/health', reuseExistingServer: !process.env.CI },
534
+ { command: 'npm run start:web', url: 'http://localhost:3000', reuseExistingServer: !process.env.CI },
535
+ ],
536
+ ```
537
+
538
+ ---
539
+
540
+ ## Pitfall 10: Using `innerHTML` for Text Assertions Instead of `toHaveText()`
541
+
542
+ **Symptom**: Tests break when HTML structure changes. Assertions are fragile and hard to read.
543
+
544
+ **Why it happens**: Developers use `innerHTML()` or `textContent()` to get text and then assert on the resolved string. This resolves once with no retry.
545
+
546
+ **Fix**: Use `expect(locator).toHaveText()` or `expect(locator).toContainText()` for auto-retrying text assertions.
547
+
548
+ **TypeScript**
549
+ ```typescript
550
+ import { test, expect } from '@playwright/test';
551
+
552
+ // BAD — resolves once, includes HTML tags, no retry
553
+ test('bad: innerHTML assertion', async ({ page }) => {
554
+ await page.goto('/product/123');
555
+ const html = await page.getByTestId('price').innerHTML();
556
+ expect(html).toContain('$49.99'); // Brittle: depends on HTML structure
557
+ });
558
+
559
+ // BAD — textContent resolves once, no retry
560
+ test('bad: textContent assertion', async ({ page }) => {
561
+ await page.goto('/product/123');
562
+ const text = await page.getByTestId('price').textContent();
563
+ expect(text).toBe('$49.99'); // No retry if text hasn't loaded yet
564
+ });
565
+
566
+ // GOOD — auto-retrying, no HTML dependency
567
+ test('good: toHaveText assertion', async ({ page }) => {
568
+ await page.goto('/product/123');
569
+ await expect(page.getByTestId('price')).toHaveText('$49.99');
570
+ });
571
+
572
+ // GOOD — partial match for flexible assertions
573
+ test('good: toContainText assertion', async ({ page }) => {
574
+ await page.goto('/product/123');
575
+ await expect(page.getByTestId('price')).toContainText('$49');
576
+ });
577
+ ```
578
+
579
+ **JavaScript**
580
+ ```javascript
581
+ const { test, expect } = require('@playwright/test');
582
+
583
+ // BAD
584
+ test('bad: textContent assertion', async ({ page }) => {
585
+ const text = await page.getByTestId('price').textContent();
586
+ expect(text).toBe('$49.99');
587
+ });
588
+
589
+ // GOOD
590
+ test('good: toHaveText assertion', async ({ page }) => {
591
+ await expect(page.getByTestId('price')).toHaveText('$49.99');
592
+ });
593
+ ```
594
+
595
+ ---
596
+
597
+ ## Pitfall 11: Over-Mocking (Mocking Your Own API)
598
+
599
+ **Symptom**: All tests pass but the app is broken in production. Mocked responses drift from the real API. False confidence.
600
+
601
+ **Why it happens**: Developers mock every API call for speed and stability, including their own backend. The mocks become stale as the real API evolves.
602
+
603
+ **Fix**: Only mock external third-party services. Test your own API for real. Use `webServer` to run your backend during tests.
604
+
605
+ **TypeScript**
606
+ ```typescript
607
+ import { test, expect } from '@playwright/test';
608
+
609
+ // BAD — mocking your own API removes confidence
610
+ test('bad: mocks own API', async ({ page }) => {
611
+ await page.route('**/api/users/me', (route) =>
612
+ route.fulfill({
613
+ status: 200,
614
+ body: JSON.stringify({ name: 'Test User', role: 'admin' }),
615
+ })
616
+ );
617
+ await page.goto('/dashboard');
618
+ await expect(page.getByText('Test User')).toBeVisible(); // Passes even if API is broken
619
+ });
620
+
621
+ // GOOD — mock only external services, test your own API for real
622
+ test('good: real API, mocked externals', async ({ page }) => {
623
+ // Block third-party analytics and ads
624
+ await page.route(/google-analytics|intercom|segment/, (route) => route.abort());
625
+
626
+ // Stub a flaky external payment provider
627
+ await page.route('**/api.stripe.com/**', (route) =>
628
+ route.fulfill({
629
+ status: 200,
630
+ body: JSON.stringify({ status: 'succeeded' }),
631
+ })
632
+ );
633
+
634
+ // Test against the real app API
635
+ await page.goto('/dashboard');
636
+ await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
637
+ });
638
+ ```
639
+
640
+ **JavaScript**
641
+ ```javascript
642
+ const { test, expect } = require('@playwright/test');
643
+
644
+ // GOOD — mock only external services
645
+ test('good: real API, mocked externals', async ({ page }) => {
646
+ await page.route(/google-analytics|intercom|segment/, (route) => route.abort());
647
+
648
+ await page.route('**/api.stripe.com/**', (route) =>
649
+ route.fulfill({
650
+ status: 200,
651
+ body: JSON.stringify({ status: 'succeeded' }),
652
+ })
653
+ );
654
+
655
+ await page.goto('/dashboard');
656
+ await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
657
+ });
658
+ ```
659
+
660
+ **When mocking your own API is acceptable**: Testing specific error states (500, 503, network timeout) or edge cases that are hard to reproduce with a real backend.
661
+
662
+ ---
663
+
664
+ ## Pitfall 12: Not Using `test.describe` for Grouping
665
+
666
+ **Symptom**: Test files are flat lists of unrelated tests. Shared configuration (`test.use()`, `test.beforeEach()`) cannot be scoped. HTML reports are hard to navigate.
667
+
668
+ **Why it happens**: Developers write tests as a flat list, never grouping related tests together.
669
+
670
+ **Fix**: Group related tests with `test.describe`. Use it for scoping shared setup, configuration overrides, and logical organization.
671
+
672
+ **TypeScript**
673
+ ```typescript
674
+ import { test, expect } from '@playwright/test';
675
+
676
+ // BAD — flat list, no grouping, no shared context
677
+ test('admin can view users', async ({ page }) => { /* ... */ });
678
+ test('admin can delete user', async ({ page }) => { /* ... */ });
679
+ test('viewer cannot delete user', async ({ page }) => { /* ... */ });
680
+ test('viewer can view users', async ({ page }) => { /* ... */ });
681
+
682
+ // GOOD — grouped by role, scoped configuration
683
+ test.describe('admin users', () => {
684
+ test.use({ storageState: '.auth/admin.json' });
685
+
686
+ test.beforeEach(async ({ page }) => {
687
+ await page.goto('/admin/users');
688
+ });
689
+
690
+ test('can view user list', async ({ page }) => {
691
+ await expect(page.getByRole('table')).toBeVisible();
692
+ });
693
+
694
+ test('can delete a user', async ({ page }) => {
695
+ await page.getByRole('row').first().getByRole('button', { name: 'Delete' }).click();
696
+ await expect(page.getByRole('dialog')).toBeVisible();
697
+ });
698
+ });
699
+
700
+ test.describe('viewer users', () => {
701
+ test.use({ storageState: '.auth/viewer.json' });
702
+
703
+ test('can view user list', async ({ page }) => {
704
+ await page.goto('/admin/users');
705
+ await expect(page.getByRole('table')).toBeVisible();
706
+ });
707
+
708
+ test('cannot see delete button', async ({ page }) => {
709
+ await page.goto('/admin/users');
710
+ await expect(page.getByRole('button', { name: 'Delete' })).not.toBeVisible();
711
+ });
712
+ });
713
+ ```
714
+
715
+ **JavaScript**
716
+ ```javascript
717
+ const { test, expect } = require('@playwright/test');
718
+
719
+ test.describe('admin users', () => {
720
+ test.use({ storageState: '.auth/admin.json' });
721
+
722
+ test.beforeEach(async ({ page }) => {
723
+ await page.goto('/admin/users');
724
+ });
725
+
726
+ test('can view user list', async ({ page }) => {
727
+ await expect(page.getByRole('table')).toBeVisible();
728
+ });
729
+
730
+ test('can delete a user', async ({ page }) => {
731
+ await page.getByRole('row').first().getByRole('button', { name: 'Delete' }).click();
732
+ await expect(page.getByRole('dialog')).toBeVisible();
733
+ });
734
+ });
735
+ ```
736
+
737
+ ---
738
+
739
+ ## Pitfall 13: Using `beforeAll` for Per-Test Setup
740
+
741
+ **Symptom**: Tests that should be independent share state from `beforeAll`. One test modifies the state and subsequent tests fail.
742
+
743
+ **Why it happens**: Developers think `beforeAll` is "more efficient" because it runs once. But `beforeAll` creates worker-scoped state that all tests in the file share.
744
+
745
+ **Fix**: Use `beforeEach` for per-test setup, or use test-scoped fixtures for setup that needs teardown.
746
+
747
+ **TypeScript**
748
+ ```typescript
749
+ import { test, expect } from '@playwright/test';
750
+
751
+ // BAD — beforeAll creates one user for all tests; tests mutate shared state
752
+ test.beforeAll(async ({ request }) => {
753
+ // This user is shared across all tests in this file
754
+ await request.post('/api/users', { data: { email: 'shared@test.com', name: 'Original' } });
755
+ });
756
+
757
+ test('updates user name', async ({ page }) => {
758
+ await page.goto('/users/shared@test.com');
759
+ await page.getByLabel('Name').fill('Updated');
760
+ await page.getByRole('button', { name: 'Save' }).click();
761
+ // Now the shared user has name "Updated" — other tests see this
762
+ });
763
+
764
+ test('checks user name is Original', async ({ page }) => {
765
+ await page.goto('/users/shared@test.com');
766
+ // FAILS — previous test changed the name
767
+ await expect(page.getByLabel('Name')).toHaveValue('Original');
768
+ });
769
+
770
+ // GOOD — each test creates its own user
771
+ test.describe('user profile', () => {
772
+ test('updates user name', async ({ page, request }) => {
773
+ const email = `user-${Date.now()}@test.com`;
774
+ await request.post('/api/users', { data: { email, name: 'Original' } });
775
+
776
+ await page.goto(`/users/${email}`);
777
+ await page.getByLabel('Name').fill('Updated');
778
+ await page.getByRole('button', { name: 'Save' }).click();
779
+ await expect(page.getByLabel('Name')).toHaveValue('Updated');
780
+ });
781
+ });
782
+ ```
783
+
784
+ **JavaScript**
785
+ ```javascript
786
+ const { test, expect } = require('@playwright/test');
787
+
788
+ // GOOD — per-test data creation
789
+ test('updates user name', async ({ page, request }) => {
790
+ const email = `user-${Date.now()}@test.com`;
791
+ await request.post('/api/users', { data: { email, name: 'Original' } });
792
+
793
+ await page.goto(`/users/${email}`);
794
+ await page.getByLabel('Name').fill('Updated');
795
+ await page.getByRole('button', { name: 'Save' }).click();
796
+ await expect(page.getByLabel('Name')).toHaveValue('Updated');
797
+ });
798
+ ```
799
+
800
+ ---
801
+
802
+ ## Pitfall 14: Storing Test Data in Variables Shared Between Tests
803
+
804
+ **Symptom**: Test B depends on data created in Test A. Reordering or running tests in parallel breaks the suite.
805
+
806
+ **Why it happens**: Developers declare `let` variables at the module level and assign them in one test, expecting another test to read them.
807
+
808
+ **Fix**: Each test must create its own data. Use fixtures for shared setup logic.
809
+
810
+ **TypeScript**
811
+ ```typescript
812
+ import { test, expect } from '@playwright/test';
813
+
814
+ // BAD — test B depends on test A creating the product
815
+ let productId: string;
816
+
817
+ test('test A: creates product', async ({ request }) => {
818
+ const res = await request.post('/api/products', { data: { name: 'Widget' } });
819
+ productId = (await res.json()).id;
820
+ });
821
+
822
+ test('test B: edits product', async ({ page }) => {
823
+ await page.goto(`/products/${productId}/edit`); // undefined if test A didn't run first
824
+ });
825
+
826
+ // GOOD — each test is self-contained
827
+ test('creates and edits product', async ({ page, request }) => {
828
+ const res = await request.post('/api/products', { data: { name: `Widget-${Date.now()}` } });
829
+ const { id } = await res.json();
830
+
831
+ await page.goto(`/products/${id}/edit`);
832
+ await page.getByLabel('Name').fill('Updated Widget');
833
+ await page.getByRole('button', { name: 'Save' }).click();
834
+ await expect(page.getByText('Updated Widget')).toBeVisible();
835
+
836
+ // Cleanup
837
+ await request.delete(`/api/products/${id}`);
838
+ });
839
+ ```
840
+
841
+ **JavaScript**
842
+ ```javascript
843
+ const { test, expect } = require('@playwright/test');
844
+
845
+ // GOOD — self-contained test
846
+ test('creates and edits product', async ({ page, request }) => {
847
+ const res = await request.post('/api/products', { data: { name: `Widget-${Date.now()}` } });
848
+ const { id } = await res.json();
849
+
850
+ await page.goto(`/products/${id}/edit`);
851
+ await page.getByLabel('Name').fill('Updated Widget');
852
+ await page.getByRole('button', { name: 'Save' }).click();
853
+ await expect(page.getByText('Updated Widget')).toBeVisible();
854
+
855
+ await request.delete(`/api/products/${id}`);
856
+ });
857
+ ```
858
+
859
+ ---
860
+
861
+ ## Pitfall 15: Deep Nesting of `test.describe` Blocks
862
+
863
+ **Symptom**: Test structure is 3+ levels deep. Hard to read. `beforeEach` hooks from outer blocks are invisible at the test level. HTML reports are cluttered.
864
+
865
+ **Why it happens**: Developers organize tests like they organize code -- deeply nested hierarchies. But tests should be flat and scannable.
866
+
867
+ **Fix**: Limit nesting to 2 levels maximum. Use separate files instead of deep nesting.
868
+
869
+ **TypeScript**
870
+ ```typescript
871
+ import { test, expect } from '@playwright/test';
872
+
873
+ // BAD — 4 levels deep, impossible to follow
874
+ test.describe('admin', () => {
875
+ test.describe('settings', () => {
876
+ test.describe('security', () => {
877
+ test.describe('two-factor auth', () => {
878
+ test('enables TOTP', async ({ page }) => {
879
+ // Which beforeEach hooks ran before this? Good luck figuring out.
880
+ });
881
+ });
882
+ });
883
+ });
884
+ });
885
+
886
+ // GOOD — max 2 levels, clear and flat
887
+ test.describe('admin security settings', () => {
888
+ test.beforeEach(async ({ page }) => {
889
+ await page.goto('/admin/settings/security');
890
+ });
891
+
892
+ test('enables two-factor auth via TOTP', async ({ page }) => {
893
+ await page.getByRole('button', { name: 'Enable 2FA' }).click();
894
+ await expect(page.getByText('Scan QR code')).toBeVisible();
895
+ });
896
+
897
+ test('disables two-factor auth', async ({ page }) => {
898
+ await page.getByRole('button', { name: 'Disable 2FA' }).click();
899
+ await expect(page.getByRole('dialog')).toBeVisible();
900
+ });
901
+ });
902
+ ```
903
+
904
+ **JavaScript**
905
+ ```javascript
906
+ const { test, expect } = require('@playwright/test');
907
+
908
+ // GOOD — flat structure, max 2 levels
909
+ test.describe('admin security settings', () => {
910
+ test.beforeEach(async ({ page }) => {
911
+ await page.goto('/admin/settings/security');
912
+ });
913
+
914
+ test('enables two-factor auth via TOTP', async ({ page }) => {
915
+ await page.getByRole('button', { name: 'Enable 2FA' }).click();
916
+ await expect(page.getByText('Scan QR code')).toBeVisible();
917
+ });
918
+ });
919
+ ```
920
+
921
+ If you need to organize many tests, split into separate files: `security-2fa.spec.ts`, `security-passwords.spec.ts`, `security-sessions.spec.ts`.
922
+
923
+ ---
924
+
925
+ ## Pitfall 16: Not Configuring Retries Differently for Local vs CI
926
+
927
+ **Symptom**: Developers use retries locally (hiding bugs during development) or have no retries in CI (failing on intermittent issues that are not their fault).
928
+
929
+ **Why it happens**: A single `retries` value is set without considering the environment.
930
+
931
+ **Fix**: Zero retries locally (fail fast), 1-2 retries in CI (catch infrastructure blips).
932
+
933
+ **TypeScript**
934
+ ```typescript
935
+ // playwright.config.ts
936
+ import { defineConfig } from '@playwright/test';
937
+
938
+ export default defineConfig({
939
+ // GOOD — different retry strategy per environment
940
+ retries: process.env.CI ? 2 : 0,
941
+
942
+ use: {
943
+ // Only capture traces on retry — saves CI time and storage
944
+ trace: 'on-first-retry',
945
+ },
946
+ });
947
+ ```
948
+
949
+ **JavaScript**
950
+ ```javascript
951
+ // playwright.config.js
952
+ const { defineConfig } = require('@playwright/test');
953
+
954
+ module.exports = defineConfig({
955
+ retries: process.env.CI ? 2 : 0,
956
+ use: {
957
+ trace: 'on-first-retry',
958
+ },
959
+ });
960
+ ```
961
+
962
+ **Important**: A test that consistently needs retries to pass is not "passing" -- it is flaky. Track retry counts and fix the root cause.
963
+
964
+ ---
965
+
966
+ ## Pitfall 17: Running All Browsers in Every CI Run
967
+
968
+ **Symptom**: CI takes 3x longer than necessary. Most bugs are caught by a single browser engine.
969
+
970
+ **Why it happens**: Developers enable Chromium + Firefox + WebKit in the config and never reconsider.
971
+
972
+ **Fix**: Run Chromium on every PR. Run all browsers in a nightly or pre-release job.
973
+
974
+ **TypeScript**
975
+ ```typescript
976
+ // playwright.config.ts
977
+ import { defineConfig, devices } from '@playwright/test';
978
+
979
+ const allBrowsers = [
980
+ { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
981
+ { name: 'firefox', use: { ...devices['Desktop Firefox'] } },
982
+ { name: 'webkit', use: { ...devices['Desktop Safari'] } },
983
+ ];
984
+
985
+ const chromeOnly = [
986
+ { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
987
+ ];
988
+
989
+ export default defineConfig({
990
+ // PR runs: Chromium only. Nightly: all browsers.
991
+ projects: process.env.ALL_BROWSERS ? allBrowsers : chromeOnly,
992
+ });
993
+ ```
994
+
995
+ **JavaScript**
996
+ ```javascript
997
+ // playwright.config.js
998
+ const { defineConfig, devices } = require('@playwright/test');
999
+
1000
+ const allBrowsers = [
1001
+ { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
1002
+ { name: 'firefox', use: { ...devices['Desktop Firefox'] } },
1003
+ { name: 'webkit', use: { ...devices['Desktop Safari'] } },
1004
+ ];
1005
+
1006
+ const chromeOnly = [
1007
+ { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
1008
+ ];
1009
+
1010
+ module.exports = defineConfig({
1011
+ projects: process.env.ALL_BROWSERS ? allBrowsers : chromeOnly,
1012
+ });
1013
+ ```
1014
+
1015
+ ```yaml
1016
+ # .github/workflows/tests.yml
1017
+ jobs:
1018
+ pr-tests:
1019
+ # Fast: Chromium only
1020
+ runs-on: ubuntu-latest
1021
+ steps:
1022
+ - run: npx playwright test
1023
+
1024
+ nightly-full:
1025
+ # Comprehensive: all browsers
1026
+ schedule:
1027
+ - cron: '0 3 * * *'
1028
+ steps:
1029
+ - run: ALL_BROWSERS=1 npx playwright test
1030
+ ```
1031
+
1032
+ ---
1033
+
1034
+ ## Pitfall 18: Using `page.evaluate()` for Things Locators Can Do
1035
+
1036
+ **Symptom**: Tests are verbose and fragile. Direct DOM manipulation bypasses Playwright's auto-waiting and actionability checks.
1037
+
1038
+ **Why it happens**: Developers with vanilla JS or Puppeteer backgrounds use `evaluate()` for everything because it feels familiar.
1039
+
1040
+ **Fix**: Use locator methods for all DOM interactions. Reserve `page.evaluate()` for things locators genuinely cannot do (reading computed styles, calling app-specific JS APIs, setting up test hooks).
1041
+
1042
+ **TypeScript**
1043
+ ```typescript
1044
+ import { test, expect } from '@playwright/test';
1045
+
1046
+ // BAD — using evaluate for things locators handle better
1047
+ test('bad: evaluate for DOM interaction', async ({ page }) => {
1048
+ await page.goto('/settings');
1049
+
1050
+ // Getting text
1051
+ const text = await page.evaluate(() =>
1052
+ document.querySelector('[data-testid="username"]')?.textContent
1053
+ );
1054
+ expect(text).toBe('John');
1055
+
1056
+ // Clicking
1057
+ await page.evaluate(() => {
1058
+ (document.querySelector('button.save-btn') as HTMLButtonElement)?.click();
1059
+ });
1060
+
1061
+ // Checking visibility
1062
+ const visible = await page.evaluate(() => {
1063
+ const el = document.querySelector('.success-message');
1064
+ return el ? window.getComputedStyle(el).display !== 'none' : false;
1065
+ });
1066
+ expect(visible).toBe(true);
1067
+ });
1068
+
1069
+ // GOOD — locators with auto-waiting and retry
1070
+ test('good: locator methods', async ({ page }) => {
1071
+ await page.goto('/settings');
1072
+
1073
+ await expect(page.getByTestId('username')).toHaveText('John');
1074
+ await page.getByRole('button', { name: 'Save' }).click();
1075
+ await expect(page.getByText('Settings saved')).toBeVisible();
1076
+ });
1077
+ ```
1078
+
1079
+ **JavaScript**
1080
+ ```javascript
1081
+ const { test, expect } = require('@playwright/test');
1082
+
1083
+ // BAD
1084
+ test('bad: evaluate for DOM interaction', async ({ page }) => {
1085
+ const text = await page.evaluate(() =>
1086
+ document.querySelector('[data-testid="username"]')?.textContent
1087
+ );
1088
+ expect(text).toBe('John');
1089
+ });
1090
+
1091
+ // GOOD
1092
+ test('good: locator methods', async ({ page }) => {
1093
+ await expect(page.getByTestId('username')).toHaveText('John');
1094
+ });
1095
+ ```
1096
+
1097
+ **When `evaluate()` is appropriate**: Reading `window.__APP_STATE__`, calling test-specific setup functions exposed by the app, manipulating `localStorage`/`sessionStorage`, or reading computed styles that have no locator equivalent.
1098
+
1099
+ ---
1100
+
1101
+ ## Pitfall 19: Not Using `test.step()` for Complex Flows
1102
+
1103
+ **Symptom**: Long tests are hard to debug. When they fail, the trace shows 50+ actions with no logical grouping. The HTML report provides no structure.
1104
+
1105
+ **Why it happens**: Developers write tests as a linear sequence of actions without labeling phases.
1106
+
1107
+ **Fix**: Wrap logical phases in `test.step()`. Steps appear in traces, reports, and error messages.
1108
+
1109
+ **TypeScript**
1110
+ ```typescript
1111
+ import { test, expect } from '@playwright/test';
1112
+
1113
+ // BAD — 30 lines of actions with no structure
1114
+ test('bad: flat checkout flow', async ({ page }) => {
1115
+ await page.goto('/products');
1116
+ await page.getByRole('button', { name: 'Add Widget' }).click();
1117
+ await page.getByRole('link', { name: 'Cart' }).click();
1118
+ await page.getByRole('button', { name: 'Checkout' }).click();
1119
+ await page.getByLabel('Email').fill('user@test.com');
1120
+ await page.getByLabel('Address').fill('123 Test St');
1121
+ await page.getByRole('button', { name: 'Continue' }).click();
1122
+ await page.getByLabel('Card number').fill('4242424242424242');
1123
+ await page.getByRole('button', { name: 'Pay' }).click();
1124
+ await expect(page.getByText('Order confirmed')).toBeVisible();
1125
+ });
1126
+
1127
+ // GOOD — logical steps, clear in traces and reports
1128
+ test('good: structured checkout flow', async ({ page }) => {
1129
+ await test.step('add item to cart', async () => {
1130
+ await page.goto('/products');
1131
+ await page.getByRole('button', { name: 'Add Widget' }).click();
1132
+ await expect(page.getByTestId('cart-count')).toHaveText('1');
1133
+ });
1134
+
1135
+ await test.step('proceed to checkout', async () => {
1136
+ await page.getByRole('link', { name: 'Cart' }).click();
1137
+ await page.getByRole('button', { name: 'Checkout' }).click();
1138
+ await expect(page).toHaveURL(/.*checkout/);
1139
+ });
1140
+
1141
+ await test.step('fill shipping details', async () => {
1142
+ await page.getByLabel('Email').fill('user@test.com');
1143
+ await page.getByLabel('Address').fill('123 Test St');
1144
+ await page.getByRole('button', { name: 'Continue' }).click();
1145
+ });
1146
+
1147
+ await test.step('complete payment', async () => {
1148
+ await page.getByLabel('Card number').fill('4242424242424242');
1149
+ await page.getByRole('button', { name: 'Pay' }).click();
1150
+ await expect(page.getByText('Order confirmed')).toBeVisible();
1151
+ });
1152
+ });
1153
+ ```
1154
+
1155
+ **JavaScript**
1156
+ ```javascript
1157
+ const { test, expect } = require('@playwright/test');
1158
+
1159
+ test('good: structured checkout flow', async ({ page }) => {
1160
+ await test.step('add item to cart', async () => {
1161
+ await page.goto('/products');
1162
+ await page.getByRole('button', { name: 'Add Widget' }).click();
1163
+ await expect(page.getByTestId('cart-count')).toHaveText('1');
1164
+ });
1165
+
1166
+ await test.step('proceed to checkout', async () => {
1167
+ await page.getByRole('link', { name: 'Cart' }).click();
1168
+ await page.getByRole('button', { name: 'Checkout' }).click();
1169
+ });
1170
+
1171
+ await test.step('fill shipping details', async () => {
1172
+ await page.getByLabel('Email').fill('user@test.com');
1173
+ await page.getByLabel('Address').fill('123 Test St');
1174
+ await page.getByRole('button', { name: 'Continue' }).click();
1175
+ });
1176
+
1177
+ await test.step('complete payment', async () => {
1178
+ await page.getByLabel('Card number').fill('4242424242424242');
1179
+ await page.getByRole('button', { name: 'Pay' }).click();
1180
+ await expect(page.getByText('Order confirmed')).toBeVisible();
1181
+ });
1182
+ });
1183
+ ```
1184
+
1185
+ When a step fails, the error message includes the step name: `Error in step "complete payment": ...`. This is significantly more helpful than line numbers alone.
1186
+
1187
+ ---
1188
+
1189
+ ## Pitfall 20: Catching Errors from Assertions (try/catch Around expect)
1190
+
1191
+ **Symptom**: Tests pass when they should fail. Assertion errors are silently swallowed. Real bugs go undetected.
1192
+
1193
+ **Why it happens**: Developers wrap assertions in try/catch to handle "optional" elements or to implement conditional logic. This defeats the purpose of assertions.
1194
+
1195
+ **Fix**: Use `expect.soft()` for non-critical checks, `.not` assertions for absent elements, or restructure the test to avoid conditional logic.
1196
+
1197
+ **TypeScript**
1198
+ ```typescript
1199
+ import { test, expect } from '@playwright/test';
1200
+
1201
+ // BAD — swallows real failures
1202
+ test('bad: try/catch around assertion', async ({ page }) => {
1203
+ await page.goto('/dashboard');
1204
+
1205
+ try {
1206
+ await expect(page.getByRole('alert')).toBeVisible({ timeout: 2_000 });
1207
+ // Dismiss the alert if it exists
1208
+ await page.getByRole('button', { name: 'Dismiss' }).click();
1209
+ } catch {
1210
+ // Alert didn't appear — that's fine
1211
+ }
1212
+
1213
+ await expect(page.getByRole('heading')).toHaveText('Dashboard');
1214
+ });
1215
+
1216
+ // GOOD — check count to determine if element exists, no try/catch needed
1217
+ test('good: conditional without try/catch', async ({ page }) => {
1218
+ await page.goto('/dashboard');
1219
+
1220
+ // If an alert banner is present, dismiss it
1221
+ const alertCount = await page.getByRole('alert').count();
1222
+ if (alertCount > 0) {
1223
+ await page.getByRole('button', { name: 'Dismiss' }).click();
1224
+ await expect(page.getByRole('alert')).not.toBeVisible();
1225
+ }
1226
+
1227
+ await expect(page.getByRole('heading')).toHaveText('Dashboard');
1228
+ });
1229
+
1230
+ // GOOD — use soft assertions for non-critical checks
1231
+ test('good: soft assertions for nice-to-have checks', async ({ page }) => {
1232
+ await page.goto('/dashboard');
1233
+
1234
+ // These are informational — test continues even if they fail
1235
+ await expect.soft(page.getByTestId('revenue')).toContainText('$');
1236
+ await expect.soft(page.getByTestId('users')).toContainText('active');
1237
+
1238
+ // This is the actual assertion — test fails if this fails
1239
+ await expect(page.getByRole('heading')).toHaveText('Dashboard');
1240
+ });
1241
+
1242
+ // GOOD — assert element is NOT present (no try/catch needed)
1243
+ test('good: assert absence directly', async ({ page }) => {
1244
+ await page.goto('/dashboard');
1245
+
1246
+ // This auto-retries until the error disappears (or times out)
1247
+ await expect(page.getByRole('alert')).not.toBeVisible();
1248
+
1249
+ await expect(page.getByRole('heading')).toHaveText('Dashboard');
1250
+ });
1251
+ ```
1252
+
1253
+ **JavaScript**
1254
+ ```javascript
1255
+ const { test, expect } = require('@playwright/test');
1256
+
1257
+ // GOOD — conditional without try/catch
1258
+ test('good: conditional without try/catch', async ({ page }) => {
1259
+ await page.goto('/dashboard');
1260
+
1261
+ const alertCount = await page.getByRole('alert').count();
1262
+ if (alertCount > 0) {
1263
+ await page.getByRole('button', { name: 'Dismiss' }).click();
1264
+ await expect(page.getByRole('alert')).not.toBeVisible();
1265
+ }
1266
+
1267
+ await expect(page.getByRole('heading')).toHaveText('Dashboard');
1268
+ });
1269
+
1270
+ // GOOD — soft assertions for non-critical checks
1271
+ test('good: soft assertions for nice-to-have checks', async ({ page }) => {
1272
+ await page.goto('/dashboard');
1273
+
1274
+ await expect.soft(page.getByTestId('revenue')).toContainText('$');
1275
+ await expect.soft(page.getByTestId('users')).toContainText('active');
1276
+
1277
+ await expect(page.getByRole('heading')).toHaveText('Dashboard');
1278
+ });
1279
+ ```
1280
+
1281
+ **Rule**: If you find yourself writing `try/catch` around an `expect()`, the test design is wrong. Rethink the assertion.
1282
+
1283
+ ---
1284
+
1285
+ ## Quick Lookup Table
1286
+
1287
+ | # | Pitfall | One-Line Fix |
1288
+ |---|---|---|
1289
+ | 1 | `waitForTimeout()` | Replace with `expect(locator).toBeVisible()` |
1290
+ | 2 | Missing `await` | Add `await` + enable `no-floating-promises` ESLint rule |
1291
+ | 3 | CSS selectors | Use `getByRole()`, `getByLabel()`, `getByTestId()` |
1292
+ | 4 | `isVisible()` check | Use `expect(locator).toBeVisible()` |
1293
+ | 5 | Shared mutable state | Use test-scoped fixtures with unique data |
1294
+ | 6 | Hardcoded URLs | Set `baseURL` in config, use relative paths |
1295
+ | 7 | `page.$()` / `page.$$()` | Use `page.locator()` or `page.getByRole()` |
1296
+ | 8 | Navigation not handled | Add `page.waitForURL()` after form submissions |
1297
+ | 9 | No `webServer` in CI | Add `webServer` config with `reuseExistingServer: !process.env.CI` |
1298
+ | 10 | `innerHTML` / `textContent` | Use `expect(locator).toHaveText()` |
1299
+ | 11 | Over-mocking own API | Mock only external services |
1300
+ | 12 | No `test.describe` | Group related tests, scope `beforeEach` and `test.use()` |
1301
+ | 13 | `beforeAll` for per-test setup | Use `beforeEach` or test-scoped fixtures |
1302
+ | 14 | Module-level test data variables | Create data inside each test or via fixtures |
1303
+ | 15 | Deep `describe` nesting (3+) | Max 2 levels; split into separate files |
1304
+ | 16 | Same retries everywhere | `retries: process.env.CI ? 2 : 0` |
1305
+ | 17 | All browsers on every PR | Chromium on PRs, full matrix nightly |
1306
+ | 18 | `page.evaluate()` overuse | Use locator methods; reserve `evaluate` for JS APIs |
1307
+ | 19 | No `test.step()` | Wrap logical phases in named steps |
1308
+ | 20 | `try/catch` around `expect` | Use `expect.soft()`, `.not`, or `locator.count()` |
1309
+
1310
+ ## Related
1311
+
1312
+ - [core/locators.md](locators.md) -- locator strategy hierarchy (Pitfalls 3, 7, 18)
1313
+ - [core/assertions-and-waiting.md](assertions-and-waiting.md) -- web-first assertions (Pitfalls 1, 4, 10, 20)
1314
+ - [core/fixtures-and-hooks.md](fixtures-and-hooks.md) -- fixtures vs hooks (Pitfalls 5, 13, 14)
1315
+ - [core/configuration.md](configuration.md) -- baseURL, webServer, retries (Pitfalls 6, 9, 16, 17)
1316
+ - [core/test-organization.md](test-organization.md) -- describe blocks, nesting, tags (Pitfalls 12, 15)
1317
+ - [core/flaky-tests.md](flaky-tests.md) -- diagnosing and fixing flaky tests (Pitfalls 1, 2, 5)
1318
+ - [core/debugging.md](debugging.md) -- traces, UI mode, page.pause()