API Testing Concepts
The vocabulary, patterns, and trade-offs you need before opening Postman, REST Assured, or any API testing tool.
REST API Fundamentals
REST organizes an API around resources (nouns) accessed via standard HTTP methods (verbs).
Resource-based URLs
GET /users # list
GET /users/123 # one user
GET /users/123/orders # nested resource — orders for user 123
POST /users # create
PUT /users/123 # replace
PATCH /users/123 # partial update
DELETE /users/123 # removeStateless
Each request carries everything the server needs — auth token, body, params. The server doesn't store client session state between calls. (Cookies and sessions are technically a state store, but in well-designed REST they're identifiers, not state.)
Methods → CRUD
| HTTP method | CRUD operation |
|---|---|
POST | Create |
GET | Read |
PUT / PATCH | Update |
DELETE | Delete |
Response formats
JSON is overwhelmingly dominant. XML still appears in older enterprise APIs and SOAP-style integrations. The Accept header is how a client requests a particular format:
Accept: application/json
Accept: application/xmlHATEOAS (Hypermedia)
Mature REST APIs include links in responses so clients can discover related actions:
{
"id": 123,
"name": "Ada",
"_links": {
"self": { "href": "/users/123" },
"orders": { "href": "/users/123/orders" },
"edit": { "href": "/users/123", "method": "PUT" }
}
}Most APIs in practice are "RESTish" rather than fully HATEOAS-compliant.
HTTP Methods
GET
GET /users/123
Accept: application/json- Idempotent — calling N times has the same effect as calling once.
- Safe — must not change server state.
- No request body (servers typically ignore one).
- Cacheable.
POST
POST /users
Content-Type: application/json
{ "name": "Ada", "email": "ada@example.com" }- Not idempotent — two calls create two resources.
- Returns
201 Createdwith the new URL inLocation(and usually the created body):
HTTP/1.1 201 Created
Location: /users/123
Content-Type: application/json
{ "id": 123, "name": "Ada", "email": "ada@example.com" }PUT
PUT /users/123
Content-Type: application/json
{ "id": 123, "name": "Ada", "email": "new@example.com" }- Idempotent — same body in, same state out, no matter how many calls.
- Replaces the entire resource. Fields you omit are typically reset to defaults / null.
PATCH
PATCH /users/123
Content-Type: application/json
{ "email": "new@example.com" }- Partial update — send only the fields that change.
- Not necessarily idempotent (e.g.
{ "counter": "+1" }is not). - Most APIs use a JSON Merge Patch (RFC 7396) or JSON Patch (RFC 6902).
DELETE
DELETE /users/123- Idempotent in spec — second
DELETEshould still return success (or404). - Returns
200 OKwith body, or204 No Contentwithout.
HEAD
Same as GET but the server returns headers only, no body. Useful to check if a resource exists or to inspect Content-Length/ETag cheaply.
HEAD /downloads/big-file.zipOPTIONS
Returns the allowed methods on a resource. Browsers fire it automatically as a CORS preflight before cross-origin requests with custom headers or non-simple methods.
OPTIONS /usersHTTP/1.1 204 No Content
Allow: GET, POST, OPTIONS
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Authorization, Content-TypeCommon Headers
Request headers worth knowing
Content-Type: application/json # body format you're sending
Accept: application/json # body format you want back
Authorization: Bearer eyJhbGciOiJIUzI1... # credentials
User-Agent: MyTestSuite/1.0 # who's calling
Cache-Control: no-cache # bypass any cache
If-None-Match: "abc123" # conditional GET — return 304 if unchanged
If-Modified-Since: Tue, 03 May 2026 10:00:00 GMT
X-Request-ID: 8b1d3a-... # correlation across services
X-Forwarded-For: 203.0.113.42 # original client IP through a proxyResponse headers worth knowing
Content-Type: application/json; charset=utf-8
Location: /users/123 # where the new resource lives (after POST)
ETag: "abc123" # version tag for caching
X-RateLimit-Limit: 1000 # total quota in window
X-RateLimit-Remaining: 847 # what's left
X-RateLimit-Reset: 1746280800 # Unix time the window resets
Retry-After: 60 # how many seconds to wait before retrying
Set-Cookie: session=xyz; HttpOnly; Secure; SameSite=Lax
Cache-Control: max-age=3600, publicContent-Type values you'll see
application/json most APIs
application/xml legacy / SOAP
application/x-www-form-urlencoded classic HTML forms
multipart/form-data file uploads
application/octet-stream raw binary
text/plain plaintext
application/graphql-response+json GraphQL over HTTP
Authentication Types
API Key
Passed in a header (preferred) or query param:
GET /users
X-API-Key: sk_live_abc123def456GET /users?api_key=sk_live_abc123def456Rotate regularly. Don't log API keys. Don't put them in URLs that hit access logs unless you have to.
Bearer Token (JWT)
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjMifQ.signatureJWTs have three base64url-encoded parts separated by dots — header, payload (claims), signature. Decode at jwt.io (paste copies stay client-side, but never paste production tokens).
Test for:
- Expiry —
expclaim respected by the server - Algorithm pinning — server rejects
alg: noneand other-algorithm tokens - Signature validation — flipping a byte in the signature fails the request
- Claim tampering — flipping
suborroleand re-signing with the wrong key fails
Basic Auth
Authorization: Basic YWRhOnNlY3JldA==YWRhOnNlY3JldA== is base64 of ada:secret. Never use over plain HTTP — the credentials are trivially decoded. Prefer Bearer tokens or OAuth.
OAuth 2.0 — flow per use case
| Flow | When to use |
|---|---|
| Authorization Code + PKCE | Web apps and SPAs / mobile apps with a user. PKCE replaces client secret. |
| Client Credentials | Server-to-server / machine-to-machine. No user involved. |
| Device Code | TVs, CLI tools, devices without a browser |
| Refresh Token | Renew an access token without re-authenticating the user |
POST /oauth/token
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials
&client_id=abc
&client_secret=xyz
&scope=orders:read{
"access_token": "eyJ...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "..."
}Session / Cookie
The classic browser pattern: log in via a form, server sends Set-Cookie, browser sends it back automatically.
HTTP/1.1 200 OK
Set-Cookie: session=abc123; HttpOnly; Secure; SameSite=Lax; Max-Age=86400Subsequent requests:
GET /me
Cookie: session=abc123Test for HttpOnly (no JS access), Secure (HTTPS only), and SameSite (CSRF defense).
REST vs GraphQL
Side by side
| REST | GraphQL | |
|---|---|---|
| Endpoints | Many (/users, /orders, …) | One (/graphql) |
| Methods | GET / POST / PUT / etc | Almost always POST |
| Response shape | Server-defined, often fixed | Client-specified per query |
| Over/under-fetching | Common | Avoided by design |
| Caching | HTTP-native (ETag, Cache-Control) | Harder — needs custom layer |
| Schema | Optional (OpenAPI) | Mandatory and strongly typed |
| Errors | HTTP status codes | Always 200, errors in body |
| Discoverability | Docs / OpenAPI | Introspection built in |
A GraphQL query
query GetUser($id: ID!) {
user(id: $id) {
id
name
email
orders(limit: 5) {
id
total
status
}
}
}{
"query": "query GetUser($id: ID!) { user(id: $id) { id name email } }",
"variables": { "id": "123" }
}A GraphQL mutation
mutation CreateUser($input: UserInput!) {
createUser(input: $input) {
id
name
}
}Introspection
{
__schema {
types {
name
kind
fields { name type { name } }
}
}
}Often disabled in production for security. If it's enabled in prod and you find sensitive types or mutations, that's a finding.
Testing GraphQL — what's different
- HTTP status is almost always
200. Readerrors[]in the body, not the status code. - Authorization is per field. Test that a viewer can read
user.namebut notuser.emailif rules say so. - Query depth and complexity limits — send a deeply nested query (
user { friends { friends { friends { ... } } } }) and verify the server caps it. - Aliases let one query fire multiple operations. Test rate limits per-operation, not per-request.
- Persisted queries: if your server only accepts pre-registered query hashes, send an unrecognised one and verify it's rejected.
Query Parameters & Pagination
Filtering
GET /products?status=active&category=tools&min_price=10Sorting
GET /products?sort=created_at&order=desc
GET /products?sort=-created_at,name # alt convention: leading - = descPagination — offset
Simple, supports random page access. Bad on huge datasets — the database has to skip N rows for every request.
GET /products?page=2&per_page=20
GET /products?offset=20&limit=20Pagination — cursor (keyset)
Stable under inserts and deletes. Cursor is opaque (often base64-encoded). No "go to page 47" — only next/prev.
GET /products?cursor=eyJpZCI6MTIzfQ&limit=20{
"data": [/* ... */],
"pagination": {
"next_cursor": "eyJpZCI6MTQzfQ",
"has_more": true
}
}Field selection (sparse fieldsets)
GET /users?fields=id,name,email
GET /users?include=orders,profileSearch
GET /products?q=mountain%20bikeTest the fully encoded form, partial matches, empty string, special characters in the query, and queries that should match nothing.
Error Response Formats
Standard envelope
{
"error": {
"code": "VALIDATION_ERROR",
"message": "One or more fields are invalid.",
"details": [
{ "field": "email", "code": "INVALID_FORMAT" },
{ "field": "age", "code": "OUT_OF_RANGE", "min": 0, "max": 120 }
]
}
}Field-level errors (form-style)
{
"errors": {
"email": ["is required", "must be a valid email"],
"age": ["must be a positive integer"]
}
}RFC 7807 — Problem Details
The standard format for many enterprise APIs:
{
"type": "https://api.example.com/problems/validation",
"title": "Your request parameters didn't validate.",
"status": 422,
"detail": "age must be ≥ 0",
"instance": "/users/123",
"errors": [
{ "pointer": "/age", "code": "OUT_OF_RANGE" }
]
}Served with Content-Type: application/problem+json.
What to test in error responses
- The
codeis machine-readable (VALIDATION_ERROR), so clients can switch on it. - The
messageis human-readable and safe to surface to a user. - No stack traces in production responses.
- No sensitive data in error messages — no SQL fragments, no other users' data, no internal IPs.
- Consistent shape across endpoints. A 404 from
/users/xshould look structurally like a 404 from/orders/y. - Localized messages if your API supports it (
Accept-Languagehonored).
Contract Testing
Verify that API consumers and providers agree on the contract — request and response shape, semantics, status codes — independently and continuously.
Why bother
- Catch breaking changes before deploy. A field rename in the provider becomes a contract failure in CI rather than a 500 in production.
- Test independently. Front-end and back-end teams don't need each other's running services to validate compatibility.
- Document by example. The contract is executable.
Pact (consumer-driven)
The consumer records its expectations of the provider as a pact file. The provider verifies it can still satisfy those expectations.
// consumer test
provider
.uponReceiving("a request for user 123")
.withRequest({ method: "GET", path: "/users/123" })
.willRespondWith({
status: 200,
headers: { "Content-Type": "application/json" },
body: like({ id: 123, name: "Ada", email: "ada@example.com" }),
});The pact file is uploaded to a Pact Broker; the provider's CI replays it against the real service.
OpenAPI / Swagger validation
Use the spec as the contract. Validate every response in test runs:
// pseudo-code with an OpenAPI validator
expect(response).toSatisfyApiSpec();Tools: chai-openapi-response-validator, dredd, Spectral, Atlassian's swagger-request-validator.
JSON Schema validation
Lightweight alternative when no full spec exists — keep schemas next to your tests:
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"required": ["id", "name", "email"],
"properties": {
"id": { "type": "integer" },
"name": { "type": "string", "minLength": 1 },
"email": { "type": "string", "format": "email" }
},
"additionalProperties": false
}Set additionalProperties: false in tests so unknown fields fail loudly — that's how silent additions get caught.
Breaking changes worth a contract test
| Change | Why it breaks consumers |
|---|---|
| Field renamed | Old name disappears |
| Field removed | Consumers reading it get undefined |
Type changed (e.g. number → string) | Parsing / arithmetic fails downstream |
| Required field added to request | Old clients no longer pass validation |
| Optional field becomes required | Same as above |
| Enum value removed | Existing data with that value fails validation |
| URL path / method changed | Old clients hit 404 |
| Status code changed (e.g. 200 → 204) | Clients that read .body break |
| Authentication scheme changed | All old tokens stop working |
API Testing Checklist
Quick checklist for any endpoint you're testing.
Functional
- Happy path — valid input → expected status, body, and side effects
- Required fields — each missing one yields a clean 400/422
- Field types — string where number expected, etc.
- Field bounds — min/max length, min/max value, list size
- Defaults — fields you omit get the documented default
- Idempotency —
GET/PUT/DELETEcalled twice has the documented effect - Side effects — DB row exists, audit log entry written, email queued
Authentication & authorization
- No credentials →
401 - Bad credentials →
401 - Expired token →
401(not200or403) - Valid credentials, wrong scope →
403 - Valid creds for user A, accessing user B's resource →
403or404 - Refresh-token flow renews access without re-auth
Pagination & filters
- First page works
- Last page works (no off-by-one)
- Page beyond last → empty list with
200, not404 -
per_pagecapped at sensible max - Negative / non-integer page returns
400 - Filters compose: two filters AND together as documented
- Sorting respects ties consistently
Rate limiting
- Burst within limit → all
200 - Burst over limit →
429withRetry-After - Headers (
X-RateLimit-*) present and decreasing as expected - Limit resets on the documented schedule
Concurrency & idempotency
- Two
POSTs with the sameIdempotency-Keycreate one resource - Simultaneous updates don't corrupt state (last-write-wins or
ETag/If-Matchrespected) - Deleted resource that's referenced from another succeeds with documented behaviour
Performance
- Median latency under SLO
- Pagination doesn't degrade as you walk further
- Heavy filters don't time out
- N+1 not introduced (cardinality of DB calls reasonable)
Security
- Injection — SQL/NoSQL/command — strings with
',--,$where,$ne, etc. don't reach the engine raw - XSS in stored data —
<script>in a name field isn't reflected un-escaped on read - IDOR — incrementing an ID lets you read someone else's data
- Mass assignment — sending
"role": "admin"onPOST /usersdoesn't elevate - Method override —
X-HTTP-Method-Override: DELETEdoesn't bypass auth - Path traversal —
../in a path parameter is rejected - Open redirect —
?redirect_url=validates against an allowlist - CORS —
Access-Control-Allow-Originisn't*for authenticated endpoints - Headers — sensitive responses include
X-Content-Type-Options: nosniff,X-Frame-Options,Strict-Transport-Security