Você não pode selecionar mais de 25 tópicos Os tópicos devem começar com uma letra ou um número, podem incluir traços ('-') e podem ter até 35 caracteres.

api-testing-patterns.md 26KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915
  1. # API Testing Patterns
  2. ## Principle
  3. Test APIs and backend services directly without browser overhead. Use Playwright's `request` context for HTTP operations, `apiRequest` utility for enhanced features, and `recurse` for async operations. Pure API tests run faster, are more stable, and provide better coverage for service-layer logic.
  4. ## Rationale
  5. Many teams over-rely on E2E/browser tests when API tests would be more appropriate:
  6. - **Slower feedback**: Browser tests take seconds, API tests take milliseconds
  7. - **More brittle**: UI changes break tests even when API works correctly
  8. - **Wrong abstraction**: Testing business logic through UI layers adds noise
  9. - **Resource heavy**: Browsers consume memory and CPU
  10. API-first testing provides:
  11. - **Fast execution**: No browser startup, no rendering, no JavaScript execution
  12. - **Direct validation**: Test exactly what the service returns
  13. - **Better isolation**: Test service logic independent of UI
  14. - **Easier debugging**: Clear request/response without DOM noise
  15. - **Contract validation**: Verify API contracts explicitly
  16. ## When to Use API Tests vs E2E Tests
  17. | Scenario | API Test | E2E Test |
  18. | ------------------------- | ------------- | ------------- |
  19. | CRUD operations | ✅ Primary | ❌ Overkill |
  20. | Business logic validation | ✅ Primary | ❌ Overkill |
  21. | Error handling (4xx, 5xx) | ✅ Primary | ⚠️ Supplement |
  22. | Authentication flows | ✅ Primary | ⚠️ Supplement |
  23. | Data transformation | ✅ Primary | ❌ Overkill |
  24. | User journeys | ❌ Can't test | ✅ Primary |
  25. | Visual regression | ❌ Can't test | ✅ Primary |
  26. | Cross-browser issues | ❌ Can't test | ✅ Primary |
  27. **Rule of thumb**: If you're testing what the server returns (not how it looks), use API tests.
  28. ## Pattern Examples
  29. ### Example 1: Pure API Test (No Browser)
  30. **Context**: Test REST API endpoints directly without any browser context.
  31. **Implementation**:
  32. ```typescript
  33. // tests/api/users.spec.ts
  34. import { test, expect } from '@playwright/test';
  35. // No page, no browser - just API
  36. test.describe('Users API', () => {
  37. test('should create user', async ({ request }) => {
  38. const response = await request.post('/api/users', {
  39. data: {
  40. name: 'John Doe',
  41. email: 'john@example.com',
  42. role: 'user',
  43. },
  44. });
  45. expect(response.status()).toBe(201);
  46. const user = await response.json();
  47. expect(user.id).toBeDefined();
  48. expect(user.name).toBe('John Doe');
  49. expect(user.email).toBe('john@example.com');
  50. });
  51. test('should get user by ID', async ({ request }) => {
  52. // Create user first
  53. const createResponse = await request.post('/api/users', {
  54. data: { name: 'Jane Doe', email: 'jane@example.com' },
  55. });
  56. const { id } = await createResponse.json();
  57. // Get user
  58. const getResponse = await request.get(`/api/users/${id}`);
  59. expect(getResponse.status()).toBe(200);
  60. const user = await getResponse.json();
  61. expect(user.id).toBe(id);
  62. expect(user.name).toBe('Jane Doe');
  63. });
  64. test('should return 404 for non-existent user', async ({ request }) => {
  65. const response = await request.get('/api/users/non-existent-id');
  66. expect(response.status()).toBe(404);
  67. const error = await response.json();
  68. expect(error.code).toBe('USER_NOT_FOUND');
  69. });
  70. test('should validate required fields', async ({ request }) => {
  71. const response = await request.post('/api/users', {
  72. data: { name: 'Missing Email' }, // email is required
  73. });
  74. expect(response.status()).toBe(400);
  75. const error = await response.json();
  76. expect(error.code).toBe('VALIDATION_ERROR');
  77. expect(error.details).toContainEqual(expect.objectContaining({ field: 'email', message: expect.any(String) }));
  78. });
  79. });
  80. ```
  81. **Key Points**:
  82. - No `page` fixture needed - only `request`
  83. - Tests run without browser overhead
  84. - Direct HTTP assertions
  85. - Clear error handling tests
  86. ### Example 2: API Test with apiRequest Utility
  87. **Context**: Use enhanced apiRequest for schema validation, retry, and type safety.
  88. **Implementation**:
  89. ```typescript
  90. // tests/api/orders.spec.ts
  91. import { test, expect } from '@seontechnologies/playwright-utils/api-request/fixtures';
  92. import { z } from 'zod';
  93. // Define schema for type safety and validation
  94. const OrderSchema = z.object({
  95. id: z.string().uuid(),
  96. userId: z.string(),
  97. items: z.array(
  98. z.object({
  99. productId: z.string(),
  100. quantity: z.number().positive(),
  101. price: z.number().positive(),
  102. }),
  103. ),
  104. total: z.number().positive(),
  105. status: z.enum(['pending', 'processing', 'shipped', 'delivered']),
  106. createdAt: z.string().datetime(),
  107. });
  108. type Order = z.infer<typeof OrderSchema>;
  109. test.describe('Orders API', () => {
  110. test('should create order with schema validation', async ({ apiRequest }) => {
  111. const { status, body } = await apiRequest<Order>({
  112. method: 'POST',
  113. path: '/api/orders',
  114. body: {
  115. userId: 'user-123',
  116. items: [
  117. { productId: 'prod-1', quantity: 2, price: 29.99 },
  118. { productId: 'prod-2', quantity: 1, price: 49.99 },
  119. ],
  120. },
  121. validateSchema: OrderSchema, // Validates response matches schema
  122. });
  123. expect(status).toBe(201);
  124. expect(body.id).toBeDefined();
  125. expect(body.status).toBe('pending');
  126. expect(body.total).toBe(109.97); // 2*29.99 + 49.99
  127. });
  128. test('should handle server errors with retry', async ({ apiRequest }) => {
  129. // apiRequest retries 5xx errors by default
  130. const { status, body } = await apiRequest({
  131. method: 'GET',
  132. path: '/api/orders/order-123',
  133. retryConfig: {
  134. maxRetries: 3,
  135. retryDelay: 1000,
  136. },
  137. });
  138. expect(status).toBe(200);
  139. });
  140. test('should list orders with pagination', async ({ apiRequest }) => {
  141. const { status, body } = await apiRequest<{ orders: Order[]; total: number; page: number }>({
  142. method: 'GET',
  143. path: '/api/orders',
  144. params: { page: 1, limit: 10, status: 'pending' },
  145. });
  146. expect(status).toBe(200);
  147. expect(body.orders).toHaveLength(10);
  148. expect(body.total).toBeGreaterThan(10);
  149. expect(body.page).toBe(1);
  150. });
  151. });
  152. ```
  153. **Key Points**:
  154. - Zod schema for runtime validation AND TypeScript types
  155. - `validateSchema` throws if response doesn't match
  156. - Built-in retry for transient failures
  157. - Type-safe `body` access
  158. - **Note**: If your project uses code-generated operations from an OpenAPI spec, see [Example 8](#example-8-operation-based-api-testing-openapi--code-generators) for the preferred `operation`-based overload (v3.14.0+)
  159. ### Example 3: Microservice-to-Microservice Testing
  160. **Context**: Test service interactions without browser - validate API contracts between services.
  161. **Implementation**:
  162. ```typescript
  163. // tests/api/service-integration.spec.ts
  164. import { test, expect } from '@seontechnologies/playwright-utils/fixtures';
  165. test.describe('Service Integration', () => {
  166. const USER_SERVICE_URL = process.env.USER_SERVICE_URL || 'http://localhost:3001';
  167. const ORDER_SERVICE_URL = process.env.ORDER_SERVICE_URL || 'http://localhost:3002';
  168. const INVENTORY_SERVICE_URL = process.env.INVENTORY_SERVICE_URL || 'http://localhost:3003';
  169. test('order service should validate user exists', async ({ apiRequest }) => {
  170. // Create user in user-service
  171. const { body: user } = await apiRequest({
  172. method: 'POST',
  173. path: '/api/users',
  174. baseUrl: USER_SERVICE_URL,
  175. body: { name: 'Test User', email: 'test@example.com' },
  176. });
  177. // Create order in order-service (should validate user via user-service)
  178. const { status, body: order } = await apiRequest({
  179. method: 'POST',
  180. path: '/api/orders',
  181. baseUrl: ORDER_SERVICE_URL,
  182. body: {
  183. userId: user.id,
  184. items: [{ productId: 'prod-1', quantity: 1 }],
  185. },
  186. });
  187. expect(status).toBe(201);
  188. expect(order.userId).toBe(user.id);
  189. });
  190. test('order service should reject invalid user', async ({ apiRequest }) => {
  191. const { status, body } = await apiRequest({
  192. method: 'POST',
  193. path: '/api/orders',
  194. baseUrl: ORDER_SERVICE_URL,
  195. body: {
  196. userId: 'non-existent-user',
  197. items: [{ productId: 'prod-1', quantity: 1 }],
  198. },
  199. });
  200. expect(status).toBe(400);
  201. expect(body.code).toBe('INVALID_USER');
  202. });
  203. test('order should decrease inventory', async ({ apiRequest, recurse }) => {
  204. // Get initial inventory
  205. const { body: initialInventory } = await apiRequest({
  206. method: 'GET',
  207. path: '/api/inventory/prod-1',
  208. baseUrl: INVENTORY_SERVICE_URL,
  209. });
  210. // Create order
  211. await apiRequest({
  212. method: 'POST',
  213. path: '/api/orders',
  214. baseUrl: ORDER_SERVICE_URL,
  215. body: {
  216. userId: 'user-123',
  217. items: [{ productId: 'prod-1', quantity: 2 }],
  218. },
  219. });
  220. // Poll for inventory update (eventual consistency)
  221. const { body: updatedInventory } = await recurse(
  222. () =>
  223. apiRequest({
  224. method: 'GET',
  225. path: '/api/inventory/prod-1',
  226. baseUrl: INVENTORY_SERVICE_URL,
  227. }),
  228. (response) => response.body.quantity === initialInventory.quantity - 2,
  229. { timeout: 10000, interval: 500 },
  230. );
  231. expect(updatedInventory.quantity).toBe(initialInventory.quantity - 2);
  232. });
  233. });
  234. ```
  235. **Key Points**:
  236. - Multiple service URLs for microservice testing
  237. - Tests service-to-service communication
  238. - Uses `recurse` for eventual consistency
  239. - No browser needed for full integration testing
  240. ### Example 4: GraphQL API Testing
  241. **Context**: Test GraphQL endpoints with queries and mutations.
  242. **Implementation**:
  243. ```typescript
  244. // tests/api/graphql.spec.ts
  245. import { test, expect } from '@seontechnologies/playwright-utils/api-request/fixtures';
  246. const GRAPHQL_ENDPOINT = '/graphql';
  247. test.describe('GraphQL API', () => {
  248. test('should query users', async ({ apiRequest }) => {
  249. const query = `
  250. query GetUsers($limit: Int) {
  251. users(limit: $limit) {
  252. id
  253. name
  254. email
  255. role
  256. }
  257. }
  258. `;
  259. const { status, body } = await apiRequest({
  260. method: 'POST',
  261. path: GRAPHQL_ENDPOINT,
  262. body: {
  263. query,
  264. variables: { limit: 10 },
  265. },
  266. });
  267. expect(status).toBe(200);
  268. expect(body.errors).toBeUndefined();
  269. expect(body.data.users).toHaveLength(10);
  270. expect(body.data.users[0]).toHaveProperty('id');
  271. expect(body.data.users[0]).toHaveProperty('name');
  272. });
  273. test('should create user via mutation', async ({ apiRequest }) => {
  274. const mutation = `
  275. mutation CreateUser($input: CreateUserInput!) {
  276. createUser(input: $input) {
  277. id
  278. name
  279. email
  280. }
  281. }
  282. `;
  283. const { status, body } = await apiRequest({
  284. method: 'POST',
  285. path: GRAPHQL_ENDPOINT,
  286. body: {
  287. query: mutation,
  288. variables: {
  289. input: {
  290. name: 'GraphQL User',
  291. email: 'graphql@example.com',
  292. },
  293. },
  294. },
  295. });
  296. expect(status).toBe(200);
  297. expect(body.errors).toBeUndefined();
  298. expect(body.data.createUser.id).toBeDefined();
  299. expect(body.data.createUser.name).toBe('GraphQL User');
  300. });
  301. test('should handle GraphQL errors', async ({ apiRequest }) => {
  302. const query = `
  303. query GetUser($id: ID!) {
  304. user(id: $id) {
  305. id
  306. name
  307. }
  308. }
  309. `;
  310. const { status, body } = await apiRequest({
  311. method: 'POST',
  312. path: GRAPHQL_ENDPOINT,
  313. body: {
  314. query,
  315. variables: { id: 'non-existent' },
  316. },
  317. });
  318. expect(status).toBe(200); // GraphQL returns 200 even for errors
  319. expect(body.errors).toBeDefined();
  320. expect(body.errors[0].message).toContain('not found');
  321. expect(body.data.user).toBeNull();
  322. });
  323. test('should handle validation errors', async ({ apiRequest }) => {
  324. const mutation = `
  325. mutation CreateUser($input: CreateUserInput!) {
  326. createUser(input: $input) {
  327. id
  328. }
  329. }
  330. `;
  331. const { status, body } = await apiRequest({
  332. method: 'POST',
  333. path: GRAPHQL_ENDPOINT,
  334. body: {
  335. query: mutation,
  336. variables: {
  337. input: {
  338. name: '', // Invalid: empty name
  339. email: 'invalid-email', // Invalid: bad format
  340. },
  341. },
  342. },
  343. });
  344. expect(status).toBe(200);
  345. expect(body.errors).toBeDefined();
  346. expect(body.errors[0].extensions.code).toBe('BAD_USER_INPUT');
  347. });
  348. });
  349. ```
  350. **Key Points**:
  351. - GraphQL queries and mutations via POST
  352. - Variables passed in request body
  353. - GraphQL returns 200 even for errors (check `body.errors`)
  354. - Test validation and business logic errors
  355. ### Example 5: Database Seeding and Cleanup via API
  356. **Context**: Use API calls to set up and tear down test data without direct database access.
  357. **Implementation**:
  358. ```typescript
  359. // tests/api/with-data-setup.spec.ts
  360. import { test, expect } from '@seontechnologies/playwright-utils/fixtures';
  361. test.describe('Orders with Data Setup', () => {
  362. let testUser: { id: string; email: string };
  363. let testProducts: Array<{ id: string; name: string; price: number }>;
  364. test.beforeAll(async ({ request }) => {
  365. // Seed user via API
  366. const userResponse = await request.post('/api/users', {
  367. data: {
  368. name: 'Test User',
  369. email: `test-${Date.now()}@example.com`,
  370. },
  371. });
  372. testUser = await userResponse.json();
  373. // Seed products via API
  374. testProducts = [];
  375. for (const product of [
  376. { name: 'Widget A', price: 29.99 },
  377. { name: 'Widget B', price: 49.99 },
  378. { name: 'Widget C', price: 99.99 },
  379. ]) {
  380. const productResponse = await request.post('/api/products', {
  381. data: product,
  382. });
  383. testProducts.push(await productResponse.json());
  384. }
  385. });
  386. test.afterAll(async ({ request }) => {
  387. // Cleanup via API
  388. if (testUser?.id) {
  389. await request.delete(`/api/users/${testUser.id}`);
  390. }
  391. for (const product of testProducts) {
  392. await request.delete(`/api/products/${product.id}`);
  393. }
  394. });
  395. test('should create order with seeded data', async ({ apiRequest }) => {
  396. const { status, body } = await apiRequest({
  397. method: 'POST',
  398. path: '/api/orders',
  399. body: {
  400. userId: testUser.id,
  401. items: [
  402. { productId: testProducts[0].id, quantity: 2 },
  403. { productId: testProducts[1].id, quantity: 1 },
  404. ],
  405. },
  406. });
  407. expect(status).toBe(201);
  408. expect(body.userId).toBe(testUser.id);
  409. expect(body.items).toHaveLength(2);
  410. expect(body.total).toBe(2 * 29.99 + 49.99);
  411. });
  412. test('should list user orders', async ({ apiRequest }) => {
  413. // Create an order first
  414. await apiRequest({
  415. method: 'POST',
  416. path: '/api/orders',
  417. body: {
  418. userId: testUser.id,
  419. items: [{ productId: testProducts[2].id, quantity: 1 }],
  420. },
  421. });
  422. // List orders for user
  423. const { status, body } = await apiRequest({
  424. method: 'GET',
  425. path: '/api/orders',
  426. params: { userId: testUser.id },
  427. });
  428. expect(status).toBe(200);
  429. expect(body.orders.length).toBeGreaterThanOrEqual(1);
  430. expect(body.orders.every((o: any) => o.userId === testUser.id)).toBe(true);
  431. });
  432. });
  433. ```
  434. **Key Points**:
  435. - `beforeAll`/`afterAll` for test data setup/cleanup
  436. - API-based seeding (no direct DB access needed)
  437. - Unique emails to prevent conflicts in parallel runs
  438. - Cleanup after all tests complete
  439. ### Example 6: Background Job Testing with Recurse
  440. **Context**: Test async operations like background jobs, webhooks, and eventual consistency.
  441. **Implementation**:
  442. ```typescript
  443. // tests/api/background-jobs.spec.ts
  444. import { test, expect } from '@seontechnologies/playwright-utils/fixtures';
  445. test.describe('Background Jobs', () => {
  446. test('should process export job', async ({ apiRequest, recurse }) => {
  447. // Trigger export job
  448. const { body: job } = await apiRequest({
  449. method: 'POST',
  450. path: '/api/exports',
  451. body: {
  452. type: 'users',
  453. format: 'csv',
  454. filters: { createdAfter: '2024-01-01' },
  455. },
  456. });
  457. expect(job.id).toBeDefined();
  458. expect(job.status).toBe('pending');
  459. // Poll until job completes
  460. const { body: completedJob } = await recurse(
  461. () => apiRequest({ method: 'GET', path: `/api/exports/${job.id}` }),
  462. (response) => response.body.status === 'completed',
  463. {
  464. timeout: 60000,
  465. interval: 2000,
  466. log: `Waiting for export job ${job.id} to complete`,
  467. },
  468. );
  469. expect(completedJob.status).toBe('completed');
  470. expect(completedJob.downloadUrl).toBeDefined();
  471. expect(completedJob.recordCount).toBeGreaterThan(0);
  472. });
  473. test('should handle job failure gracefully', async ({ apiRequest, recurse }) => {
  474. // Trigger job that will fail
  475. const { body: job } = await apiRequest({
  476. method: 'POST',
  477. path: '/api/exports',
  478. body: {
  479. type: 'invalid-type', // This will cause failure
  480. format: 'csv',
  481. },
  482. });
  483. // Poll until job fails
  484. const { body: failedJob } = await recurse(
  485. () => apiRequest({ method: 'GET', path: `/api/exports/${job.id}` }),
  486. (response) => ['completed', 'failed'].includes(response.body.status),
  487. { timeout: 30000 },
  488. );
  489. expect(failedJob.status).toBe('failed');
  490. expect(failedJob.error).toBeDefined();
  491. expect(failedJob.error.code).toBe('INVALID_EXPORT_TYPE');
  492. });
  493. test('should process webhook delivery', async ({ apiRequest, recurse }) => {
  494. // Trigger action that sends webhook
  495. const { body: order } = await apiRequest({
  496. method: 'POST',
  497. path: '/api/orders',
  498. body: {
  499. userId: 'user-123',
  500. items: [{ productId: 'prod-1', quantity: 1 }],
  501. webhookUrl: 'https://webhook.site/test-endpoint',
  502. },
  503. });
  504. // Poll for webhook delivery status
  505. const { body: webhookStatus } = await recurse(
  506. () => apiRequest({ method: 'GET', path: `/api/webhooks/order/${order.id}` }),
  507. (response) => response.body.delivered === true,
  508. { timeout: 30000, interval: 1000 },
  509. );
  510. expect(webhookStatus.delivered).toBe(true);
  511. expect(webhookStatus.deliveredAt).toBeDefined();
  512. expect(webhookStatus.responseStatus).toBe(200);
  513. });
  514. });
  515. ```
  516. **Key Points**:
  517. - `recurse` for polling async operations
  518. - Test both success and failure scenarios
  519. - Configurable timeout and interval
  520. - Log messages for debugging
  521. ### Example 7: Service Authentication (No Browser)
  522. **Context**: Test authenticated API endpoints using tokens directly - no browser login needed.
  523. **Implementation**:
  524. ```typescript
  525. // tests/api/authenticated.spec.ts
  526. import { test, expect } from '@seontechnologies/playwright-utils/fixtures';
  527. test.describe('Authenticated API Tests', () => {
  528. let authToken: string;
  529. test.beforeAll(async ({ request }) => {
  530. // Get token via API (no browser!)
  531. const response = await request.post('/api/auth/login', {
  532. data: {
  533. email: process.env.TEST_USER_EMAIL,
  534. password: process.env.TEST_USER_PASSWORD,
  535. },
  536. });
  537. const { token } = await response.json();
  538. authToken = token;
  539. });
  540. test('should access protected endpoint with token', async ({ apiRequest }) => {
  541. const { status, body } = await apiRequest({
  542. method: 'GET',
  543. path: '/api/me',
  544. headers: {
  545. Authorization: `Bearer ${authToken}`,
  546. },
  547. });
  548. expect(status).toBe(200);
  549. expect(body.email).toBe(process.env.TEST_USER_EMAIL);
  550. });
  551. test('should reject request without token', async ({ apiRequest }) => {
  552. const { status, body } = await apiRequest({
  553. method: 'GET',
  554. path: '/api/me',
  555. // No Authorization header
  556. });
  557. expect(status).toBe(401);
  558. expect(body.code).toBe('UNAUTHORIZED');
  559. });
  560. test('should reject expired token', async ({ apiRequest }) => {
  561. const expiredToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...'; // Expired token
  562. const { status, body } = await apiRequest({
  563. method: 'GET',
  564. path: '/api/me',
  565. headers: {
  566. Authorization: `Bearer ${expiredToken}`,
  567. },
  568. });
  569. expect(status).toBe(401);
  570. expect(body.code).toBe('TOKEN_EXPIRED');
  571. });
  572. test('should handle role-based access', async ({ apiRequest }) => {
  573. // User token (non-admin)
  574. const { status } = await apiRequest({
  575. method: 'GET',
  576. path: '/api/admin/users',
  577. headers: {
  578. Authorization: `Bearer ${authToken}`,
  579. },
  580. });
  581. expect(status).toBe(403); // Forbidden for non-admin
  582. });
  583. });
  584. ```
  585. **Key Points**:
  586. - Token obtained via API login (no browser)
  587. - Token reused across all tests in describe block
  588. - Test auth, expired tokens, and RBAC
  589. - Pure API testing without UI
  590. ### Example 8: Operation-Based API Testing (OpenAPI / Code Generators)
  591. **Context**: When your project uses code-generated operation definitions from an OpenAPI spec, leverage the operation-based overload of `apiRequest` (v3.14.0+) instead of manual `method`/`path` extraction. This eliminates `typeof` assertions and provides full type inference for request body, response, and query parameters.
  592. **Implementation**:
  593. ```typescript
  594. // tests/api/operations.spec.ts
  595. import { test, expect } from '@seontechnologies/playwright-utils/api-request/fixtures';
  596. test.describe('API Tests with Generated Operations', () => {
  597. test('should create entity with full type safety', async ({ apiRequest }) => {
  598. // Operation object from code generator — contains path, method, and type info
  599. const { status, body } = await apiRequest({
  600. operation: createEntityOp({ workspaceId }),
  601. headers: getHeaders(workspaceId),
  602. body: entityInput, // Compile-time typed from operation.request
  603. });
  604. expect(status).toBe(201);
  605. expect(body.id).toBeDefined(); // body typed from operation.response
  606. });
  607. test('should list with typed query parameters', async ({ apiRequest }) => {
  608. // query field replaces manual string concatenation
  609. const { body } = await apiRequest({
  610. operation: listEntitiesOp({ workspaceId }),
  611. headers: getHeaders(workspaceId),
  612. query: { page: 0, page_size: 10, status: 'active' },
  613. });
  614. expect(body.items).toHaveLength(10);
  615. expect(body.total).toBeGreaterThan(10);
  616. });
  617. test('should poll async operation until complete', async ({ apiRequest, recurse }) => {
  618. const { body: job } = await apiRequest({
  619. operation: startJobOp({ workspaceId }),
  620. headers: getHeaders(workspaceId),
  621. body: { type: 'export' },
  622. });
  623. await recurse(
  624. async () =>
  625. apiRequest({
  626. operation: getJobOp({ workspaceId, jobId: job.id }),
  627. headers: getHeaders(workspaceId),
  628. }),
  629. (res) => res.body.status === 'completed',
  630. { timeout: 60000, interval: 2000 },
  631. );
  632. });
  633. });
  634. ```
  635. **Key Points**:
  636. - `operation` replaces `method` + `path` — mutually exclusive at compile time
  637. - Types for body, response, and query all inferred from the operation definition
  638. - Works with any code generator using structural typing (no imports from playwright-utils needed in generator)
  639. - Composable with `recurse`, `validateSchema`, and all existing `apiRequest` features
  640. - Preferred approach over `typeof operation.response` for generated operations
  641. ## API Test Configuration
  642. ### Playwright Config for API-Only Tests
  643. ```typescript
  644. // playwright.config.ts
  645. import { defineConfig } from '@playwright/test';
  646. export default defineConfig({
  647. testDir: './tests/api',
  648. // No browser needed for API tests
  649. use: {
  650. baseURL: process.env.API_URL || 'http://localhost:3000',
  651. extraHTTPHeaders: {
  652. Accept: 'application/json',
  653. 'Content-Type': 'application/json',
  654. },
  655. },
  656. // Faster without browser overhead
  657. timeout: 30000,
  658. // Run API tests in parallel
  659. workers: 4,
  660. fullyParallel: true,
  661. // No screenshots/traces needed for API tests
  662. reporter: [['html'], ['json', { outputFile: 'api-test-results.json' }]],
  663. });
  664. ```
  665. ### Separate API Test Project
  666. ```typescript
  667. // playwright.config.ts
  668. export default defineConfig({
  669. projects: [
  670. {
  671. name: 'api',
  672. testDir: './tests/api',
  673. use: {
  674. baseURL: process.env.API_URL,
  675. },
  676. },
  677. {
  678. name: 'e2e',
  679. testDir: './tests/e2e',
  680. use: {
  681. baseURL: process.env.APP_URL,
  682. ...devices['Desktop Chrome'],
  683. },
  684. },
  685. ],
  686. });
  687. ```
  688. ## Comparison: API Tests vs E2E Tests
  689. | Aspect | API Test | E2E Test |
  690. | ------------------- | ---------------------- | --------------------------- |
  691. | **Speed** | ~50-100ms per test | ~2-10s per test |
  692. | **Stability** | Very stable | More flaky (UI timing) |
  693. | **Setup** | Minimal | Browser, context, page |
  694. | **Debugging** | Clear request/response | DOM, screenshots, traces |
  695. | **Coverage** | Service logic | User experience |
  696. | **Parallelization** | Easy (stateless) | Complex (browser resources) |
  697. | **CI Cost** | Low (no browser) | High (browser containers) |
  698. ## Related Fragments
  699. - `api-request.md` - apiRequest utility details
  700. - `recurse.md` - Polling patterns for async operations
  701. - `auth-session.md` - Token management
  702. - `contract-testing.md` - Pact contract testing
  703. - `test-levels-framework.md` - When to use which test level
  704. - `data-factories.md` - Test data setup patterns
  705. ## Anti-Patterns
  706. **DON'T use E2E for API validation:**
  707. ```typescript
  708. // Bad: Testing API through UI
  709. test('validate user creation', async ({ page }) => {
  710. await page.goto('/admin/users');
  711. await page.fill('#name', 'John');
  712. await page.click('#submit');
  713. await expect(page.getByText('User created')).toBeVisible();
  714. });
  715. ```
  716. **DO test APIs directly:**
  717. ```typescript
  718. // Good: Direct API test
  719. test('validate user creation', async ({ apiRequest }) => {
  720. const { status, body } = await apiRequest({
  721. method: 'POST',
  722. path: '/api/users',
  723. body: { name: 'John' },
  724. });
  725. expect(status).toBe(201);
  726. expect(body.id).toBeDefined();
  727. });
  728. ```
  729. **DON'T ignore API tests because "E2E covers it":**
  730. ```typescript
  731. // Bad thinking: "Our E2E tests create users, so API is tested"
  732. // Reality: E2E tests one happy path; API tests cover edge cases
  733. ```
  734. **DO have dedicated API test coverage:**
  735. ```typescript
  736. // Good: Explicit API test suite
  737. test.describe('Users API', () => {
  738. test('creates user', async ({ apiRequest }) => {
  739. /* ... */
  740. });
  741. test('handles duplicate email', async ({ apiRequest }) => {
  742. /* ... */
  743. });
  744. test('validates required fields', async ({ apiRequest }) => {
  745. /* ... */
  746. });
  747. test('handles malformed JSON', async ({ apiRequest }) => {
  748. /* ... */
  749. });
  750. test('rate limits requests', async ({ apiRequest }) => {
  751. /* ... */
  752. });
  753. });
  754. ```