@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,1623 @@
1
+ # API Testing
2
+
3
+ > **When to use**: Testing REST or GraphQL APIs directly — validating endpoints, seeding test data, or verifying backend behavior without browser overhead.
4
+ > **Prerequisites**: [core/configuration.md](configuration.md) for `baseURL` setup, [core/fixtures-and-hooks.md](fixtures-and-hooks.md) for custom fixture patterns.
5
+
6
+ ## Quick Reference
7
+
8
+ ```typescript
9
+ // Standalone API test — no browser launched
10
+ import { test, expect } from '@playwright/test';
11
+
12
+ test('GET /api/users returns user list', async ({ request }) => {
13
+ const response = await request.get('/api/users');
14
+ expect(response.status()).toBe(200);
15
+ expect(response.headers()['content-type']).toContain('application/json');
16
+ const body = await response.json();
17
+ expect(body.users).toHaveLength(3);
18
+ expect(body.users[0]).toMatchObject({ id: expect.any(Number), email: expect.any(String) });
19
+ });
20
+ ```
21
+
22
+ ## Patterns
23
+
24
+ ### APIRequestContext Basics
25
+
26
+ **Use when**: Making HTTP requests in any test — GET, POST, PUT, PATCH, DELETE with headers, query params, and request bodies.
27
+ **Avoid when**: You need to test browser-rendered responses (redirects, cookies set via `Set-Cookie` with `HttpOnly`). Use a browser test instead.
28
+
29
+ The `request` fixture provides a pre-configured `APIRequestContext` that inherits `baseURL` from your config. No browser is launched.
30
+
31
+ **TypeScript**
32
+ ```typescript
33
+ import { test, expect } from '@playwright/test';
34
+
35
+ test('CRUD operations via API', async ({ request }) => {
36
+ // GET with query parameters
37
+ const listResponse = await request.get('/api/users', {
38
+ params: { page: 1, limit: 10, role: 'admin' },
39
+ });
40
+ expect(listResponse.ok()).toBeTruthy();
41
+
42
+ // POST with JSON body
43
+ const createResponse = await request.post('/api/users', {
44
+ data: {
45
+ name: 'Jane Doe',
46
+ email: 'jane@example.com',
47
+ role: 'editor',
48
+ },
49
+ });
50
+ expect(createResponse.status()).toBe(201);
51
+ const created = await createResponse.json();
52
+
53
+ // PUT — full replacement
54
+ const updateResponse = await request.put(`/api/users/${created.id}`, {
55
+ data: {
56
+ name: 'Jane Smith',
57
+ email: 'jane.smith@example.com',
58
+ role: 'editor',
59
+ },
60
+ });
61
+ expect(updateResponse.ok()).toBeTruthy();
62
+
63
+ // PATCH — partial update
64
+ const patchResponse = await request.patch(`/api/users/${created.id}`, {
65
+ data: { role: 'admin' },
66
+ });
67
+ expect(patchResponse.ok()).toBeTruthy();
68
+ const patched = await patchResponse.json();
69
+ expect(patched.role).toBe('admin');
70
+
71
+ // DELETE
72
+ const deleteResponse = await request.delete(`/api/users/${created.id}`);
73
+ expect(deleteResponse.status()).toBe(204);
74
+
75
+ // Verify deletion
76
+ const getDeleted = await request.get(`/api/users/${created.id}`);
77
+ expect(getDeleted.status()).toBe(404);
78
+ });
79
+
80
+ test('custom headers and auth tokens', async ({ request }) => {
81
+ const response = await request.get('/api/protected/resource', {
82
+ headers: {
83
+ 'Authorization': 'Bearer eyJhbGciOiJIUzI1NiIs...',
84
+ 'X-Request-ID': 'test-correlation-id-123',
85
+ 'Accept': 'application/json',
86
+ },
87
+ });
88
+ expect(response.ok()).toBeTruthy();
89
+ });
90
+
91
+ test('form-urlencoded body', async ({ request }) => {
92
+ const response = await request.post('/api/oauth/token', {
93
+ form: {
94
+ grant_type: 'client_credentials',
95
+ client_id: 'my-app',
96
+ client_secret: 'secret-value',
97
+ },
98
+ });
99
+ expect(response.ok()).toBeTruthy();
100
+ const token = await response.json();
101
+ expect(token).toHaveProperty('access_token');
102
+ });
103
+ ```
104
+
105
+ **JavaScript**
106
+ ```javascript
107
+ const { test, expect } = require('@playwright/test');
108
+
109
+ test('CRUD operations via API', async ({ request }) => {
110
+ const listResponse = await request.get('/api/users', {
111
+ params: { page: 1, limit: 10, role: 'admin' },
112
+ });
113
+ expect(listResponse.ok()).toBeTruthy();
114
+
115
+ const createResponse = await request.post('/api/users', {
116
+ data: {
117
+ name: 'Jane Doe',
118
+ email: 'jane@example.com',
119
+ role: 'editor',
120
+ },
121
+ });
122
+ expect(createResponse.status()).toBe(201);
123
+ const created = await createResponse.json();
124
+
125
+ const updateResponse = await request.put(`/api/users/${created.id}`, {
126
+ data: {
127
+ name: 'Jane Smith',
128
+ email: 'jane.smith@example.com',
129
+ role: 'editor',
130
+ },
131
+ });
132
+ expect(updateResponse.ok()).toBeTruthy();
133
+
134
+ const patchResponse = await request.patch(`/api/users/${created.id}`, {
135
+ data: { role: 'admin' },
136
+ });
137
+ expect(patchResponse.ok()).toBeTruthy();
138
+
139
+ const deleteResponse = await request.delete(`/api/users/${created.id}`);
140
+ expect(deleteResponse.status()).toBe(204);
141
+ });
142
+
143
+ test('form-urlencoded body', async ({ request }) => {
144
+ const response = await request.post('/api/oauth/token', {
145
+ form: {
146
+ grant_type: 'client_credentials',
147
+ client_id: 'my-app',
148
+ client_secret: 'secret-value',
149
+ },
150
+ });
151
+ expect(response.ok()).toBeTruthy();
152
+ const token = await response.json();
153
+ expect(token).toHaveProperty('access_token');
154
+ });
155
+ ```
156
+
157
+ ### API Test Structure
158
+
159
+ **Use when**: Writing dedicated API test suites that do not need a browser.
160
+ **Avoid when**: You need to assert on UI state after an API call — use a combined test with `page` and `request` fixtures.
161
+
162
+ Structure API tests in their own directory with descriptive `describe` blocks per resource or domain.
163
+
164
+ **TypeScript**
165
+ ```typescript
166
+ // tests/api/users.spec.ts
167
+ import { test, expect } from '@playwright/test';
168
+
169
+ // No browser is launched — these tests use only the request fixture
170
+ test.describe('Users API', () => {
171
+ test.describe('GET /api/users', () => {
172
+ test('returns paginated user list', async ({ request }) => {
173
+ const response = await request.get('/api/users', {
174
+ params: { page: 1, limit: 5 },
175
+ });
176
+ expect(response.status()).toBe(200);
177
+ const body = await response.json();
178
+ expect(body.users.length).toBeLessThanOrEqual(5);
179
+ expect(body.pagination).toMatchObject({
180
+ page: 1,
181
+ limit: 5,
182
+ total: expect.any(Number),
183
+ });
184
+ });
185
+
186
+ test('filters by role', async ({ request }) => {
187
+ const response = await request.get('/api/users', {
188
+ params: { role: 'admin' },
189
+ });
190
+ const body = await response.json();
191
+ for (const user of body.users) {
192
+ expect(user.role).toBe('admin');
193
+ }
194
+ });
195
+ });
196
+
197
+ test.describe('POST /api/users', () => {
198
+ test('creates a new user with valid data', async ({ request }) => {
199
+ const response = await request.post('/api/users', {
200
+ data: { name: 'Test User', email: `test-${Date.now()}@example.com` },
201
+ });
202
+ expect(response.status()).toBe(201);
203
+ const user = await response.json();
204
+ expect(user).toMatchObject({
205
+ id: expect.any(Number),
206
+ name: 'Test User',
207
+ });
208
+ });
209
+
210
+ test('rejects duplicate email', async ({ request }) => {
211
+ const email = `dupe-${Date.now()}@example.com`;
212
+ await request.post('/api/users', { data: { name: 'First', email } });
213
+
214
+ const response = await request.post('/api/users', {
215
+ data: { name: 'Second', email },
216
+ });
217
+ expect(response.status()).toBe(409);
218
+ const body = await response.json();
219
+ expect(body.error).toContain('already exists');
220
+ });
221
+ });
222
+ });
223
+ ```
224
+
225
+ **JavaScript**
226
+ ```javascript
227
+ // tests/api/users.spec.js
228
+ const { test, expect } = require('@playwright/test');
229
+
230
+ test.describe('Users API', () => {
231
+ test.describe('GET /api/users', () => {
232
+ test('returns paginated user list', async ({ request }) => {
233
+ const response = await request.get('/api/users', {
234
+ params: { page: 1, limit: 5 },
235
+ });
236
+ expect(response.status()).toBe(200);
237
+ const body = await response.json();
238
+ expect(body.users.length).toBeLessThanOrEqual(5);
239
+ expect(body.pagination).toMatchObject({
240
+ page: 1,
241
+ limit: 5,
242
+ total: expect.any(Number),
243
+ });
244
+ });
245
+ });
246
+
247
+ test.describe('POST /api/users', () => {
248
+ test('creates a new user with valid data', async ({ request }) => {
249
+ const response = await request.post('/api/users', {
250
+ data: { name: 'Test User', email: `test-${Date.now()}@example.com` },
251
+ });
252
+ expect(response.status()).toBe(201);
253
+ const user = await response.json();
254
+ expect(user).toMatchObject({
255
+ id: expect.any(Number),
256
+ name: 'Test User',
257
+ });
258
+ });
259
+ });
260
+ });
261
+ ```
262
+
263
+ **Config tip**: Use a dedicated project for API tests to avoid launching browsers.
264
+
265
+ ```typescript
266
+ // playwright.config.ts — API project runs without a browser
267
+ import { defineConfig } from '@playwright/test';
268
+
269
+ export default defineConfig({
270
+ projects: [
271
+ {
272
+ name: 'api',
273
+ testDir: './tests/api',
274
+ use: {
275
+ baseURL: 'https://api.example.com',
276
+ extraHTTPHeaders: {
277
+ 'Accept': 'application/json',
278
+ },
279
+ },
280
+ },
281
+ {
282
+ name: 'e2e',
283
+ testDir: './tests/e2e',
284
+ use: {
285
+ baseURL: 'https://app.example.com',
286
+ browserName: 'chromium',
287
+ },
288
+ },
289
+ ],
290
+ });
291
+ ```
292
+
293
+ ### Request Fixtures
294
+
295
+ **Use when**: Multiple tests need an authenticated API client, or you want to share request configuration (headers, base URL, auth tokens) across a test suite.
296
+ **Avoid when**: A single test makes one-off API calls. Use the built-in `request` fixture directly.
297
+
298
+ **TypeScript**
299
+ ```typescript
300
+ // fixtures/api-fixtures.ts
301
+ import { test as base, expect, APIRequestContext } from '@playwright/test';
302
+
303
+ type ApiFixtures = {
304
+ authenticatedRequest: APIRequestContext;
305
+ adminRequest: APIRequestContext;
306
+ };
307
+
308
+ export const test = base.extend<ApiFixtures>({
309
+ authenticatedRequest: async ({ playwright }, use) => {
310
+ // Create a fresh context with auth headers
311
+ const context = await playwright.request.newContext({
312
+ baseURL: 'https://api.example.com',
313
+ extraHTTPHeaders: {
314
+ 'Authorization': `Bearer ${process.env.API_TOKEN}`,
315
+ 'Accept': 'application/json',
316
+ },
317
+ });
318
+ await use(context);
319
+ await context.dispose();
320
+ },
321
+
322
+ adminRequest: async ({ playwright }, use) => {
323
+ // Login via API to get a token, then create authenticated context
324
+ const loginContext = await playwright.request.newContext({
325
+ baseURL: 'https://api.example.com',
326
+ });
327
+ const loginResponse = await loginContext.post('/api/auth/login', {
328
+ data: {
329
+ email: process.env.ADMIN_EMAIL,
330
+ password: process.env.ADMIN_PASSWORD,
331
+ },
332
+ });
333
+ expect(loginResponse.ok()).toBeTruthy();
334
+ const { token } = await loginResponse.json();
335
+ await loginContext.dispose();
336
+
337
+ const context = await playwright.request.newContext({
338
+ baseURL: 'https://api.example.com',
339
+ extraHTTPHeaders: {
340
+ 'Authorization': `Bearer ${token}`,
341
+ 'Accept': 'application/json',
342
+ },
343
+ });
344
+ await use(context);
345
+ await context.dispose();
346
+ },
347
+ });
348
+
349
+ export { expect };
350
+ ```
351
+
352
+ ```typescript
353
+ // tests/api/admin.spec.ts
354
+ import { test, expect } from '../../fixtures/api-fixtures';
355
+
356
+ test('admin can list all users', async ({ adminRequest }) => {
357
+ const response = await adminRequest.get('/api/admin/users');
358
+ expect(response.status()).toBe(200);
359
+ const body = await response.json();
360
+ expect(body.users.length).toBeGreaterThan(0);
361
+ });
362
+
363
+ test('admin can delete a user', async ({ adminRequest }) => {
364
+ // Create then delete
365
+ const createResp = await adminRequest.post('/api/users', {
366
+ data: { name: 'To Delete', email: `del-${Date.now()}@example.com` },
367
+ });
368
+ const { id } = await createResp.json();
369
+
370
+ const deleteResp = await adminRequest.delete(`/api/users/${id}`);
371
+ expect(deleteResp.status()).toBe(204);
372
+ });
373
+ ```
374
+
375
+ **JavaScript**
376
+ ```javascript
377
+ // fixtures/api-fixtures.js
378
+ const { test: base, expect } = require('@playwright/test');
379
+
380
+ const test = base.extend({
381
+ authenticatedRequest: async ({ playwright }, use) => {
382
+ const context = await playwright.request.newContext({
383
+ baseURL: 'https://api.example.com',
384
+ extraHTTPHeaders: {
385
+ 'Authorization': `Bearer ${process.env.API_TOKEN}`,
386
+ 'Accept': 'application/json',
387
+ },
388
+ });
389
+ await use(context);
390
+ await context.dispose();
391
+ },
392
+
393
+ adminRequest: async ({ playwright }, use) => {
394
+ const loginContext = await playwright.request.newContext({
395
+ baseURL: 'https://api.example.com',
396
+ });
397
+ const loginResponse = await loginContext.post('/api/auth/login', {
398
+ data: {
399
+ email: process.env.ADMIN_EMAIL,
400
+ password: process.env.ADMIN_PASSWORD,
401
+ },
402
+ });
403
+ expect(loginResponse.ok()).toBeTruthy();
404
+ const { token } = await loginResponse.json();
405
+ await loginContext.dispose();
406
+
407
+ const context = await playwright.request.newContext({
408
+ baseURL: 'https://api.example.com',
409
+ extraHTTPHeaders: {
410
+ 'Authorization': `Bearer ${token}`,
411
+ 'Accept': 'application/json',
412
+ },
413
+ });
414
+ await use(context);
415
+ await context.dispose();
416
+ },
417
+ });
418
+
419
+ module.exports = { test, expect };
420
+ ```
421
+
422
+ ```javascript
423
+ // tests/api/admin.spec.js
424
+ const { test, expect } = require('../../fixtures/api-fixtures');
425
+
426
+ test('admin can list all users', async ({ adminRequest }) => {
427
+ const response = await adminRequest.get('/api/admin/users');
428
+ expect(response.status()).toBe(200);
429
+ const body = await response.json();
430
+ expect(body.users.length).toBeGreaterThan(0);
431
+ });
432
+ ```
433
+
434
+ ### JSON Response Assertions
435
+
436
+ **Use when**: Validating response status, headers, and body structure after every API call.
437
+ **Avoid when**: Never skip these. Every API test should assert on status and body.
438
+
439
+ **TypeScript**
440
+ ```typescript
441
+ import { test, expect } from '@playwright/test';
442
+
443
+ test('thorough response validation', async ({ request }) => {
444
+ const response = await request.get('/api/users/42');
445
+
446
+ // Status code — always check first
447
+ expect(response.status()).toBe(200);
448
+
449
+ // Status category — ok() checks 200-299 range
450
+ expect(response.ok()).toBeTruthy();
451
+
452
+ // Response headers
453
+ expect(response.headers()['content-type']).toContain('application/json');
454
+ expect(response.headers()['x-request-id']).toBeDefined();
455
+ expect(response.headers()['cache-control']).toMatch(/max-age=\d+/);
456
+
457
+ // TLS and connection details (Playwright 1.61+ — mirrors the browser-side APIs)
458
+ const security = await response.securityDetails(); // protocol, issuer, validity
459
+ expect(security?.protocol).toBe('TLS 1.3');
460
+ const addr = await response.serverAddr(); // { ipAddress, port } actually hit
461
+ expect(addr?.port).toBe(443);
462
+
463
+ // Full body parse and deep assertion
464
+ const user = await response.json();
465
+
466
+ // Exact match on known fields
467
+ expect(user.id).toBe(42);
468
+ expect(user.name).toBe('Jane Doe');
469
+ expect(user.email).toBe('jane@example.com');
470
+
471
+ // Partial match — ignore fields you don't care about
472
+ expect(user).toMatchObject({
473
+ id: 42,
474
+ name: 'Jane Doe',
475
+ role: expect.stringMatching(/^(admin|editor|viewer)$/),
476
+ });
477
+
478
+ // Type checks with expect.any()
479
+ expect(user).toMatchObject({
480
+ id: expect.any(Number),
481
+ name: expect.any(String),
482
+ createdAt: expect.any(String),
483
+ permissions: expect.any(Array),
484
+ });
485
+
486
+ // Array content
487
+ expect(user.permissions).toEqual(
488
+ expect.arrayContaining(['read', 'write'])
489
+ );
490
+ expect(user.permissions).not.toContain('delete');
491
+
492
+ // Nested object assertions
493
+ expect(user.profile).toMatchObject({
494
+ avatar: expect.stringMatching(/^https:\/\//),
495
+ bio: expect.any(String),
496
+ });
497
+
498
+ // Date format validation
499
+ expect(new Date(user.createdAt).toISOString()).toBe(user.createdAt);
500
+ });
501
+
502
+ test('list response structure', async ({ request }) => {
503
+ const response = await request.get('/api/users');
504
+ const body = await response.json();
505
+
506
+ // Array length
507
+ expect(body.users).toHaveLength(10);
508
+
509
+ // Every item in array matches shape
510
+ for (const user of body.users) {
511
+ expect(user).toMatchObject({
512
+ id: expect.any(Number),
513
+ name: expect.any(String),
514
+ email: expect.stringContaining('@'),
515
+ });
516
+ }
517
+
518
+ // Pagination metadata
519
+ expect(body.pagination).toEqual({
520
+ page: 1,
521
+ limit: 10,
522
+ total: expect.any(Number),
523
+ totalPages: expect.any(Number),
524
+ });
525
+ });
526
+ ```
527
+
528
+ **JavaScript**
529
+ ```javascript
530
+ const { test, expect } = require('@playwright/test');
531
+
532
+ test('thorough response validation', async ({ request }) => {
533
+ const response = await request.get('/api/users/42');
534
+
535
+ expect(response.status()).toBe(200);
536
+ expect(response.ok()).toBeTruthy();
537
+ expect(response.headers()['content-type']).toContain('application/json');
538
+
539
+ const user = await response.json();
540
+
541
+ expect(user).toMatchObject({
542
+ id: 42,
543
+ name: 'Jane Doe',
544
+ role: expect.stringMatching(/^(admin|editor|viewer)$/),
545
+ });
546
+
547
+ expect(user).toMatchObject({
548
+ id: expect.any(Number),
549
+ name: expect.any(String),
550
+ createdAt: expect.any(String),
551
+ permissions: expect.any(Array),
552
+ });
553
+
554
+ expect(user.permissions).toEqual(
555
+ expect.arrayContaining(['read', 'write'])
556
+ );
557
+ });
558
+
559
+ test('list response structure', async ({ request }) => {
560
+ const response = await request.get('/api/users');
561
+ const body = await response.json();
562
+
563
+ expect(body.users).toHaveLength(10);
564
+ for (const user of body.users) {
565
+ expect(user).toMatchObject({
566
+ id: expect.any(Number),
567
+ name: expect.any(String),
568
+ email: expect.stringContaining('@'),
569
+ });
570
+ }
571
+ });
572
+ ```
573
+
574
+ ### GraphQL Testing
575
+
576
+ **Use when**: Your backend exposes a GraphQL API and you want to test queries, mutations, variables, and error handling.
577
+ **Avoid when**: Your API is purely REST. Use the standard HTTP methods instead.
578
+
579
+ All GraphQL requests go through `POST` to a single endpoint. Send `query`, `variables`, and optionally `operationName` in the JSON body.
580
+
581
+ **TypeScript**
582
+ ```typescript
583
+ import { test, expect } from '@playwright/test';
584
+
585
+ const GRAPHQL_ENDPOINT = '/graphql';
586
+
587
+ test.describe('GraphQL API', () => {
588
+ test('query with variables', async ({ request }) => {
589
+ const response = await request.post(GRAPHQL_ENDPOINT, {
590
+ data: {
591
+ query: `
592
+ query GetUser($id: ID!) {
593
+ user(id: $id) {
594
+ id
595
+ name
596
+ email
597
+ posts {
598
+ id
599
+ title
600
+ }
601
+ }
602
+ }
603
+ `,
604
+ variables: { id: '42' },
605
+ },
606
+ });
607
+
608
+ expect(response.ok()).toBeTruthy();
609
+ const { data, errors } = await response.json();
610
+
611
+ // GraphQL returns 200 even on errors — always check both
612
+ expect(errors).toBeUndefined();
613
+ expect(data.user).toMatchObject({
614
+ id: '42',
615
+ name: expect.any(String),
616
+ email: expect.stringContaining('@'),
617
+ });
618
+ expect(data.user.posts).toEqual(
619
+ expect.arrayContaining([
620
+ expect.objectContaining({ id: expect.any(String), title: expect.any(String) }),
621
+ ])
622
+ );
623
+ });
624
+
625
+ test('mutation creates a resource', async ({ request }) => {
626
+ const response = await request.post(GRAPHQL_ENDPOINT, {
627
+ data: {
628
+ query: `
629
+ mutation CreatePost($input: CreatePostInput!) {
630
+ createPost(input: $input) {
631
+ id
632
+ title
633
+ status
634
+ author {
635
+ id
636
+ }
637
+ }
638
+ }
639
+ `,
640
+ variables: {
641
+ input: {
642
+ title: 'API Testing with Playwright',
643
+ body: 'A comprehensive guide...',
644
+ status: 'DRAFT',
645
+ },
646
+ },
647
+ },
648
+ });
649
+
650
+ const { data, errors } = await response.json();
651
+ expect(errors).toBeUndefined();
652
+ expect(data.createPost).toMatchObject({
653
+ id: expect.any(String),
654
+ title: 'API Testing with Playwright',
655
+ status: 'DRAFT',
656
+ });
657
+ });
658
+
659
+ test('handles GraphQL validation errors', async ({ request }) => {
660
+ const response = await request.post(GRAPHQL_ENDPOINT, {
661
+ data: {
662
+ query: `
663
+ mutation CreatePost($input: CreatePostInput!) {
664
+ createPost(input: $input) {
665
+ id
666
+ }
667
+ }
668
+ `,
669
+ variables: {
670
+ input: { title: '' }, // invalid: empty title
671
+ },
672
+ },
673
+ });
674
+
675
+ // GraphQL often returns 200 even for validation errors
676
+ const { data, errors } = await response.json();
677
+ expect(errors).toBeDefined();
678
+ expect(errors.length).toBeGreaterThan(0);
679
+ expect(errors[0].message).toContain('title');
680
+ expect(errors[0].extensions?.code).toBe('BAD_USER_INPUT');
681
+ });
682
+
683
+ test('handles authorization errors', async ({ request }) => {
684
+ const response = await request.post(GRAPHQL_ENDPOINT, {
685
+ data: {
686
+ query: `
687
+ query AdminDashboard {
688
+ adminStats {
689
+ totalRevenue
690
+ activeUsers
691
+ }
692
+ }
693
+ `,
694
+ },
695
+ // No auth header
696
+ });
697
+
698
+ const { data, errors } = await response.json();
699
+ expect(errors).toBeDefined();
700
+ expect(errors[0].extensions?.code).toBe('UNAUTHORIZED');
701
+ expect(data?.adminStats).toBeNull();
702
+ });
703
+ });
704
+ ```
705
+
706
+ **JavaScript**
707
+ ```javascript
708
+ const { test, expect } = require('@playwright/test');
709
+
710
+ const GRAPHQL_ENDPOINT = '/graphql';
711
+
712
+ test.describe('GraphQL API', () => {
713
+ test('query with variables', async ({ request }) => {
714
+ const response = await request.post(GRAPHQL_ENDPOINT, {
715
+ data: {
716
+ query: `
717
+ query GetUser($id: ID!) {
718
+ user(id: $id) {
719
+ id
720
+ name
721
+ email
722
+ }
723
+ }
724
+ `,
725
+ variables: { id: '42' },
726
+ },
727
+ });
728
+
729
+ const { data, errors } = await response.json();
730
+ expect(errors).toBeUndefined();
731
+ expect(data.user).toMatchObject({
732
+ id: '42',
733
+ name: expect.any(String),
734
+ email: expect.stringContaining('@'),
735
+ });
736
+ });
737
+
738
+ test('mutation creates a resource', async ({ request }) => {
739
+ const response = await request.post(GRAPHQL_ENDPOINT, {
740
+ data: {
741
+ query: `
742
+ mutation CreatePost($input: CreatePostInput!) {
743
+ createPost(input: $input) {
744
+ id
745
+ title
746
+ status
747
+ }
748
+ }
749
+ `,
750
+ variables: {
751
+ input: {
752
+ title: 'API Testing with Playwright',
753
+ body: 'A comprehensive guide...',
754
+ status: 'DRAFT',
755
+ },
756
+ },
757
+ },
758
+ });
759
+
760
+ const { data, errors } = await response.json();
761
+ expect(errors).toBeUndefined();
762
+ expect(data.createPost).toMatchObject({
763
+ id: expect.any(String),
764
+ title: 'API Testing with Playwright',
765
+ status: 'DRAFT',
766
+ });
767
+ });
768
+
769
+ test('handles GraphQL validation errors', async ({ request }) => {
770
+ const response = await request.post(GRAPHQL_ENDPOINT, {
771
+ data: {
772
+ query: `
773
+ mutation CreatePost($input: CreatePostInput!) {
774
+ createPost(input: $input) { id }
775
+ }
776
+ `,
777
+ variables: { input: { title: '' } },
778
+ },
779
+ });
780
+
781
+ const { data, errors } = await response.json();
782
+ expect(errors).toBeDefined();
783
+ expect(errors.length).toBeGreaterThan(0);
784
+ expect(errors[0].message).toContain('title');
785
+ });
786
+ });
787
+ ```
788
+
789
+ ### API Data Seeding
790
+
791
+ **Use when**: E2E tests need specific data to exist before running. API seeding is 10-100x faster than UI-based setup.
792
+ **Avoid when**: The test specifically validates the creation flow through the UI. Seed everything *except* what you are testing.
793
+
794
+ **TypeScript**
795
+ ```typescript
796
+ import { test as base, expect, APIRequestContext } from '@playwright/test';
797
+
798
+ // Fixture that seeds data via API before each test
799
+ type SeedFixtures = {
800
+ seedUser: { id: number; email: string; password: string };
801
+ seedProject: { id: number; name: string };
802
+ };
803
+
804
+ export const test = base.extend<SeedFixtures>({
805
+ seedUser: async ({ request }, use) => {
806
+ const email = `user-${Date.now()}@example.com`;
807
+ const password = 'TestPass123!';
808
+
809
+ // Create via API
810
+ const response = await request.post('/api/users', {
811
+ data: { name: 'Test User', email, password },
812
+ });
813
+ expect(response.ok()).toBeTruthy();
814
+ const user = await response.json();
815
+
816
+ // Pass to test
817
+ await use({ id: user.id, email, password });
818
+
819
+ // Cleanup after test — always delete what you created
820
+ await request.delete(`/api/users/${user.id}`);
821
+ },
822
+
823
+ seedProject: async ({ request, seedUser }, use) => {
824
+ const response = await request.post('/api/projects', {
825
+ data: { name: `Test Project ${Date.now()}`, ownerId: seedUser.id },
826
+ });
827
+ expect(response.ok()).toBeTruthy();
828
+ const project = await response.json();
829
+
830
+ await use({ id: project.id, name: project.name });
831
+
832
+ await request.delete(`/api/projects/${project.id}`);
833
+ },
834
+ });
835
+
836
+ export { expect };
837
+ ```
838
+
839
+ ```typescript
840
+ // tests/e2e/project-dashboard.spec.ts
841
+ import { test, expect } from '../../fixtures/seed-fixtures';
842
+
843
+ test('user sees their project on dashboard', async ({ page, seedUser, seedProject }) => {
844
+ // Login via UI (or use storageState for speed)
845
+ await page.goto('/login');
846
+ await page.getByLabel('Email').fill(seedUser.email);
847
+ await page.getByLabel('Password').fill(seedUser.password);
848
+ await page.getByRole('button', { name: 'Sign in' }).click();
849
+
850
+ // Data already exists — go straight to assertion
851
+ await page.waitForURL('/dashboard');
852
+ await expect(page.getByRole('heading', { name: seedProject.name })).toBeVisible();
853
+ });
854
+ ```
855
+
856
+ **JavaScript**
857
+ ```javascript
858
+ // fixtures/seed-fixtures.js
859
+ const { test: base, expect } = require('@playwright/test');
860
+
861
+ const test = base.extend({
862
+ seedUser: async ({ request }, use) => {
863
+ const email = `user-${Date.now()}@example.com`;
864
+ const password = 'TestPass123!';
865
+
866
+ const response = await request.post('/api/users', {
867
+ data: { name: 'Test User', email, password },
868
+ });
869
+ expect(response.ok()).toBeTruthy();
870
+ const user = await response.json();
871
+
872
+ await use({ id: user.id, email, password });
873
+
874
+ await request.delete(`/api/users/${user.id}`);
875
+ },
876
+
877
+ seedProject: async ({ request, seedUser }, use) => {
878
+ const response = await request.post('/api/projects', {
879
+ data: { name: `Test Project ${Date.now()}`, ownerId: seedUser.id },
880
+ });
881
+ expect(response.ok()).toBeTruthy();
882
+ const project = await response.json();
883
+
884
+ await use({ id: project.id, name: project.name });
885
+
886
+ await request.delete(`/api/projects/${project.id}`);
887
+ },
888
+ });
889
+
890
+ module.exports = { test, expect };
891
+ ```
892
+
893
+ ```javascript
894
+ // tests/e2e/project-dashboard.spec.js
895
+ const { test, expect } = require('../../fixtures/seed-fixtures');
896
+
897
+ test('user sees their project on dashboard', async ({ page, seedUser, seedProject }) => {
898
+ await page.goto('/login');
899
+ await page.getByLabel('Email').fill(seedUser.email);
900
+ await page.getByLabel('Password').fill(seedUser.password);
901
+ await page.getByRole('button', { name: 'Sign in' }).click();
902
+
903
+ await page.waitForURL('/dashboard');
904
+ await expect(page.getByRole('heading', { name: seedProject.name })).toBeVisible();
905
+ });
906
+ ```
907
+
908
+ ### Schema Validation
909
+
910
+ **Use when**: Verifying that API responses match a contract — field types, required fields, value constraints. Catches backend regressions early.
911
+ **Avoid when**: You only need to check one or two specific fields. Use `toMatchObject` instead.
912
+
913
+ #### Option A: Zod (recommended for TypeScript projects)
914
+
915
+ **TypeScript**
916
+ ```typescript
917
+ import { test, expect } from '@playwright/test';
918
+ import { z } from 'zod';
919
+
920
+ // Define schemas once, reuse across tests
921
+ const UserSchema = z.object({
922
+ id: z.number().positive(),
923
+ name: z.string().min(1),
924
+ email: z.string().email(),
925
+ role: z.enum(['admin', 'editor', 'viewer']),
926
+ createdAt: z.string().datetime(),
927
+ profile: z.object({
928
+ avatar: z.string().url().nullable(),
929
+ bio: z.string().max(500),
930
+ }),
931
+ });
932
+
933
+ const PaginatedUsersSchema = z.object({
934
+ users: z.array(UserSchema),
935
+ pagination: z.object({
936
+ page: z.number().int().positive(),
937
+ limit: z.number().int().positive(),
938
+ total: z.number().int().nonnegative(),
939
+ }),
940
+ });
941
+
942
+ test('GET /api/users matches schema', async ({ request }) => {
943
+ const response = await request.get('/api/users');
944
+ expect(response.ok()).toBeTruthy();
945
+
946
+ const body = await response.json();
947
+ const result = PaginatedUsersSchema.safeParse(body);
948
+
949
+ if (!result.success) {
950
+ // Detailed error output showing exactly which fields failed
951
+ throw new Error(
952
+ `Schema validation failed:\n${result.error.issues
953
+ .map((i) => ` ${i.path.join('.')}: ${i.message}`)
954
+ .join('\n')}`
955
+ );
956
+ }
957
+ });
958
+
959
+ test('POST /api/users returns valid user', async ({ request }) => {
960
+ const response = await request.post('/api/users', {
961
+ data: { name: 'Schema Test', email: `schema-${Date.now()}@example.com` },
962
+ });
963
+
964
+ const body = await response.json();
965
+ // Throws with detailed path info if validation fails
966
+ UserSchema.parse(body);
967
+ });
968
+ ```
969
+
970
+ #### Option B: Manual type checks (no dependencies)
971
+
972
+ **TypeScript**
973
+ ```typescript
974
+ import { test, expect } from '@playwright/test';
975
+
976
+ function assertUserShape(user: unknown): void {
977
+ expect(user).toBeDefined();
978
+ expect(user).toMatchObject({
979
+ id: expect.any(Number),
980
+ name: expect.any(String),
981
+ email: expect.any(String),
982
+ role: expect.any(String),
983
+ createdAt: expect.any(String),
984
+ });
985
+
986
+ const u = user as Record<string, unknown>;
987
+ // Value constraints
988
+ expect(['admin', 'editor', 'viewer']).toContain(u.role);
989
+ expect(typeof u.email === 'string' && u.email.includes('@')).toBe(true);
990
+ expect(new Date(u.createdAt as string).toString()).not.toBe('Invalid Date');
991
+ }
992
+
993
+ test('response matches expected shape', async ({ request }) => {
994
+ const response = await request.get('/api/users/1');
995
+ expect(response.ok()).toBeTruthy();
996
+
997
+ const body = await response.json();
998
+ assertUserShape(body);
999
+ });
1000
+ ```
1001
+
1002
+ **JavaScript**
1003
+ ```javascript
1004
+ const { test, expect } = require('@playwright/test');
1005
+
1006
+ function assertUserShape(user) {
1007
+ expect(user).toBeDefined();
1008
+ expect(user).toMatchObject({
1009
+ id: expect.any(Number),
1010
+ name: expect.any(String),
1011
+ email: expect.any(String),
1012
+ role: expect.any(String),
1013
+ createdAt: expect.any(String),
1014
+ });
1015
+ expect(['admin', 'editor', 'viewer']).toContain(user.role);
1016
+ expect(user.email).toContain('@');
1017
+ expect(new Date(user.createdAt).toString()).not.toBe('Invalid Date');
1018
+ }
1019
+
1020
+ test('response matches expected shape', async ({ request }) => {
1021
+ const response = await request.get('/api/users/1');
1022
+ expect(response.ok()).toBeTruthy();
1023
+
1024
+ const body = await response.json();
1025
+ assertUserShape(body);
1026
+ });
1027
+ ```
1028
+
1029
+ ### Error Response Testing
1030
+
1031
+ **Use when**: Every API has error paths. Test them. A missing 401 test today is a security hole tomorrow.
1032
+ **Avoid when**: Never skip error testing.
1033
+
1034
+ **TypeScript**
1035
+ ```typescript
1036
+ import { test, expect } from '@playwright/test';
1037
+
1038
+ test.describe('Error responses', () => {
1039
+ test('400 — validation error with details', async ({ request }) => {
1040
+ const response = await request.post('/api/users', {
1041
+ data: { name: '', email: 'not-an-email' }, // invalid
1042
+ });
1043
+ expect(response.status()).toBe(400);
1044
+
1045
+ const body = await response.json();
1046
+ expect(body).toMatchObject({
1047
+ error: 'Validation Error',
1048
+ details: expect.any(Array),
1049
+ });
1050
+ // Check individual field errors
1051
+ expect(body.details).toEqual(
1052
+ expect.arrayContaining([
1053
+ expect.objectContaining({ field: 'name', message: expect.any(String) }),
1054
+ expect.objectContaining({ field: 'email', message: expect.any(String) }),
1055
+ ])
1056
+ );
1057
+ });
1058
+
1059
+ test('401 — missing authentication', async ({ request }) => {
1060
+ // Create a fresh context with NO auth headers
1061
+ const response = await request.get('/api/protected/resource', {
1062
+ headers: { 'Authorization': '' }, // explicitly clear
1063
+ });
1064
+ expect(response.status()).toBe(401);
1065
+
1066
+ const body = await response.json();
1067
+ expect(body.error).toMatch(/unauthorized|unauthenticated/i);
1068
+ });
1069
+
1070
+ test('403 — insufficient permissions', async ({ request }) => {
1071
+ // Assuming `request` is authenticated as a viewer
1072
+ const response = await request.delete('/api/admin/users/1');
1073
+ expect(response.status()).toBe(403);
1074
+
1075
+ const body = await response.json();
1076
+ expect(body.error).toMatch(/forbidden|insufficient permissions/i);
1077
+ });
1078
+
1079
+ test('404 — resource not found', async ({ request }) => {
1080
+ const response = await request.get('/api/users/999999');
1081
+ expect(response.status()).toBe(404);
1082
+
1083
+ const body = await response.json();
1084
+ expect(body).toMatchObject({
1085
+ error: expect.stringMatching(/not found/i),
1086
+ });
1087
+ });
1088
+
1089
+ test('409 — conflict on duplicate resource', async ({ request }) => {
1090
+ const email = `conflict-${Date.now()}@example.com`;
1091
+ await request.post('/api/users', {
1092
+ data: { name: 'First', email },
1093
+ });
1094
+
1095
+ const response = await request.post('/api/users', {
1096
+ data: { name: 'Duplicate', email },
1097
+ });
1098
+ expect(response.status()).toBe(409);
1099
+ });
1100
+
1101
+ test('422 — unprocessable entity', async ({ request }) => {
1102
+ const response = await request.post('/api/orders', {
1103
+ data: { items: [] }, // empty cart
1104
+ });
1105
+ expect(response.status()).toBe(422);
1106
+ const body = await response.json();
1107
+ expect(body.error).toContain('at least one item');
1108
+ });
1109
+
1110
+ test('429 — rate limiting', async ({ request }) => {
1111
+ // Send rapid requests to trigger rate limit
1112
+ const responses = await Promise.all(
1113
+ Array.from({ length: 50 }, () =>
1114
+ request.get('/api/search', { params: { q: 'test' } })
1115
+ )
1116
+ );
1117
+ const rateLimited = responses.filter((r) => r.status() === 429);
1118
+ expect(rateLimited.length).toBeGreaterThan(0);
1119
+
1120
+ // Verify rate limit headers
1121
+ const limited = rateLimited[0];
1122
+ expect(limited.headers()['retry-after']).toBeDefined();
1123
+ });
1124
+ });
1125
+ ```
1126
+
1127
+ **JavaScript**
1128
+ ```javascript
1129
+ const { test, expect } = require('@playwright/test');
1130
+
1131
+ test.describe('Error responses', () => {
1132
+ test('400 — validation error with details', async ({ request }) => {
1133
+ const response = await request.post('/api/users', {
1134
+ data: { name: '', email: 'not-an-email' },
1135
+ });
1136
+ expect(response.status()).toBe(400);
1137
+
1138
+ const body = await response.json();
1139
+ expect(body).toMatchObject({
1140
+ error: 'Validation Error',
1141
+ details: expect.any(Array),
1142
+ });
1143
+ expect(body.details).toEqual(
1144
+ expect.arrayContaining([
1145
+ expect.objectContaining({ field: 'name', message: expect.any(String) }),
1146
+ expect.objectContaining({ field: 'email', message: expect.any(String) }),
1147
+ ])
1148
+ );
1149
+ });
1150
+
1151
+ test('401 — missing authentication', async ({ request }) => {
1152
+ const response = await request.get('/api/protected/resource', {
1153
+ headers: { 'Authorization': '' },
1154
+ });
1155
+ expect(response.status()).toBe(401);
1156
+ });
1157
+
1158
+ test('404 — resource not found', async ({ request }) => {
1159
+ const response = await request.get('/api/users/999999');
1160
+ expect(response.status()).toBe(404);
1161
+ });
1162
+
1163
+ test('409 — conflict on duplicate resource', async ({ request }) => {
1164
+ const email = `conflict-${Date.now()}@example.com`;
1165
+ await request.post('/api/users', { data: { name: 'First', email } });
1166
+
1167
+ const response = await request.post('/api/users', {
1168
+ data: { name: 'Duplicate', email },
1169
+ });
1170
+ expect(response.status()).toBe(409);
1171
+ });
1172
+ });
1173
+ ```
1174
+
1175
+ ### File Upload via API
1176
+
1177
+ **Use when**: Testing file upload endpoints with multipart form data — document uploads, image processing, CSV imports.
1178
+ **Avoid when**: You need to test the browser file picker dialog. Use `page.setInputFiles()` in an E2E test instead.
1179
+
1180
+ **TypeScript**
1181
+ ```typescript
1182
+ import { test, expect } from '@playwright/test';
1183
+ import path from 'path';
1184
+ import fs from 'fs';
1185
+
1186
+ test('upload a file via multipart form data', async ({ request }) => {
1187
+ const filePath = path.resolve('tests/fixtures/test-document.pdf');
1188
+
1189
+ const response = await request.post('/api/documents/upload', {
1190
+ multipart: {
1191
+ file: {
1192
+ name: 'test-document.pdf',
1193
+ mimeType: 'application/pdf',
1194
+ buffer: fs.readFileSync(filePath),
1195
+ },
1196
+ description: 'Quarterly report',
1197
+ category: 'reports',
1198
+ },
1199
+ });
1200
+
1201
+ expect(response.status()).toBe(201);
1202
+ const body = await response.json();
1203
+ expect(body).toMatchObject({
1204
+ id: expect.any(String),
1205
+ filename: 'test-document.pdf',
1206
+ mimeType: 'application/pdf',
1207
+ size: expect.any(Number),
1208
+ url: expect.stringMatching(/^https:\/\//),
1209
+ });
1210
+ });
1211
+
1212
+ test('upload an image with metadata', async ({ request }) => {
1213
+ const imagePath = path.resolve('tests/fixtures/avatar.png');
1214
+
1215
+ const response = await request.post('/api/users/42/avatar', {
1216
+ multipart: {
1217
+ image: {
1218
+ name: 'avatar.png',
1219
+ mimeType: 'image/png',
1220
+ buffer: fs.readFileSync(imagePath),
1221
+ },
1222
+ crop: JSON.stringify({ x: 0, y: 0, width: 200, height: 200 }),
1223
+ },
1224
+ });
1225
+
1226
+ expect(response.ok()).toBeTruthy();
1227
+ const body = await response.json();
1228
+ expect(body.avatarUrl).toMatch(/\.png$/);
1229
+ });
1230
+
1231
+ test('upload multiple files', async ({ request }) => {
1232
+ const files = ['report-q1.csv', 'report-q2.csv'].map((name) => ({
1233
+ name,
1234
+ mimeType: 'text/csv',
1235
+ buffer: fs.readFileSync(path.resolve(`tests/fixtures/${name}`)),
1236
+ }));
1237
+
1238
+ // Send sequential uploads when the API does not support batch
1239
+ const results = [];
1240
+ for (const file of files) {
1241
+ const response = await request.post('/api/imports/csv', {
1242
+ multipart: { file },
1243
+ });
1244
+ expect(response.ok()).toBeTruthy();
1245
+ results.push(await response.json());
1246
+ }
1247
+ expect(results).toHaveLength(2);
1248
+ });
1249
+
1250
+ test('rejects oversized files', async ({ request }) => {
1251
+ // Create a buffer that exceeds the server limit
1252
+ const largeBuffer = Buffer.alloc(11 * 1024 * 1024); // 11MB
1253
+
1254
+ const response = await request.post('/api/documents/upload', {
1255
+ multipart: {
1256
+ file: {
1257
+ name: 'large-file.bin',
1258
+ mimeType: 'application/octet-stream',
1259
+ buffer: largeBuffer,
1260
+ },
1261
+ },
1262
+ });
1263
+
1264
+ expect(response.status()).toBe(413); // Payload Too Large
1265
+ });
1266
+ ```
1267
+
1268
+ **JavaScript**
1269
+ ```javascript
1270
+ const { test, expect } = require('@playwright/test');
1271
+ const path = require('path');
1272
+ const fs = require('fs');
1273
+
1274
+ test('upload a file via multipart form data', async ({ request }) => {
1275
+ const filePath = path.resolve('tests/fixtures/test-document.pdf');
1276
+
1277
+ const response = await request.post('/api/documents/upload', {
1278
+ multipart: {
1279
+ file: {
1280
+ name: 'test-document.pdf',
1281
+ mimeType: 'application/pdf',
1282
+ buffer: fs.readFileSync(filePath),
1283
+ },
1284
+ description: 'Quarterly report',
1285
+ category: 'reports',
1286
+ },
1287
+ });
1288
+
1289
+ expect(response.status()).toBe(201);
1290
+ const body = await response.json();
1291
+ expect(body).toMatchObject({
1292
+ id: expect.any(String),
1293
+ filename: 'test-document.pdf',
1294
+ mimeType: 'application/pdf',
1295
+ size: expect.any(Number),
1296
+ });
1297
+ });
1298
+
1299
+ test('rejects oversized files', async ({ request }) => {
1300
+ const largeBuffer = Buffer.alloc(11 * 1024 * 1024);
1301
+
1302
+ const response = await request.post('/api/documents/upload', {
1303
+ multipart: {
1304
+ file: {
1305
+ name: 'large-file.bin',
1306
+ mimeType: 'application/octet-stream',
1307
+ buffer: largeBuffer,
1308
+ },
1309
+ },
1310
+ });
1311
+
1312
+ expect(response.status()).toBe(413);
1313
+ });
1314
+ ```
1315
+
1316
+ ### Chained API Calls
1317
+
1318
+ **Use when**: Testing multi-step workflows — create, read, update, delete sequences; order flows; state machine transitions. This verifies the API's behavior as an integrated system, not just isolated endpoints.
1319
+ **Avoid when**: You can test each endpoint in isolation and the interactions are trivial.
1320
+
1321
+ **TypeScript**
1322
+ ```typescript
1323
+ import { test, expect } from '@playwright/test';
1324
+
1325
+ test('complete order workflow', async ({ request }) => {
1326
+ // Step 1: Create a product
1327
+ const productResp = await request.post('/api/products', {
1328
+ data: { name: 'Widget', price: 29.99, stock: 100 },
1329
+ });
1330
+ expect(productResp.status()).toBe(201);
1331
+ const product = await productResp.json();
1332
+
1333
+ // Step 2: Create a cart
1334
+ const cartResp = await request.post('/api/carts', {
1335
+ data: { items: [{ productId: product.id, quantity: 2 }] },
1336
+ });
1337
+ expect(cartResp.status()).toBe(201);
1338
+ const cart = await cartResp.json();
1339
+ expect(cart.total).toBe(59.98);
1340
+
1341
+ // Step 3: Checkout — create an order from the cart
1342
+ const orderResp = await request.post('/api/orders', {
1343
+ data: {
1344
+ cartId: cart.id,
1345
+ shippingAddress: {
1346
+ street: '123 Test St',
1347
+ city: 'Testville',
1348
+ zip: '12345',
1349
+ },
1350
+ },
1351
+ });
1352
+ expect(orderResp.status()).toBe(201);
1353
+ const order = await orderResp.json();
1354
+ expect(order.status).toBe('pending');
1355
+ expect(order.items).toHaveLength(1);
1356
+ expect(order.total).toBe(59.98);
1357
+
1358
+ // Step 4: Verify order appears in user's order list
1359
+ const ordersResp = await request.get('/api/orders');
1360
+ const orders = await ordersResp.json();
1361
+ expect(orders.items.map((o: any) => o.id)).toContain(order.id);
1362
+
1363
+ // Step 5: Verify product stock decreased
1364
+ const updatedProduct = await (await request.get(`/api/products/${product.id}`)).json();
1365
+ expect(updatedProduct.stock).toBe(98); // 100 - 2
1366
+
1367
+ // Cleanup
1368
+ await request.delete(`/api/orders/${order.id}`);
1369
+ await request.delete(`/api/products/${product.id}`);
1370
+ });
1371
+
1372
+ test('state machine transitions — publish workflow', async ({ request }) => {
1373
+ // Create a draft post
1374
+ const createResp = await request.post('/api/posts', {
1375
+ data: { title: 'Draft Post', body: 'Content here.' },
1376
+ });
1377
+ const post = await createResp.json();
1378
+ expect(post.status).toBe('draft');
1379
+
1380
+ // Submit for review
1381
+ const reviewResp = await request.patch(`/api/posts/${post.id}/status`, {
1382
+ data: { status: 'in_review' },
1383
+ });
1384
+ expect(reviewResp.ok()).toBeTruthy();
1385
+ expect((await reviewResp.json()).status).toBe('in_review');
1386
+
1387
+ // Approve (requires admin — use appropriate fixture in real tests)
1388
+ const approveResp = await request.patch(`/api/posts/${post.id}/status`, {
1389
+ data: { status: 'published' },
1390
+ });
1391
+ expect(approveResp.ok()).toBeTruthy();
1392
+ expect((await approveResp.json()).status).toBe('published');
1393
+
1394
+ // Verify: cannot go back to draft from published
1395
+ const revertResp = await request.patch(`/api/posts/${post.id}/status`, {
1396
+ data: { status: 'draft' },
1397
+ });
1398
+ expect(revertResp.status()).toBe(422);
1399
+
1400
+ // Cleanup
1401
+ await request.delete(`/api/posts/${post.id}`);
1402
+ });
1403
+
1404
+ test('API + E2E hybrid — seed via API, verify in browser', async ({ request, page }) => {
1405
+ // Seed test data via API
1406
+ const resp = await request.post('/api/products', {
1407
+ data: {
1408
+ name: `Hybrid Test Product ${Date.now()}`,
1409
+ price: 42.00,
1410
+ published: true,
1411
+ },
1412
+ });
1413
+ const product = await resp.json();
1414
+
1415
+ // Verify via browser
1416
+ await page.goto('/products');
1417
+ await expect(
1418
+ page.getByRole('heading', { name: product.name })
1419
+ ).toBeVisible();
1420
+ await expect(page.getByText('$42.00')).toBeVisible();
1421
+
1422
+ // Cleanup via API
1423
+ await request.delete(`/api/products/${product.id}`);
1424
+ });
1425
+ ```
1426
+
1427
+ **JavaScript**
1428
+ ```javascript
1429
+ const { test, expect } = require('@playwright/test');
1430
+
1431
+ test('complete order workflow', async ({ request }) => {
1432
+ const productResp = await request.post('/api/products', {
1433
+ data: { name: 'Widget', price: 29.99, stock: 100 },
1434
+ });
1435
+ expect(productResp.status()).toBe(201);
1436
+ const product = await productResp.json();
1437
+
1438
+ const cartResp = await request.post('/api/carts', {
1439
+ data: { items: [{ productId: product.id, quantity: 2 }] },
1440
+ });
1441
+ expect(cartResp.status()).toBe(201);
1442
+ const cart = await cartResp.json();
1443
+ expect(cart.total).toBe(59.98);
1444
+
1445
+ const orderResp = await request.post('/api/orders', {
1446
+ data: {
1447
+ cartId: cart.id,
1448
+ shippingAddress: {
1449
+ street: '123 Test St',
1450
+ city: 'Testville',
1451
+ zip: '12345',
1452
+ },
1453
+ },
1454
+ });
1455
+ expect(orderResp.status()).toBe(201);
1456
+ const order = await orderResp.json();
1457
+ expect(order.status).toBe('pending');
1458
+
1459
+ const ordersResp = await request.get('/api/orders');
1460
+ const orders = await ordersResp.json();
1461
+ expect(orders.items.map((o) => o.id)).toContain(order.id);
1462
+
1463
+ await request.delete(`/api/orders/${order.id}`);
1464
+ await request.delete(`/api/products/${product.id}`);
1465
+ });
1466
+
1467
+ test('API + E2E hybrid — seed via API, verify in browser', async ({ request, page }) => {
1468
+ const resp = await request.post('/api/products', {
1469
+ data: {
1470
+ name: `Hybrid Test Product ${Date.now()}`,
1471
+ price: 42.00,
1472
+ published: true,
1473
+ },
1474
+ });
1475
+ const product = await resp.json();
1476
+
1477
+ await page.goto('/products');
1478
+ await expect(
1479
+ page.getByRole('heading', { name: product.name })
1480
+ ).toBeVisible();
1481
+
1482
+ await request.delete(`/api/products/${product.id}`);
1483
+ });
1484
+ ```
1485
+
1486
+ ## Decision Guide
1487
+
1488
+ | Scenario | Use API Tests | Use E2E Tests | Why |
1489
+ |---|---|---|---|
1490
+ | Validate response status/body/headers | Yes | No | No browser needed; 10-100x faster |
1491
+ | Test business logic (calculations, rules) | Yes | No | API tests isolate backend logic from UI |
1492
+ | Verify form submission creates correct data | Seed via API, submit via UI | Yes | UI test validates the form; API check confirms persistence |
1493
+ | Test error messages shown to user | No | Yes | Error rendering is a UI concern |
1494
+ | Validate pagination, filtering, sorting | Yes | Maybe both | API test for correctness; E2E test only if the UI logic is complex |
1495
+ | Seed test data for E2E tests | Yes (fixture) | No | API seeding is fast and reliable |
1496
+ | Test auth flows (login/logout/RBAC) | Yes for token/session logic | Yes for UI flow | Both matter: API protects resources, UI guides users |
1497
+ | Verify file upload processing | Yes | Only if testing file picker UI | API test validates backend processing |
1498
+ | Contract/schema regression testing | Yes | No | Schema tests run in milliseconds |
1499
+ | Test third-party webhook handling | Yes | No | Webhooks are API-to-API; no UI involved |
1500
+ | Verify redirect behavior after action | No | Yes | Redirects are browser/navigation concerns |
1501
+ | Test real-time updates (WebSocket + API trigger) | API triggers | E2E verifies | Seed via API, observe in browser |
1502
+
1503
+ ## Anti-Patterns
1504
+
1505
+ | Don't Do This | Problem | Do This Instead |
1506
+ |---|---|---|
1507
+ | Use E2E tests to validate pure API responses | Slow, flaky, launches a browser for no reason | Use `request` fixture — no browser, direct HTTP |
1508
+ | Ignore `response.status()` | A 500 with a fallback body can pass all body assertions | Always assert status first: `expect(response.status()).toBe(200)` |
1509
+ | Skip response header checks | Missing `Content-Type`, `Cache-Control`, CORS headers cause production bugs | Assert critical headers: `expect(response.headers()['content-type']).toContain('application/json')` |
1510
+ | Only test the happy path | Real users trigger 400, 401, 403, 404, 409, 422 — every one needs a test | Dedicate a `describe` block to error responses |
1511
+ | Hardcode IDs in API tests | Tests break when database is reset or IDs are reassigned | Create resources in the test, use returned IDs |
1512
+ | Share mutable state between tests | Tests that depend on execution order are flaky and cannot run in parallel | Each test creates and cleans up its own data |
1513
+ | Parse `response.text()` then `JSON.parse()` manually | Playwright's `response.json()` handles this and throws clear errors on non-JSON | Use `await response.json()` |
1514
+ | Forget cleanup after creating resources | Test pollution: subsequent tests may see stale data or hit unique constraints | Use fixtures with teardown or explicit `delete` calls |
1515
+ | Test GraphQL by checking only `response.ok()` | GraphQL returns 200 even on errors — `errors` array is the real signal | Always check both `data` and `errors` in the response body |
1516
+ | Use `page.request` when you don't need a page | `page.request` shares cookies with the browser context, which may cause auth confusion | Use the standalone `request` fixture for pure API tests |
1517
+
1518
+ ## Troubleshooting
1519
+
1520
+ ### "Request failed: connect ECONNREFUSED 127.0.0.1:3000"
1521
+
1522
+ **Cause**: The API server is not running, or `baseURL` points to the wrong host/port.
1523
+
1524
+ **Fix**:
1525
+ - Verify the server is running before tests: use `webServer` in `playwright.config.ts` to start it automatically.
1526
+ - Check `baseURL` in your config matches the actual server address.
1527
+
1528
+ ```typescript
1529
+ // playwright.config.ts
1530
+ export default defineConfig({
1531
+ webServer: {
1532
+ command: 'npm run start:api',
1533
+ url: 'http://localhost:3000/api/health',
1534
+ reuseExistingServer: !process.env.CI,
1535
+ },
1536
+ use: {
1537
+ baseURL: 'http://localhost:3000',
1538
+ },
1539
+ });
1540
+ ```
1541
+
1542
+ ### "response.json() failed — body is not valid JSON"
1543
+
1544
+ **Cause**: The endpoint returned HTML (error page), plain text, or an empty body instead of JSON.
1545
+
1546
+ **Fix**:
1547
+ - Check `response.status()` first — a 500 or 302 often returns HTML.
1548
+ - Log `await response.text()` to see the actual body.
1549
+ - Verify the `Accept: application/json` header is set.
1550
+
1551
+ ```typescript
1552
+ const response = await request.get('/api/endpoint');
1553
+ if (!response.ok()) {
1554
+ console.error(`Status: ${response.status()}, Body: ${await response.text()}`);
1555
+ }
1556
+ const body = await response.json(); // now you know what failed
1557
+ ```
1558
+
1559
+ ### "401 Unauthorized" when using `request` fixture
1560
+
1561
+ **Cause**: The built-in `request` fixture does not carry browser cookies or auth tokens automatically. It starts with a clean slate.
1562
+
1563
+ **Fix**:
1564
+ - Set `extraHTTPHeaders` in your config or create a custom authenticated fixture.
1565
+ - If you need cookies from a browser login, use `page.request` (which shares the browser context's cookies) instead of the standalone `request` fixture.
1566
+
1567
+ ```typescript
1568
+ // Option A: config-level headers
1569
+ export default defineConfig({
1570
+ use: {
1571
+ extraHTTPHeaders: {
1572
+ 'Authorization': `Bearer ${process.env.API_TOKEN}`,
1573
+ },
1574
+ },
1575
+ });
1576
+
1577
+ // Option B: per-request headers
1578
+ const response = await request.get('/api/resource', {
1579
+ headers: { 'Authorization': `Bearer ${token}` },
1580
+ });
1581
+
1582
+ // Option C: use page.request to inherit browser cookies
1583
+ test('API call with browser auth', async ({ page }) => {
1584
+ await page.goto('/login');
1585
+ // ... login via UI ...
1586
+ const response = await page.request.get('/api/profile');
1587
+ expect(response.ok()).toBeTruthy();
1588
+ });
1589
+ ```
1590
+
1591
+ ### GraphQL returns 200 but data is null
1592
+
1593
+ **Cause**: GraphQL servers return HTTP 200 even when the query has errors. The actual error is in the `errors` array.
1594
+
1595
+ **Fix**: Always destructure and check both `data` and `errors`.
1596
+
1597
+ ```typescript
1598
+ const { data, errors } = await response.json();
1599
+ if (errors) {
1600
+ console.error('GraphQL errors:', JSON.stringify(errors, null, 2));
1601
+ }
1602
+ expect(errors).toBeUndefined();
1603
+ expect(data.user).toBeDefined();
1604
+ ```
1605
+
1606
+ ### Tests pass locally but fail in CI
1607
+
1608
+ **Cause**: Different environments, database state, or missing environment variables.
1609
+
1610
+ **Fix**:
1611
+ - Use `process.env` for secrets and base URLs — never hardcode them.
1612
+ - Run database seeds or migrations in `globalSetup`.
1613
+ - Use unique identifiers (timestamps, UUIDs) for test data to avoid collisions in parallel runs.
1614
+ - Check that the CI `baseURL` matches the deployed or containerized service.
1615
+
1616
+ ## Related
1617
+
1618
+ - [core/configuration.md](configuration.md) — `baseURL`, `extraHTTPHeaders`, and `webServer` config
1619
+ - [core/fixtures-and-hooks.md](fixtures-and-hooks.md) — custom fixture patterns for reusable API clients
1620
+ - [core/authentication.md](authentication.md) — auth patterns including token-based API auth
1621
+ - [core/network-mocking.md](network-mocking.md) — mocking API responses in E2E tests (opposite of this guide)
1622
+ - [core/test-architecture.md](test-architecture.md) — when to use API tests vs E2E vs component tests
1623
+ - [core/when-to-mock.md](when-to-mock.md) — when to hit real APIs vs mock them