On this page9 sections
ConceptsIntermediate7-9 min reference

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           # remove

Stateless

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 methodCRUD operation
POSTCreate
GETRead
PUT / PATCHUpdate
DELETEDelete

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/xml

HATEOAS (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 Created with the new URL in Location (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 DELETE should still return success (or 404).
  • Returns 200 OK with body, or 204 No Content without.

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.zip

OPTIONS

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 /users
HTTP/1.1 204 No Content
Allow: GET, POST, OPTIONS
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Authorization, Content-Type

Common 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 proxy

Response 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, public

Content-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_abc123def456
GET /users?api_key=sk_live_abc123def456

Rotate 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.signature

JWTs 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:

  • Expiryexp claim respected by the server
  • Algorithm pinning — server rejects alg: none and other-algorithm tokens
  • Signature validation — flipping a byte in the signature fails the request
  • Claim tampering — flipping sub or role and 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

FlowWhen to use
Authorization Code + PKCEWeb apps and SPAs / mobile apps with a user. PKCE replaces client secret.
Client CredentialsServer-to-server / machine-to-machine. No user involved.
Device CodeTVs, CLI tools, devices without a browser
Refresh TokenRenew 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": "..."
}

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=86400

Subsequent requests:

GET /me
Cookie: session=abc123

Test for HttpOnly (no JS access), Secure (HTTPS only), and SameSite (CSRF defense).

REST vs GraphQL

Side by side

RESTGraphQL
EndpointsMany (/users, /orders, …)One (/graphql)
MethodsGET / POST / PUT / etcAlmost always POST
Response shapeServer-defined, often fixedClient-specified per query
Over/under-fetchingCommonAvoided by design
CachingHTTP-native (ETag, Cache-Control)Harder — needs custom layer
SchemaOptional (OpenAPI)Mandatory and strongly typed
ErrorsHTTP status codesAlways 200, errors in body
DiscoverabilityDocs / OpenAPIIntrospection 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. Read errors[] in the body, not the status code.
  • Authorization is per field. Test that a viewer can read user.name but not user.email if 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=10

Sorting

GET /products?sort=created_at&order=desc
GET /products?sort=-created_at,name              # alt convention: leading - = desc

Pagination — 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=20

Pagination — 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,profile
GET /products?q=mountain%20bike

Test 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 code is machine-readable (VALIDATION_ERROR), so clients can switch on it.
  • The message is 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/x should look structurally like a 404 from /orders/y.
  • Localized messages if your API supports it (Accept-Language honored).

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

ChangeWhy it breaks consumers
Field renamedOld name disappears
Field removedConsumers reading it get undefined
Type changed (e.g. numberstring)Parsing / arithmetic fails downstream
Required field added to requestOld clients no longer pass validation
Optional field becomes requiredSame as above
Enum value removedExisting data with that value fails validation
URL path / method changedOld clients hit 404
Status code changed (e.g. 200 → 204)Clients that read .body break
Authentication scheme changedAll 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
  • IdempotencyGET/PUT/DELETE called 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 (not 200 or 403)
  • Valid credentials, wrong scope → 403
  • Valid creds for user A, accessing user B's resource → 403 or 404
  • 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, not 404
  • per_page capped 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 → 429 with Retry-After
  • Headers (X-RateLimit-*) present and decreasing as expected
  • Limit resets on the documented schedule

Concurrency & idempotency

  • Two POSTs with the same Idempotency-Key create one resource
  • Simultaneous updates don't corrupt state (last-write-wins or ETag/If-Match respected)
  • 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" on POST /users doesn't elevate
  • Method overrideX-HTTP-Method-Override: DELETE doesn't bypass auth
  • Path traversal../ in a path parameter is rejected
  • Open redirect?redirect_url= validates against an allowlist
  • CORSAccess-Control-Allow-Origin isn't * for authenticated endpoints
  • Headers — sensitive responses include X-Content-Type-Options: nosniff, X-Frame-Options, Strict-Transport-Security