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.
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:
200for reads,201(with aLocationheader) for creates,204for deletes with no body. 400or422for invalid input, consistently across endpoints.401when credentials are missing or invalid,403when they are valid but not permitted.404for a missing resource, and what happens for a malformed ID (often400or404, but never500).405for unsupported methods and415for an unsupportedContent-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-Typematches the body, including charset where relevant.Locationon201points at the new resource.- Caching headers are correct. Responses with personal data usually need
Cache-Control: no-storeorprivate. - 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
ServerorX-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, not500. - 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,
nullwhere 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:
PUTthe same body twice: the resource ends in the same state, and the second call does not fail.DELETEtwice: the second returns404or204(agree which), never500.- If the API supports an
Idempotency-Keyheader forPOST(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.
limitboundaries: 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
cancelledback topaid. - 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
- Exact status codes for success and every failure class; no
5xxfrom client input. - Full response schema validated, plus key values asserted.
- Headers: content type, location, caching, security, CORS, no leaks.
- Authentication failures return
401; authorisation is checked per object and per method (IDOR). - Input validation on every field: missing, wrong type, boundaries, formats, unexpected fields.
- Consistent, safe error format, ideally RFC 9457 problem details.
- Idempotent methods behave idempotently;
POSTretries are safe where it matters. - Pagination returns every item exactly once; filters and sorts are correct.
- Rate limits return
429with retry guidance. - A response-time smoke budget on important endpoints.
#api-testing#rest-assured#playwright#security#checklist
Practise it
Related tools
Related interview questions
- Which HTTP status codes matter in API testing? 401 vs 403, 400 vs 422, 200 vs 201 vs 204
- What do you validate in an API response beyond the status code?
- How do you use JSON schema validation, filters and logging in a REST Assured framework?
- How do you test rate limiting, concurrency and race conditions in an API?
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.