Skip to content
SDETRole
SDETRole

API Testing

The API Testing Checklist: What to Verify on Every Endpoint

A practical API testing checklist covering status codes, schemas, auth and IDOR, validation, idempotency, pagination, RFC 9457 errors and rate limits, with code.

By SDETRole Editorial Team Published 9 min read
Table of contents

Most API test suites start the same way: call the endpoint, check for a 200, assert on a field or two. That catches the endpoint being completely broken and very little else. The bugs that reach production tend to live in the places nobody checked: the 200 that should have been a 403, the field that silently changed type, the retry that created a second order.

This is the checklist we use when reviewing tests for an endpoint. Not every item applies to every endpoint, but every item should be a conscious decision. Examples use Playwright’s APIRequestContext in TypeScript and REST Assured 5 in Java, since those are the two stacks most SDET teams use.

The setup

Both examples assume a base URL and a token from the environment.

// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  use: {
    baseURL: process.env.API_BASE_URL,
    extraHTTPHeaders: {
      Accept: 'application/json',
      Authorization: `Bearer ${process.env.API_TOKEN}`,
    },
  },
});
import io.restassured.builder.RequestSpecBuilder;
import io.restassured.http.ContentType;
import io.restassured.specification.RequestSpecification;

public final class Specs {
    public static RequestSpecification authed(String token) {
        return new RequestSpecBuilder()
            .setBaseUri(System.getenv("API_BASE_URL"))
            .setAccept(ContentType.JSON)
            .addHeader("Authorization", "Bearer " + token)
            .build();
    }
}

1. Status codes

Assert the exact status code, not a range. A 201 Created returned as 200 OK matters to clients that branch on it, and expect(res.ok()).toBeTruthy() would pass both.

Check at least:

  • The success code for each method: 200 for reads, 201 (with a Location header) for creates, 204 for deletes with no body.
  • 400 or 422 for invalid input, consistently across endpoints.
  • 401 when credentials are missing or invalid, 403 when they are valid but not permitted.
  • 404 for a missing resource, and what happens for a malformed ID (often 400 or 404, but never 500).
  • 405 for unsupported methods and 415 for an unsupported Content-Type, if the API claims to enforce them.

Any 5xx caused by client input is a bug. If you are unsure what a code means, the HTTP status codes reference covers each one with testing notes.

2. Response schema and types

A response can have the right values and still be wrong: a number serialised as a string, a missing optional field that clients treat as required, an unexpected null. Validate the whole shape against a JSON Schema, then assert on the values that matter for this test.

import { test, expect } from '@playwright/test';
import Ajv from 'ajv';
import addFormats from 'ajv-formats';

const ajv = new Ajv({ allErrors: true });
addFormats(ajv);

const userSchema = {
  type: 'object',
  required: ['id', 'email', 'createdAt'],
  properties: {
    id: { type: 'integer' },
    email: { type: 'string', format: 'email' },
    createdAt: { type: 'string', format: 'date-time' },
    nickname: { type: ['string', 'null'] },
  },
  additionalProperties: false,
};
const validateUser = ajv.compile(userSchema);

test('GET /v1/users/:id returns a valid user', async ({ request }) => {
  const res = await request.get('/v1/users/42');
  expect(res.status()).toBe(200);

  const body = await res.json();
  expect(validateUser(body), JSON.stringify(validateUser.errors)).toBe(true);
  expect(body.id).toBe(42);
});
import static io.restassured.RestAssured.given;
import static io.restassured.module.jsv.JsonSchemaValidator.matchesJsonSchemaInClasspath;
import static org.hamcrest.Matchers.equalTo;

@Test
void getUserMatchesSchema() {
    given()
        .spec(Specs.authed(System.getenv("API_TOKEN")))
    .when()
        .get("/v1/users/{id}", 42)
    .then()
        .statusCode(200)
        .contentType(ContentType.JSON)
        .body(matchesJsonSchemaInClasspath("schemas/user.json"))
        .body("id", equalTo(42));
}

matchesJsonSchemaInClasspath comes from REST Assured’s separate json-schema-validator module. Whether to use additionalProperties: false is a team decision: it catches accidental data leaks (a passwordHash field appearing in a response), but makes tests fail on every additive change. A common compromise is strict schemas for security-sensitive resources and permissive ones elsewhere.

