Lesson 8 of 9 · API Testing
Designing API Assertions
Decide what an API test should check: status, headers, schema, values, business rules and side effects, without writing brittle tests that break on every harmless change.
intermediate 8 min read Updated
You will learn to
- Apply a layered model of API assertions from status code to side effects
- Validate response structure with JSON Schema and values with targeted matchers
- Handle dynamic fields, ordering and floating-point values without brittle tests
- Assert error responses and business rules, not just happy-path status codes
Prerequisites
- REST APIs Explained
- JSON for Testers
- API Automation with Playwright
Sending a request is the easy part of API testing. The skill is in deciding what to check afterwards. Assert too little, and a test passes while the API returns the wrong data. Assert too much, and the test breaks every time a developer adds a field or a timestamp changes, until the team stops trusting it.
This lesson gives you a checklist for designing assertions, with examples in Playwright and TypeScript against the SDETRole Practice API at https://sdetrole.com/api/mock. The ideas apply equally to REST Assured or Postman.
The assertion layers
Think of an API response as having layers, from coarse to fine. A good test checks each layer that matters for its purpose, and no more.
| Layer | Question | Example |
|---|---|---|
| 1. Status | Did the operation succeed or fail as expected? | 201 for a create, 404 for an unknown id |
| 2. Headers | Is the metadata right? | Content-Type, Location, Cache-Control, pagination headers |
| 3. Structure | Does the body have the right shape and types? | JSON Schema: required fields, types, enums |
| 4. Values | Are the important values correct? | The user returned is the one requested |
| 5. Business rules | Do values relate correctly to each other? | Order total equals subtotal plus shipping and tax |
| 6. Side effects | Did the system change as intended, and nothing else? | A created user can be fetched; a deleted one cannot |
| 7. Non-functional | Is it fast and safe enough? | Response time budget, no sensitive fields leaked |
A status-only test, expect(response.status()).toBe(200), covers layer 1 and is the most common weak test in API suites. A 200 with an empty array, the wrong user or an error message in the body is still a 200.
Layer 1 and 2: status and headers
Assert the exact status where the contract defines it. toBeOK() (any 2xx) is fine as a guard, but 201 vs 200 vs 204 is part of the API’s contract and worth pinning.
Headers that are worth asserting on most endpoints:
const res = await request.get('users/1');
expect(res.status()).toBe(200);
const h = res.headers(); // keys are lower-case
expect(h['content-type']).toMatch(/^application\/json/);
expect(h['cache-control']).toBe('no-store'); // personal data must not be cached
expect(h['x-request-id']).toMatch(/^[0-9a-f-]{36}$/); // present and well-formed, value varies
For errors, the Practice API returns application/problem+json (RFC 9457), so check the media type too: an error with Content-Type: text/html usually means a proxy or framework error page leaked through instead of the API’s own error handling.
Layer 3: structure with JSON Schema
A schema describes the whole shape once and catches problems you would never write individual assertions for: a field silently renamed, a number turned into a string, an unexpected null. Using Ajv in a Playwright 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', 'name', 'email', 'role', 'active', 'createdAt'],
properties: {
id: { type: 'integer', minimum: 1 },
name: { type: 'string', minLength: 2, maxLength: 80 },
email: { type: 'string', format: 'email' },
role: { enum: ['admin', 'tester', 'developer', 'viewer'] },
active: { type: 'boolean' },
createdAt: { type: 'string', format: 'date-time' },
},
additionalProperties: false,
};
const validateUser = ajv.compile(userSchema);
test('every user on page 1 matches the schema', async ({ request }) => {
const res = await request.get('users', { params: { page: 1, limit: 10 } });
const { data } = await res.json();
for (const user of data) {
const valid = validateUser(user);
expect(valid, JSON.stringify(validateUser.errors, null, 2)).toBe(true);
}
});
Passing validateUser.errors as the assertion message means a failure tells you exactly which field broke and why.
Layer 4: values, precisely but not brittly
Assert the values that prove the right thing happened, and tolerate the ones that legitimately vary.
const res = await request.post('users', {
data: { name: 'Test User', email: 'test.user.42@example.com', role: 'tester' },
});
expect(res.status()).toBe(201);
const created = await res.json();
expect(created).toEqual({
id: expect.any(Number), // generated
name: 'Test User', // echoes input
email: 'test.user.42@example.com',
role: 'tester',
active: true, // documented default
createdAt: expect.any(String), // generated
});
expect(Math.abs(Date.now() - Date.parse(created.createdAt))).toBeLessThan(60_000);
This uses toEqual with asymmetric matchers (expect.any), which checks every field exists while allowing generated values to vary. The final line checks the timestamp is recent rather than equal to a fixed value.
Rules of thumb:
- Echoed input: assert exactly.
- Defaults: assert exactly; they are part of the contract (here,
active: true, androle: 'viewer'when no role is sent). - Generated IDs and timestamps: assert type and format, then use them (fetch the resource by its new id).
- Lists: unless the API documents an order, assert membership (
toContainEqual,arrayContaining) rather than position. - Money and decimals: never compare computed floats with
toBe. UsetoBeCloseTo(value, 2), or compare integer minor units (cents).
Layer 5: business rules
The most valuable assertions often relate fields to each other. The Practice API’s order 1001 has items, shipping and totals:
test('order totals are internally consistent', async ({ request }) => {
const res = await request.get('orders/1001');
await expect(res).toBeOK();
const order = await res.json();
const itemsSum = order.items.reduce(
(sum: number, i: { qty: number; price: number }) => sum + i.qty * i.price,
0,
);
expect(order.totals.subtotal).toBeCloseTo(itemsSum, 2);
expect(order.totals.total).toBeCloseTo(
order.totals.subtotal + order.totals.shipping + order.totals.tax,
2,
);
// a shipped order must have a tracking number
if (order.status === 'shipped') {
expect(order.shipping.trackingNumber).toMatch(/^TRK-\d+$/);
}
});
For 1001, the items are 89.00 + 2 x 39.99 + 199.00 = 367.98, and 367.98 + 12.00 shipping = 379.98. In JavaScript, 89 + 79.98 is not exactly 168.98 because of binary floating point, which is precisely why toBeCloseTo is used.
The same idea for collections: on a paginated list, every page should respect limit, the union of all pages should have total unique ids, and no id should appear on two pages.
Layer 6: side effects
A 201 Created proves the server said it created something. To prove it did, follow up:
POST /usersand captureLocation.GETtheLocationand assert the stored values match what you sent.- Check nothing else changed, for example the total count increased by exactly one.
For a DELETE, the follow-up GET should return 404. For an update, re-fetch and confirm both the changed fields and the unchanged ones.
The Practice API is intentionally stateless (writes are validated and answered but never stored), so on it these follow-ups return the original seed data. Practise the shape of the test there, and expect the follow-up assertions to pass on a real, stateful system.
Error responses deserve full assertions
Negative tests often stop at the status code. Check the error body too, because clients depend on it to show useful messages:
test('invalid create reports each field', async ({ request }) => {
const res = await request.post('users', { data: { name: 'A', email: 'not-an-email' } });
expect(res.status()).toBe(422);
expect(res.headers()['content-type']).toContain('application/problem+json');
const problem = await res.json();
expect(problem).toMatchObject({ status: 422, title: 'Unprocessable Content' });
expect(problem.errors).toEqual(
expect.arrayContaining([
expect.objectContaining({ field: 'name' }),
expect.objectContaining({ field: 'email', message: 'must be a valid email address' }),
]),
);
});
Also assert what must not be there: no stack traces, SQL fragments or internal hostnames in detail.
Soft assertions
Playwright’s expect.soft records a failure and continues, so one test run reports every broken field instead of stopping at the first:
const user = await (await request.get('users/1')).json();
expect.soft(user.name).toBe('Priya Shah');
expect.soft(user.role).toBe('tester');
expect.soft(user.active).toBe(true);
Use soft assertions for independent field checks within one response. Keep hard assertions for preconditions, such as the status code, where continuing makes no sense.
Practice exercise
Design and write the assertions for GET /users?page=3&limit=10 on the Practice API. Before writing code, list which layers apply and what you will check in each. Explore the response in the API Playground, and try generating a first draft with the API Assertion Builder.
Show a model answer
Layers that apply: status, headers (pagination), structure, values and a collection-level business rule. Side effects do not apply to a GET.
test('last page of users', async ({ request }) => {
const res = await request.get('users', { params: { page: 3, limit: 10 } });
// 1. status
expect(res.status()).toBe(200);
// 2. headers
const h = res.headers();
expect(h['x-total-count']).toBe('25');
expect(h['link']).toContain('rel="prev"');
expect(h['link']).not.toContain('rel="next"'); // last page
// 3. structure
const body = await res.json();
for (const user of body.data) expect(validateUser(user)).toBe(true);
// 4. values
expect(body).toMatchObject({ page: 3, limit: 10, total: 25, totalPages: 3 });
expect(body.data).toHaveLength(5); // 25 users, 10 per page: 10 + 10 + 5
expect(body.data.map((u: { id: number }) => u.id)).toEqual([21, 22, 23, 24, 25]);
// 5. business rule
expect(body.totalPages).toBe(Math.ceil(body.total / body.limit));
});Asserting the exact ids on this page is acceptable because the Practice API’s seed data is fixed and is returned in id order. On a live system with changing data, you would assert length, uniqueness and ordering instead.
Key takeaways
- Work through the layers: status, headers, structure, values, business rules, side effects, non-functional. A status-only test proves very little.
- Use JSON Schema for shape and targeted matchers for the values that carry meaning.
- Tolerate legitimately variable data with asymmetric matchers (
expect.any), membership checks andtoBeCloseTofor decimals. - Assert error bodies, media types and the absence of leaked internals, not just 4xx codes.
- Prove side effects with follow-up requests, and aim for one behaviour per test, checked thoroughly.
Key terms
Finished this lesson?
Progress is saved in this browser. No account needed.
Practise it
Related interview questions
- What do you validate in an API response beyond the status code?
- Which HTTP status codes matter in API testing? 401 vs 403, 400 vs 422, 200 vs 201 vs 204
- How do you use JSON schema validation, filters and logging in a REST Assured framework?
- Explain REST Assured's given/when/then structure and write a basic test