@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.
- package/README.md +293 -0
- package/extensions/dino.ts +70 -0
- package/extensions/mabl-mcp.ts +306 -0
- package/extensions/playwright-mcp.ts +142 -0
- package/package.json +47 -0
- package/prompts/create-pr.md +110 -0
- package/prompts/generate-bdd-from-url.md +65 -0
- package/prompts/generate-pom-from-url-cli.md +48 -0
- package/prompts/generate-pom-from-url.md +45 -0
- package/prompts/generate-spec-from-feature.md +64 -0
- package/prompts/heal-playwright-failures.md +106 -0
- package/prompts/setup-playwright.md +81 -0
- package/scripts/start-dino.cmd +8 -0
- package/scripts/start-dino.mjs +226 -0
- package/scripts/start-dino.ps1 +9 -0
- package/scripts/start-dino.sh +10 -0
- package/skills/acli/SKILL.md +292 -0
- package/skills/bdd-gherkin/SKILL.md +552 -0
- package/skills/build-review-interface/SKILL.md +96 -0
- package/skills/error-analysis/SKILL.md +164 -0
- package/skills/eval-audit/SKILL.md +183 -0
- package/skills/evaluate-rag/SKILL.md +177 -0
- package/skills/generate-synthetic-data/SKILL.md +131 -0
- package/skills/mabl-mcp/SKILL.md +79 -0
- package/skills/playwright-cli/SKILL.md +246 -0
- package/skills/playwright-cli/advanced-workflows.md +679 -0
- package/skills/playwright-cli/core-commands.md +375 -0
- package/skills/playwright-cli/device-emulation.md +475 -0
- package/skills/playwright-cli/request-mocking.md +373 -0
- package/skills/playwright-cli/running-custom-code.md +553 -0
- package/skills/playwright-cli/screenshots-and-media.md +457 -0
- package/skills/playwright-cli/session-management.md +425 -0
- package/skills/playwright-cli/storage-and-auth.md +487 -0
- package/skills/playwright-cli/test-generation.md +336 -0
- package/skills/playwright-cli/tracing-and-debugging.md +501 -0
- package/skills/playwright-core/SKILL.md +101 -0
- package/skills/playwright-core/accessibility.md +1517 -0
- package/skills/playwright-core/angular.md +1086 -0
- package/skills/playwright-core/api-testing.md +1623 -0
- package/skills/playwright-core/assertions-and-waiting.md +703 -0
- package/skills/playwright-core/auth-flows.md +1081 -0
- package/skills/playwright-core/authentication.md +1409 -0
- package/skills/playwright-core/browser-apis.md +673 -0
- package/skills/playwright-core/browser-extensions.md +319 -0
- package/skills/playwright-core/canvas-and-webgl.md +494 -0
- package/skills/playwright-core/clock-and-time-mocking.md +427 -0
- package/skills/playwright-core/common-pitfalls.md +1318 -0
- package/skills/playwright-core/component-testing.md +1179 -0
- package/skills/playwright-core/configuration.md +731 -0
- package/skills/playwright-core/crud-testing.md +945 -0
- package/skills/playwright-core/debugging.md +827 -0
- package/skills/playwright-core/drag-and-drop.md +992 -0
- package/skills/playwright-core/electron-testing.md +622 -0
- package/skills/playwright-core/error-and-edge-cases.md +1181 -0
- package/skills/playwright-core/error-index.md +1903 -0
- package/skills/playwright-core/file-operations.md +749 -0
- package/skills/playwright-core/file-upload-download.md +1012 -0
- package/skills/playwright-core/fixtures-and-hooks.md +1072 -0
- package/skills/playwright-core/flaky-tests.md +883 -0
- package/skills/playwright-core/forms-and-validation.md +1056 -0
- package/skills/playwright-core/i18n-and-localization.md +622 -0
- package/skills/playwright-core/iframes-and-shadow-dom.md +488 -0
- package/skills/playwright-core/locator-strategy.md +588 -0
- package/skills/playwright-core/locators.md +743 -0
- package/skills/playwright-core/mobile-and-responsive.md +1669 -0
- package/skills/playwright-core/multi-context-and-popups.md +581 -0
- package/skills/playwright-core/multi-user-and-collaboration.md +477 -0
- package/skills/playwright-core/network-mocking.md +1395 -0
- package/skills/playwright-core/nextjs.md +1013 -0
- package/skills/playwright-core/performance-testing.md +608 -0
- package/skills/playwright-core/react.md +1082 -0
- package/skills/playwright-core/search-and-filter.md +1366 -0
- package/skills/playwright-core/security-testing.md +624 -0
- package/skills/playwright-core/service-workers-and-pwa.md +524 -0
- package/skills/playwright-core/test-architecture.md +569 -0
- package/skills/playwright-core/test-data-management.md +1209 -0
- package/skills/playwright-core/test-organization.md +947 -0
- package/skills/playwright-core/third-party-integrations.md +754 -0
- package/skills/playwright-core/trace-analysis.md +161 -0
- package/skills/playwright-core/visual-regression.md +1027 -0
- package/skills/playwright-core/vue.md +1162 -0
- package/skills/playwright-core/websockets-and-realtime.md +623 -0
- package/skills/playwright-core/when-to-mock.md +827 -0
- package/skills/playwright-pom/SKILL.md +94 -0
- package/skills/playwright-pom/page-object-model.md +921 -0
- package/skills/playwright-pom/pom-vs-fixtures-vs-helpers.md +937 -0
- package/skills/validate-evaluator/SKILL.md +215 -0
- package/skills/write-judge-prompt/SKILL.md +144 -0
- 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
|