The JSON Schema validator is useful for checking a schema against a real response before committing it.

3. Headers

Headers are part of the contract and are often untested:

  • Content-Type matches the body, including charset where relevant.
  • Location on 201 points at the new resource.
  • Caching headers are correct. Responses with personal data usually need Cache-Control: no-store or private.
  • Security headers your platform requires, such as Strict-Transport-Security.
  • CORS headers (Access-Control-Allow-Origin) do not reflect arbitrary origins on authenticated endpoints.
  • No internal details leak, such as framework versions in Server or X-Powered-By.
const res = await request.get('/v1/users/42');
const headers = res.headers(); // keys are lower-cased
expect(headers['content-type']).toContain('application/json');
expect(headers['cache-control']).toMatch(/no-store|private/);
expect(headers['x-powered-by']).toBeUndefined();

4. Authentication and authorisation

Authentication asks “who are you?”. Authorisation asks “are you allowed to do this?”. Test both, separately.

Authentication checks:

  • No token returns 401.
  • Malformed, expired and wrongly signed tokens return 401, not 500.
  • A token for another environment or audience is rejected.

Authorisation checks are where the serious bugs are. Broken Object Level Authorization (BOLA, often called IDOR) is the first entry in the OWASP API Security Top 10 (2023). The test is simple: authenticate as user A and request user B’s resource.

@Test
void userCannotReadAnotherUsersOrder() {
    String aliceToken = TestUsers.tokenFor("alice");
    long bobsOrderId = TestData.createOrderFor("bob");

    given()
        .spec(Specs.authed(aliceToken))
    .when()
        .get("/v1/orders/{id}", bobsOrderId)
    .then()
        .statusCode(anyOf(equalTo(403), equalTo(404)));
}

Accepting 403 or 404 is deliberate: some APIs return 404 to avoid confirming that the resource exists. Agree which one your API uses and tighten the assertion. Repeat the same idea for writes (PUT, PATCH, DELETE), for nested resources (/users/{id}/addresses), and for function-level checks such as a normal user calling an admin endpoint.

5. Input validation

For each field in a request body or query string, test the boundaries and the types. The API payload generator can produce variants quickly.

  • Missing required fields, one at a time.
  • Wrong types: string for number, object for array, null where not allowed.
  • Boundaries: empty strings, maximum length and one over, zero, negatives, very large numbers.
  • Formats: invalid emails, dates and enum values.
  • Unexpected fields, especially ones that should not be client-controlled (role, isAdmin, price). Mass assignment is a real vulnerability class.
  • Encoding: Unicode, emoji, right-to-left text, and characters with special meaning (quotes, angle brackets, SQL and script fragments) to confirm they are stored and returned safely.

Every invalid input should produce a 4xx with a useful error, never a 5xx and never a silent success.

6. Error response format

Errors should be consistent and machine-readable. RFC 9457, “Problem Details for HTTP APIs” (which obsoletes RFC 7807), defines a standard format with media type application/problem+json and members such as type, title, status, detail and instance. Even if your API uses its own format, the checks are the same: consistent structure, correct status, a helpful message, and no stack traces or SQL.

test('invalid email returns a problem details response', async ({ request }) => {
  const res = await request.post('/v1/users', {
    data: { email: 'not-an-email', name: 'Test' },
  });

  expect(res.status()).toBe(400);
  expect(res.headers()['content-type']).toContain('application/problem+json');

  const problem = await res.json();
  expect(problem).toMatchObject({ status: 400, title: expect.any(String) });
  expect(JSON.stringify(problem)).not.toMatch(/Exception|at com\.|SELECT /);
});

7. Idempotency

RFC 9110 defines GET, HEAD, OPTIONS, PUT and DELETE as idempotent: making the same request twice has the same effect on the server as making it once. POST and PATCH are not idempotent by default.

Tests to write:

  • PUT the same body twice: the resource ends in the same state, and the second call does not fail.
  • DELETE twice: the second returns 404 or 204 (agree which), never 500.
  • If the API supports an Idempotency-Key header for POST (common for payments; an IETF draft standardises it), sending the same key twice must create one resource and return the same result.
