Skip to content

Integration Testing Against APIs

Integration tests that hit real, running services catch things unit tests never will. Schema drift, serialization bugs, auth middleware that silently eats headers, validation rules that only exist in the database — mocks hide all of this.

Why not mock?

Mocking HTTP responses is tempting — tests run fast, no infrastructure needed, deterministic by default. But mocked tests answer the wrong question. They tell you "does my code work assuming the API behaves exactly like I think it does?" instead of "does my code work against the actual API?"

The gaps are real:

What mocks hideWhat happens in production
The API added a required field last week400 Bad Request on every call
Timestamps come back as strings, not DatesYour date logic silently breaks
Auth middleware rejects your token formatWorks in tests, fails on deploy
The API returns { errors: [...] } not { error: "..." }Your error handler shows a blank message
Pagination headers changed from X-Total to X-Total-CountInfinite loading spinners

If your tests don't talk to the real service, you're testing your assumptions, not your code.

Why I wrote expect-asymmetric

I got tired of it. Tired of mocking response bodies, tired of maintaining fake payloads that drifted from reality, tired of tests that passed locally and broke against a deployed API because the mock was wrong in some subtle way you'd never think to check.

The turning point was discovering that Jest and Vitest support asymmetric matchers — objects you can drop into toEqual() that match by shape instead of exact value. expect.any(String) and expect.stringContaining() are built in, but they're blunt tools. "It's a string" is not the same as "it's a UUID". "It contains a date" is not the same as "it's an ISO 8601 timestamp from the last five seconds".

So I built a library of ~30 matchers that let you assert the shape of values precisely — UUIDs, ISO dates, JWTs, emails, numeric ranges, date tolerances — and compose them with and, or, and not. You drop them into toEqual() or toMatchObject() exactly where you'd put a literal value, and they do the right thing.

expect-asymmetric

Asymmetric matchers for expect (Jest/Vitest), written & published by @jdrydn

npm.im/expect-asymmetric

Setup

Install the dependency:

npm install --save-dev expect-asymmetric
yarn add -D expect-asymmetric
pnpm add -D expect-asymmetric

Point your tests at a real running instance. A base URL from an environment variable keeps it flexible — for the sake of this document, we have wrapped the HTTP client in a makeRequest function that returns { status, headers, data } so every test reads the same regardless of which library is used:

ts
import axios from 'axios';

const baseURL = process.env.API_URL ?? 'http://localhost:3000';

async function makeRequest(method: string, path: string, bodyOrQuery?: unknown) {
  const hasBody = !['GET', 'HEAD'].includes(method);
  const res = await axios({
    method,
    baseURL,
    url: path,
    params: !hasBody ? bodyOrQuery : undefined,
    data: hasBody ? bodyOrQuery : undefined,
    responseType: 'json',
    validateStatus: () => true,
  });
  const headers = Object.fromEntries(Object.entries(res.headers).map(([k, v]) => [k, String(v)]));
  return { status: res.status, headers, data: res.data };
}
ts
const baseURL = process.env.API_URL ?? 'http://localhost:3000';

async function makeRequest(method: string, path: string, bodyOrQuery?: unknown) {
  const hasBody = !['GET', 'HEAD'].includes(method);
  const url = new URL(path, baseURL);
  if (!hasBody && bodyOrQuery) {
    Object.entries(bodyOrQuery as Record<string, string>).forEach(([k, v]) => url.searchParams.set(k, v));
  }
  const res = await fetch(url, {
    method,
    headers: hasBody ? { 'Content-Type': 'application/json' } : {},
    body: hasBody ? JSON.stringify(bodyOrQuery) : undefined,
  });
  const headers = Object.fromEntries(res.headers.entries());
  return { status: res.status, headers, data: await res.json() };
}
ts
import https from 'node:https';

const baseURL = process.env.API_URL ?? 'https://localhost:3000';

function makeRequest(method: string, path: string, bodyOrQuery?: unknown) {
  const hasBody = !['GET', 'HEAD'].includes(method);
  const url = new URL(path, baseURL);
  if (!hasBody && bodyOrQuery) {
    Object.entries(bodyOrQuery as Record<string, string>).forEach(([k, v]) => url.searchParams.set(k, v));
  }
  return new Promise<{ status: number; headers: Record<string, string>; data: unknown }>((resolve, reject) => {
    const req = https.request(
      url,
      { method, headers: hasBody ? { 'Content-Type': 'application/json' } : {} },
      (res) => {
        const chunks: Buffer[] = [];
        res.on('data', (chunk) => chunks.push(chunk));
        res.on('end', () => {
          const headers = Object.fromEntries(Object.entries(res.headers).map(([k, v]) => [k, String(v)]));
          resolve({ status: res.statusCode!, headers, data: JSON.parse(Buffer.concat(chunks).toString()) });
        });
      },
    );
    req.on('error', reject);
    if (hasBody && bodyOrQuery) req.write(JSON.stringify(bodyOrQuery));
    req.end();
  });
}

