Skip to content
SDETRole
SDETRole

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, and role: '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. Use toBeCloseTo(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:

  1. POST /users and capture Location.
  2. GET the Location and assert the stored values match what you sent.
  3. 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 and toBeCloseTo for 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.

Type to search across SDETRole.

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