Skip to content
← Back to Codex
Testing10 min read

API Testing Beyond Status Codes

Every API testing tutorial starts the same way: send a GET request, check for 200, done. In practice, this catches maybe 10% of the bugs that actually ship to production. The interesting defects live in the gaps between happy paths — malformed payloads, race conditions, authorization edge cases, and contract drift.

The Testing Pyramid Applied to APIs

API tests sit in the middle of the testing pyramid, and they're the highest-leverage tests you can write. They're faster than E2E tests (no browser, no UI rendering), more realistic than unit tests (they exercise real middleware, validation, and database queries), and they catch integration bugs that unit tests can't.

My typical coverage split for a REST API:

Contract Testing

The most common API bug I see is contract drift — the backend changes a field name or nests a response differently, and the frontend breaks. Neither team's tests catch it because they're testing their own code in isolation.

Contract tests verify the API's response shape against a schema:

test('GET /api/users/:id returns expected shape', async ({ request }) => {
  const response = await request.get('/api/users/1');
  const body = await response.json();

  expect(body).toMatchObject({
    id: expect.any(Number),
    username: expect.any(String),
    email: expect.any(String),
    createdAt: expect.stringMatching(/^\d{4}-\d{2}-\d{2}/),
    role: expect.stringMatching(/^(admin|user|viewer)$/),
  });

  // Fields that should NOT be in the response
  expect(body).not.toHaveProperty('passwordHash');
  expect(body).not.toHaveProperty('internalFlags');
});

The negative assertions matter as much as the positive ones. I've caught PII leaks and internal debug fields in production responses this way.

Boundary Testing

Every input field has boundaries that developers define in validation logic. Those boundaries are where bugs cluster:

describe('POST /api/projects', () => {
  test('rejects title over 200 characters', async ({ request }) => {
    const response = await request.post('/api/projects', {
      data: { title: 'a'.repeat(201), description: 'Valid' },
    });
    expect(response.status()).toBe(422);
  });

  test('accepts title at exactly 200 characters', async ({ request }) => {
    const response = await request.post('/api/projects', {
      data: { title: 'a'.repeat(200), description: 'Valid' },
    });
    expect(response.status()).toBe(201);
  });

  test('rejects empty title', async ({ request }) => {
    const response = await request.post('/api/projects', {
      data: { title: '', description: 'Valid' },
    });
    expect(response.status()).toBe(422);
  });

  test('handles unicode in title', async ({ request }) => {
    const response = await request.post('/api/projects', {
      data: { title: '测试项目 🚀 Ñoño', description: 'Valid' },
    });
    expect(response.status()).toBe(201);
    const body = await response.json();
    expect(body.title).toBe('测试项目 🚀 Ñoño');
  });
});

The unicode test catches a surprising number of bugs — truncation at byte boundaries, encoding mismatches between the API and database, and rendering issues downstream.

Authorization Matrix Testing

The most dangerous bugs are authorization failures — when user A can access user B's data. I test this with an explicit authorization matrix:

| Endpoint | Owner | Admin | Other User | Unauthenticated | |----------|-------|-------|------------|-----------------| | GET /api/users/:id | 200 | 200 | 200 (limited) | 401 | | PUT /api/users/:id | 200 | 200 | 403 | 401 | | DELETE /api/users/:id | 403 | 200 | 403 | 401 | | GET /api/users/:id/billing | 200 | 200 | 403 | 401 |

Each cell becomes a test. The matrix makes gaps visible — if there's no test for "other user accesses billing," that's a gap worth filling.

test('non-owner cannot access billing details', async ({ request }) => {
  const response = await request.get('/api/users/other-user-id/billing', {
    headers: { Authorization: `Bearer ${regularUserToken}` },
  });
  expect(response.status()).toBe(403);
});

Error Response Consistency

APIs often have inconsistent error formats. One endpoint returns { error: "Not found" }, another returns { message: "Resource not found", code: "NOT_FOUND" }. This makes frontend error handling fragile.

I test that all error responses follow the same shape:

const errorEndpoints = [
  { method: 'GET', path: '/api/users/nonexistent', expectedStatus: 404 },
  { method: 'POST', path: '/api/users', data: {}, expectedStatus: 422 },
  { method: 'DELETE', path: '/api/admin/users/1', expectedStatus: 403 },
];

for (const { method, path, data, expectedStatus } of errorEndpoints) {
  test(`${method} ${path} returns consistent error format`, async ({ request }) => {
    const response = await request[method.toLowerCase()](path, { data });
    expect(response.status()).toBe(expectedStatus);

    const body = await response.json();
    expect(body).toHaveProperty('error');
    expect(body.error).toHaveProperty('code');
    expect(body.error).toHaveProperty('message');
    expect(typeof body.error.message).toBe('string');
  });
}

Performance Baselines

You don't need a load testing tool to catch performance regressions. Simple response time assertions in your existing test suite work surprisingly well:

test('search endpoint responds within 500ms', async ({ request }) => {
  const start = performance.now();
  const response = await request.get('/api/search?q=test&limit=20');
  const duration = performance.now() - start;

  expect(response.status()).toBe(200);
  expect(duration).toBeLessThan(500);
});

When a developer accidentally removes a database index or adds an N+1 query, this test catches it before it ships. It's not a substitute for proper load testing, but it's a free early warning system.

The Tooling Stack

For API testing specifically, I use:

The tool matters less than the strategy. A well-organized Bruno collection with 50 targeted tests beats a Postman workspace with 500 status-code-only checks.

What I've Learned

The APIs I've tested that had the fewest production incidents shared three traits: consistent error formats, an explicit authorization matrix with tests for every cell, and contract tests that ran on every PR. Everything else is optimization.