The problem with expect.any()

Say your API returns a user object after creation. You might write:

ts
// Too loose — this passes even if id is "not-a-uuid" or createdAt is "yesterday"
expect(res.data).toEqual({
  id: expect.any(String),
  email: 'someone@example.com',
  name: 'Someone',
  createdAt: expect.any(String),
  updatedAt: expect.any(String),
});

This tells you the fields exist and they're strings. It doesn't tell you the ID is actually a UUID, or that the timestamps are valid ISO 8601 dates, or that createdAt is recent rather than stuck at some hardcoded epoch. You're asserting the type, not the shape.

Asserting response shapes with expect-asymmetric

expect-asymmetric gives you matchers that slot into toEqual() and toMatchObject() — the same way expect.any() does — but with actual validation:

ts
import { describe, expect, test } from 'vitest';
import matchers from 'expect-asymmetric';
import ms from 'ms';

describe('POST /users', () => {
  test('should create a user', async () => {
    const res = await makeRequest('POST', '/users', {
      email: 'someone@example.com',
      name: 'Someone',
    });

    expect(res.status).toEqual(201);
    expect(res.data).toEqual({
      id: matchers.stringUUID(),
      email: 'someone@example.com',
      name: 'Someone',
      createdAt: matchers.stringDateISO8601(),
      updatedAt: matchers.stringDateISO8601(),
    });
  });
});

Now if id comes back as "user_123" instead of a UUID, the test fails. If createdAt comes back as "2025-01-01" without the time component, the test fails. You're asserting what the value looks like, not just that it exists.

Dates that are actually recent

For timestamps, you often want to check they're not just valid but recent — a createdAt from three years ago probably means something is broken:

ts
expect(res.data).toEqual({
  id: matchers.stringUUID(),
  email: 'someone@example.com',
  name: 'Someone',
  createdAt: matchers.dateWithin(new Date(), ms('5s')),
  updatedAt: matchers.dateWithin(new Date(), ms('5s')),
});

dateWithin checks the value is within 5 seconds of now — tight enough to catch stale data, loose enough to handle test execution time.

Testing error responses

Error responses deserve the same shape-level scrutiny. A 404 that returns HTML instead of JSON will sail past a status-code-only check:

ts
describe('GET /users/:id', () => {
  test('should return 404 for a missing user', async () => {
    const res = await makeRequest('GET', '/users/00000000-0000-0000-0000-000000000000');

    expect(res.status).toEqual(404);
    expect(res.data).toEqual({
      error: matchers.stringIncludes('not found'),
    });
  });
});

And for validation errors, assert the structure of the error payload — not just that it's a 400:

ts
describe('POST /users', () => {
  test('should reject an invalid email', async () => {
    const res = await makeRequest('POST', '/users', {
      email: 'not-an-email',
      name: 'Someone',
    });

    expect(res.status).toEqual(400);
    expect(res.data).toEqual({
      errors: expect.arrayContaining([
        expect.objectContaining({
          field: 'email',
          message: expect.any(String),
        }),
      ]),
    });
  });
});

You can mix expect-asymmetric matchers with Vitest's built-in asymmetric matchers freely — they're the same kind of object under the hood.

Composing matchers

The and, or, and not matchers let you combine checks. This is particularly useful for fields that could be one of several valid shapes:

ts
// A status field that should be one of a known set
expect(res.data).toEqual({
  id: matchers.stringUUID(),
  status: matchers.or([
    matchers.stringEquals('pending'),
    matchers.stringEquals('active'),
    matchers.stringEquals('suspended'),
  ]),
  role: matchers.and([matchers.stringStartsWith('org_'), matchers.not([matchers.stringEquals('org_superadmin')])]),
});

Useful matchers for API testing

The full list is in the README, but these come up most often when testing APIs:

MatcherWhat it checks
stringUUID()Valid UUID format
stringDateISO8601()Valid ISO 8601 date string
stringEmail()Valid email format
stringJWT()Valid JWT structure (three base64 segments)
dateWithin(target, ms)Date within a tolerance window
numberGreaterThan(n)Numeric comparisons without exact values
stringStartsWith(prefix)Prefix checks for structured IDs
and() / or() / not()Compose matchers for complex assertions

Further reading