QA Labs
QA Labs
Phase 4

Kata 24: API-First Testing

Kata 24: API-First Testing

What You Will Learn

  • How to send HTTP requests directly from your test framework (no browser UI)
  • How to verify response body, status codes, and headers
  • How to send POST requests with JSON payloads
  • How to chain API calls (create a resource, then fetch it)
  • How to test error responses from APIs
  • How to send custom headers (e.g., authentication tokens)
  • How to run parameterized tests across multiple API inputs
  • Differences between Playwright's request context and Cypress's cy.request()

Prerequisites

  • Completed Katas 22-23 (Network Interception, API Mocking)
  • Understanding of HTTP methods, status codes, and JSON
  • Understanding of REST API conventions (GET, POST, PUT, DELETE)

Concepts Explained

API-First Testing

API-first testing means testing the API layer directly — sending real
HTTP requests and verifying responses — WITHOUT loading a browser page.

Why test APIs directly?
  1. Speed  — API tests run 10-100x faster than UI tests
  2. Scope  — catch backend bugs before the UI is involved
  3. Setup  — seed test data via API before running UI tests
  4. Coverage — test error paths that are hard to trigger from the UI

This is NOT the same as mocking (Kata 22-23). Here we hit real endpoints
and verify real responses. We use a public test API (jsonplaceholder)
since we don't have our own backend.

Playwright: request Context

// PLAYWRIGHT
// Playwright provides a built-in `request` fixture for making HTTP requests
// without a browser page. It acts like a lightweight HTTP client.
//
// Key methods:
//   request.get(url, options?)   — send a GET request
//   request.post(url, options?)  — send a POST request
//   request.put(url, options?)   — send a PUT request
//   request.delete(url, options?)— send a DELETE request
//
// Options:
//   data    — request body (object, string, or Buffer)
//   headers — custom headers as an object
//   params  — URL query parameters as an object
//
// The response object has:
//   response.status()  — HTTP status code (number)
//   response.headers() — response headers (object)
//   response.json()    — parse body as JSON (Promise)
//   response.text()    — body as string (Promise)
//   response.ok()      — true if status is 200-299

// Example: GET request
const response = await request.get('https://jsonplaceholder.typicode.com/posts/1');
expect(response.status()).toBe(200);
const post = await response.json();
expect(post.id).toBe(1);

Cypress: cy.request()

// CYPRESS
// cy.request() sends an HTTP request from the Cypress test runner.
// It does NOT go through the browser — it's sent directly from Node.js.
//
// Signatures:
//   cy.request(url)                     — simple GET
//   cy.request(method, url)             — GET/POST/PUT/DELETE
//   cy.request(method, url, body)       — with request body
//   cy.request({ method, url, headers, body, ... }) — full options
//
// The yielded response has:
//   response.status  — HTTP status code
//   response.headers — response headers
//   response.body    — parsed response body (auto-parsed from JSON)
//   response.duration — request duration in ms
//
// By default, cy.request() fails on non-2xx status codes. Use
// failOnStatusCode: false to test error responses.

// Example: GET request
cy.request('https://jsonplaceholder.typicode.com/posts/1')
  .its('status').should('eq', 200);

cy.request('https://jsonplaceholder.typicode.com/posts/1')
  .its('body').should('have.property', 'id', 1);

Setting a Base URL

// PLAYWRIGHT
// Use test.use() to set apiBaseURL so you don't repeat it in every call.
test.use({ baseURL: 'https://jsonplaceholder.typicode.com' });
// Then use relative paths:
const response = await request.get('/posts/1');

// CYPRESS
// Set baseUrl in cypress.config.ts or override per-test:
// In the test, use relative URLs:
cy.request('/posts/1');

Playground

This kata has a reference page instead of an interactive playground. The HTML page explains the API-first testing concept and lists the endpoints used in the exercises.

The exercises use JSONPlaceholder (https://jsonplaceholder.typicode.com), a free public REST API that provides fake data for testing.

Exercises

Exercise 1: GET Request and Verify Response Body

Send a GET request to /posts/1 and verify the response body contains id: 1, and has userId, title, and body properties.

Exercise 2: POST Request with JSON Body

Send a POST request to /posts with a JSON body { title, body, userId }. Verify the response contains the posted data and includes a new id field.

Exercise 3: Verify Response Status Code

Send GET requests to /posts/1 (should return 200) and /posts/9999 (should return 404). Verify the correct status codes.

Exercise 4: Verify Response Headers

Send a GET request to /posts/1 and verify the response headers contain content-type: application/json.

Exercise 5: Chain Requests (Create Then Get)

Send a POST request to create a new post, capture the returned userId, then send a GET request to /users/:userId to fetch the user who created it. Verify the user data.

Exercise 6: Verify Error Responses

Send a GET request to /posts/0 (invalid ID). Verify the response returns a 404 status code and an empty object body.

Exercise 7: Send Custom Headers

Send a GET request to /posts/1 with a custom header X-Request-Source: qa-test. Verify the request succeeds (the test API ignores unknown headers, but this exercises the header-sending mechanism).

Exercise 8: Parameterized API Tests

Test multiple posts (IDs 1 through 5) in a loop. For each, send a GET request and verify the response has the matching id and all required fields.

Solutions

Playwright Solution

See playwright/api-first-testing.spec.ts

Cypress Solution

See cypress/api-first-testing.cy.ts

Common Mistakes

MistakeWhy it's wrongFix
Forgetting await on response.json() in Playwright.json() returns a Promise; without await you get a Promise object, not the dataAlways await response.json()
Using cy.request() without failOnStatusCode: false for error testsCypress throws an error on non-2xx responses by defaultAdd failOnStatusCode: false when testing 4xx/5xx responses
Hardcoding response values that might changePublic APIs may update their dataAssert on structure (has property) not exact values where possible
Not checking both status AND bodyA 200 status doesn't guarantee the body is correctAlways verify both status code and response body
Confusing request (API client) with page (browser) in Playwrightrequest.get() sends a direct HTTP call; page.goto() loads a browser pageUse request for API tests, page for UI tests
Sending body with GET requestsGET requests should not have a body per HTTP specUse query parameters or URL path segments for GET request data

Quick Reference

Playwright API Testing

ActionMethodExample
GET requestrequest.get(url)const r = await request.get('/posts/1')
POST requestrequest.post(url, { data })const r = await request.post('/posts', { data: { title: '...' } })
Check statusresponse.status()expect(r.status()).toBe(200)
Parse JSON bodyresponse.json()const data = await r.json()
Check headersresponse.headers()const h = r.headers()
Set base URLtest.use({ baseURL })test.use({ baseURL: 'https://...' })
Custom headersrequest.get(url, { headers })request.get('/posts', { headers: { 'X-Foo': 'bar' } })

Cypress API Testing

ActionMethodExample
GET requestcy.request(url)cy.request('/posts/1')
POST requestcy.request('POST', url, body)cy.request('POST', '/posts', { title: '...' })
Check status.its('status').its('status').should('eq', 200)
Check body.its('body').its('body').should('have.property', 'id')
Check headers.its('headers').its('headers').its('content-type')
Allow errorsfailOnStatusCode: falsecy.request({ url, failOnStatusCode: false })
Custom headerscy.request({ headers })cy.request({ url, headers: { 'X-Foo': 'bar' } })