test('repeated POST with the same idempotency key creates one payment', async ({ request }) => {
  const key = crypto.randomUUID();
  const body = { amount: 1999, currency: 'GBP', orderId: 'ord_123' };

  const first = await request.post('/v1/payments', { data: body, headers: { 'Idempotency-Key': key } });
  const second = await request.post('/v1/payments', { data: body, headers: { 'Idempotency-Key': key } });

  expect(first.status()).toBe(201);
  expect([200, 201]).toContain(second.status());
  expect((await second.json()).id).toBe((await first.json()).id);
});

Retries in clients, proxies and message queues mean non-idempotent behaviour eventually causes duplicate orders or payments. This is worth testing even if nobody asked.

8. Pagination, filtering and sorting

List endpoints break in quiet ways. Check:

  • First page, last page, an empty page and a page beyond the end.
  • limit boundaries: zero, maximum, maximum plus one, negative.
  • Walking every page returns each item exactly once, with no duplicates or gaps.
  • Sorting is stable when the sort key has ties (otherwise items can move between pages).
  • Filters combine correctly and invalid filter values return 400.
  • For cursor pagination, cursors are opaque and tampered cursors are rejected.
test('walking all pages returns each item exactly once', async ({ request }) => {
  const seen = new Set<number>();
  let cursor: string | undefined;

  do {
    const res = await request.get('/v1/products', {
      params: cursor ? { limit: 50, cursor } : { limit: 50 },
    });
    expect(res.status()).toBe(200);
    const page = await res.json();
    for (const item of page.items) {
      expect(seen.has(item.id), `duplicate id ${item.id}`).toBe(false);
      seen.add(item.id);
    }
    cursor = page.nextCursor ?? undefined;
  } while (cursor);

  expect(seen.size).toBeGreaterThan(0);
});

9. Rate limits

If the API enforces rate limits, verify that exceeding them returns 429 Too Many Requests and, ideally, a Retry-After header. Many APIs also expose remaining quota through headers; an IETF draft defines standard RateLimit headers, but vendor-specific X-RateLimit-* headers are still common. Check whichever your API documents.

Run these tests against an isolated environment or with a dedicated client identity. Exhausting a shared rate limit can break other teams’ tests and is a good way to make enemies.

10. Performance smoke

Functional API tests are not load tests, but a simple response-time budget catches obvious regressions such as a missing index or an accidental N+1 query.

import static org.hamcrest.Matchers.lessThan;
import java.util.concurrent.TimeUnit;

given().spec(Specs.authed(token))
.when().get("/v1/orders?limit=50")
.then()
    .statusCode(200)
    .time(lessThan(1500L), TimeUnit.MILLISECONDS);

Keep budgets generous enough to avoid flaky failures on shared environments, and treat them as smoke alarms, not SLAs. Real performance testing needs a workload model, percentiles and a dedicated tool.

Beyond the single endpoint

Some important checks span endpoints:

  • State transitions: an order cannot move from cancelled back to paid.
  • Side effects: creating an order reduces stock and emits an event.
  • Consistency: data written through one endpoint reads back identically through another.
  • Contract drift: the implementation still matches the published OpenAPI document.

These are often where the most valuable API tests live, because UI tests cover them slowly and unit tests cannot see them at all.

You can try many of these checks against a safe, sandboxed API in the API Playground.

The checklist

  1. Exact status codes for success and every failure class; no 5xx from client input.
  2. Full response schema validated, plus key values asserted.
  3. Headers: content type, location, caching, security, CORS, no leaks.
  4. Authentication failures return 401; authorisation is checked per object and per method (IDOR).
  5. Input validation on every field: missing, wrong type, boundaries, formats, unexpected fields.
  6. Consistent, safe error format, ideally RFC 9457 problem details.
  7. Idempotent methods behave idempotently; POST retries are safe where it matters.
  8. Pagination returns every item exactly once; filters and sorts are correct.
  9. Rate limits return 429 with retry guidance.
  10. A response-time smoke budget on important endpoints.
Share LinkedInXReddit

#api-testing#rest-assured#playwright#security#checklist

SDETRole Editorial Team

Practising SDETs and QA engineers who write, test and review every SDETRole article and lab. Code samples are run against current tool versions before publishing.

Type to search across SDETRole.

Full-text search by Pagefind · runs in your browser Open search page →