JSON Testing JavaScript: Jest Matchers, Snapshots, Supertest & Nock

Last updated:

Testing JSON in JavaScript means verifying structure, values, and schema — Jest's toMatchObject() checks a subset of properties, toEqual() checks deep equality, and toStrictEqual() additionally checks object types and undefined properties. Snapshot testing (toMatchSnapshot()) serializes a JSON response to a .snap file on first run and diffs against it on subsequent runs — 90% of API regression bugs are caught without writing explicit assertions. Supertest makes HTTP integration testing against an Express/Fastify app require zero running server.

This guide covers Jest JSON matchers, snapshot testing strategies, Supertest integration testing, Nock HTTP mocking, AJV schema assertions, and Vitest as a Vite-native alternative. Every pattern is paired with TypeScript examples.

Jest JSON Matchers: toMatchObject, toEqual, and toStrictEqual

Jest provides three JSON comparison matchers with meaningfully different semantics. toMatchObject() asserts that the received object contains at least the expected properties — extra properties in the received object are ignored. toEqual() asserts deep equality — both objects must have the same keys and values, but class instance types are not checked. toStrictEqual() is the most rigorous — it additionally requires matching object types and treats { a: undefined } differently from {}. Choosing the wrong matcher leads to tests that pass when they should fail or vice versa.

import { describe, it, expect } from "@jest/globals"; // or "vitest"

// ── Sample API response ───────────────────────────────────────────
const apiResponse = {
  id: 1,
  name: "Alice",
  email: "alice@example.com",
  createdAt: "2025-01-01T00:00:00Z",
  metadata: { plan: "pro", verified: true },
};

// ── toMatchObject: partial match — extra properties are ignored ────
it("contains user id and name", () => {
  expect(apiResponse).toMatchObject({ id: 1, name: "Alice" });
  // PASSES — createdAt and metadata are present but not checked
});

it("checks nested properties", () => {
  expect(apiResponse).toMatchObject({
    metadata: { plan: "pro" },
  });
  // PASSES — metadata.verified is ignored
});

// ── toEqual: deep equality — exact match required ─────────────────
it("exact user shape", () => {
  expect({ id: 1, name: "Alice" }).toEqual({ id: 1, name: "Alice" });
  // PASSES

  expect(apiResponse).toEqual({ id: 1, name: "Alice" });
  // FAILS — apiResponse has extra properties (email, createdAt, metadata)
});

// ── toStrictEqual: strict type and undefined checks ───────────────
it("strict equality checks undefined properties", () => {
  expect({ a: undefined }).toEqual({});
  // PASSES — toEqual ignores explicit undefined

  expect({ a: undefined }).toStrictEqual({});
  // FAILS — toStrictEqual distinguishes { a: undefined } from {}
});

it("strict equality checks object types", () => {
  class User {
    constructor(public name: string) {}
  }

  expect(new User("Alice")).toEqual({ name: "Alice" });
  // PASSES — toEqual ignores class type

  expect(new User("Alice")).toStrictEqual({ name: "Alice" });
  // FAILS — toStrictEqual: received is User instance, expected is plain object
});

// ── Array matchers ────────────────────────────────────────────────
const usersArray = [
  { id: 1, name: "Alice", role: "admin" },
  { id: 2, name: "Bob",   role: "editor" },
];

it("each user has required fields", () => {
  expect(usersArray).toEqual(
    expect.arrayContaining([
      expect.objectContaining({ id: 1, name: "Alice" }),
      expect.objectContaining({ id: 2, name: "Bob" }),
    ])
  );
});

// ── expect.objectContaining: inline partial matching ──────────────
it("response contains success flag", () => {
  const res = { success: true, data: { id: 5 }, requestId: "abc123" };
  expect(res).toEqual(
    expect.objectContaining({ success: true, data: { id: 5 } })
  );
  // PASSES — requestId ignored via objectContaining
});

// ── Numeric and string matchers ───────────────────────────────────
it("id is a positive integer", () => {
  expect(apiResponse.id).toEqual(expect.any(Number));
  expect(apiResponse.name).toEqual(expect.any(String));
  expect(apiResponse.id).toBeGreaterThan(0);
});

// ── TypeScript: typing the expected shape ─────────────────────────
interface UserResponse {
  id: number;
  name: string;
  email: string;
}

function assertUserShape(data: unknown): asserts data is UserResponse {
  expect(data).toMatchObject({
    id: expect.any(Number),
    name: expect.any(String),
    email: expect.any(String),
  });
}

Use toMatchObject() as the default for API integration tests — responses evolve over time and adding new fields should not break existing tests. Reserve toEqual() for unit tests on pure data transformation functions where the exact output shape is intentional and known. Use toStrictEqual() only when testing code that distinguishes between missing properties and properties explicitly set to undefined — this is rare in API testing but common in state management and configuration parsing. expect.objectContaining() and expect.arrayContaining() are composable versions of the same semantics and can be nested inside toEqual() for mixed strict/partial assertions.

Snapshot Testing JSON API Responses

Snapshot testing serializes a JSON value to a .snap file on the first run and automatically diffs against it on every subsequent run. It catches structural regressions — a field being renamed, a new field appearing, a null replacing a string — without requiring explicit assertions for every property. The tradeoff is that snapshots must be reviewed carefully when updated; a snapshot update that silently swallows a bug is worse than no test.

import request from "supertest";
import app from "../app";

// ── Basic snapshot test ───────────────────────────────────────────
it("GET /api/users/1 matches snapshot", async () => {
  const res = await request(app).get("/api/users/1").expect(200);
  expect(res.body).toMatchSnapshot();
  // First run: creates __snapshots__/users.test.ts.snap
  // Subsequent runs: diffs against stored snapshot
});

// ── Snapshot file content (auto-generated) ────────────────────────
// exports[`GET /api/users/1 matches snapshot 1`] = `
// {
//   "createdAt": "2025-01-01T00:00:00.000Z",
//   "email": "alice@example.com",
//   "id": 1,
//   "name": "Alice",
// }
// `;

// ── Snapshot only the stable part of the response ─────────────────
// Bad: timestamps change on every request — snapshot always fails
it("snapshot full response (BAD — timestamp changes)", async () => {
  const res = await request(app).get("/api/users/1").expect(200);
  expect(res.body).toMatchSnapshot(); // fails if createdAt is dynamic
});

// Good: snapshot only the stable fields
it("snapshot user shape (GOOD)", async () => {
  const res = await request(app).get("/api/users/1").expect(200);
  const { createdAt, updatedAt, ...stableFields } = res.body;
  expect(stableFields).toMatchSnapshot();
});

// ── Property matchers: replace dynamic values before snapshotting ─
it("snapshot with property matchers", async () => {
  const res = await request(app).post("/api/users").send({ name: "Carol" });
  expect(res.body).toMatchSnapshot({
    id: expect.any(Number),        // id is dynamic — accept any number
    createdAt: expect.any(String), // timestamp — accept any string
  });
  // name and other fields are still snapshotted exactly
});

// ── Inline snapshot: snapshot lives in the test file ─────────────
it("GET /api/config matches inline snapshot", async () => {
  const res = await request(app).get("/api/config").expect(200);
  expect(res.body).toMatchInlineSnapshot(`
    {
      "features": {
        "darkMode": true,
        "beta": false,
      },
      "version": "2.1.0",
    }
  `);
  // Jest updates this string automatically when you run --updateSnapshot
});

// ── Snapshot list responses ───────────────────────────────────────
it("GET /api/users returns correct shape", async () => {
  const res = await request(app).get("/api/users").expect(200);
  // Snapshot just the first item and the count — not the entire list
  expect({
    count: res.body.length,
    firstItem: res.body[0],
  }).toMatchSnapshot({
    firstItem: expect.objectContaining({
      id: expect.any(Number),
      name: expect.any(String),
    }),
  });
});

// ── Update snapshots ──────────────────────────────────────────────
// CLI: jest --updateSnapshot   (or: jest -u)
// Watch mode: press 'u' to update all, 'i' to update one at a time
// Specific file: jest --updateSnapshot path/to/users.test.ts

Property matchers (toMatchSnapshot({ id: expect.any(Number) })) are the key to making snapshots stable in the presence of dynamic values like auto-generated IDs and timestamps. Without property matchers, any test that hits a database or generates a UUID will fail on every run because the snapshot was recorded with a different value. Inline snapshots (toMatchInlineSnapshot()) are preferable for small responses — the expected value is visible in the test file without opening a separate .snap file, and code review catches snapshot changes in the same diff as the code change. Commit .snap files to version control — they are the regression baseline, and omitting them defeats the purpose of snapshot testing.

Supertest: HTTP Integration Testing for Express and Fastify

Supertest wraps an Express or Fastify app instance and exposes a fluent HTTP assertion API. It binds the app to an ephemeral port internally, sends real HTTP requests over localhost, and tears down the connection after the test — no server.listen() call required. This makes integration tests self-contained: no port conflicts, no leaked server handles between tests.

import request from "supertest";
import express, { Request, Response } from "express";

// ── Minimal Express app for testing ──────────────────────────────
const app = express();
app.use(express.json());

app.get("/api/users/:id", (req: Request, res: Response) => {
  res.json({ id: Number(req.params.id), name: "Alice" });
});

app.post("/api/users", (req: Request, res: Response) => {
  const { name } = req.body;
  res.status(201).json({ id: 42, name });
});

app.delete("/api/users/:id", (req: Request, res: Response) => {
  res.status(204).send();
});

// ── GET request assertions ────────────────────────────────────────
describe("GET /api/users/:id", () => {
  it("returns user JSON", async () => {
    const res = await request(app)
      .get("/api/users/1")
      .expect(200)
      .expect("Content-Type", /json/);

    expect(res.body).toMatchObject({ id: 1, name: "Alice" });
  });

  it("returns 404 for unknown user", async () => {
    await request(app).get("/api/users/999").expect(404);
  });
});

// ── POST with JSON body ───────────────────────────────────────────
describe("POST /api/users", () => {
  it("creates a user and returns 201", async () => {
    const res = await request(app)
      .post("/api/users")
      .send({ name: "Bob" })        // sets Content-Type: application/json
      .expect(201);

    expect(res.body).toMatchObject({ id: expect.any(Number), name: "Bob" });
  });

  it("rejects missing name with 400", async () => {
    const res = await request(app).post("/api/users").send({}).expect(400);
    expect(res.body).toMatchObject({ error: expect.any(String) });
  });
});

// ── DELETE — no response body ─────────────────────────────────────
it("DELETE returns 204 no content", async () => {
  await request(app).delete("/api/users/1").expect(204);
});

// ── Authentication headers ────────────────────────────────────────
it("authorized request passes", async () => {
  const res = await request(app)
    .get("/api/profile")
    .set("Authorization", "Bearer test-token")
    .set("Accept", "application/json")
    .expect(200);

  expect(res.body).toMatchObject({ userId: expect.any(Number) });
});

// ── Fastify: use app.server after ready() ─────────────────────────
import Fastify from "fastify";

const fastify = Fastify();
fastify.get("/api/health", async () => ({ status: "ok" }));

describe("Fastify app", () => {
  beforeAll(async () => {
    await fastify.ready(); // initialize routes
  });

  afterAll(async () => {
    await fastify.close();
  });

  it("health check returns ok", async () => {
    const res = await request(fastify.server) // pass the underlying http.Server
      .get("/api/health")
      .expect(200);

    expect(res.body).toEqual({ status: "ok" });
  });
});

// ── Persistent server handle (avoid open handles warning) ─────────
let server: ReturnType<typeof app.listen>;

beforeAll(() => { server = app.listen(); });
afterAll((done) => { server.close(done); });

it("uses persistent server", async () => {
  await request(server).get("/api/users/1").expect(200);
});

For Fastify, call await fastify.ready() in beforeAll before running tests — Fastify registers plugins and routes asynchronously, and passing the server before it is ready results in requests to unregistered routes. Always call fastify.close() in afterAll to release the server handle and avoid Jest's open handle warning. For Express apps with keep-alive connections, use the persistent server pattern (server = app.listen()) and close it in afterAll — Supertest's ephemeral-port mode creates a new server per request(app) call, which is fine for most tests but slightly slower for large test suites. See also the JSON API design guide for structuring the endpoints under test.

Nock: Mocking HTTP Requests in JSON Tests

Nock intercepts outgoing HTTP and HTTPS requests at the Node.js http module level — before any network packet leaves the process. This makes it ideal for testing code that calls external APIs without hitting real endpoints, introducing latency, or requiring network access in CI. Interceptors are one-shot by default, matching the semantics of real API calls.

import nock from "nock";
import axios from "axios";

// ── Setup: disable real network in tests ──────────────────────────
beforeAll(() => {
  nock.disableNetConnect(); // fail on any un-mocked request
});

afterEach(() => {
  nock.cleanAll(); // remove all interceptors after each test
});

afterAll(() => {
  nock.enableNetConnect(); // re-enable for other test suites
});

// ── Basic GET mock ────────────────────────────────────────────────
it("fetches user from external API", async () => {
  nock("https://api.github.com")
    .get("/users/alice")
    .reply(200, { login: "alice", id: 12345, type: "User" });

  const res = await axios.get("https://api.github.com/users/alice");
  expect(res.data).toMatchObject({ login: "alice", type: "User" });
});

// ── POST with request body matching ──────────────────────────────
it("posts JSON and returns created resource", async () => {
  nock("https://api.example.com")
    .post("/users", { name: "Bob", email: "bob@example.com" })
    .reply(201, { id: 99, name: "Bob", email: "bob@example.com" });

  const res = await axios.post("https://api.example.com/users", {
    name: "Bob",
    email: "bob@example.com",
  });

  expect(res.status).toBe(201);
  expect(res.data).toMatchObject({ id: 99, name: "Bob" });
});

// ── Error responses ───────────────────────────────────────────────
it("handles 404 from external API", async () => {
  nock("https://api.example.com")
    .get("/users/999")
    .reply(404, { error: "User not found" });

  await expect(axios.get("https://api.example.com/users/999")).rejects.toThrow(
    "Request failed with status code 404"
  );
});

// ── Network error simulation ──────────────────────────────────────
it("handles network timeout", async () => {
  nock("https://api.example.com")
    .get("/slow-endpoint")
    .replyWithError("ECONNRESET"); // simulate connection reset

  await expect(axios.get("https://api.example.com/slow-endpoint")).rejects.toThrow();
});

// ── Persist interceptor across multiple calls ─────────────────────
it("calls API multiple times with same mock", async () => {
  nock("https://api.example.com")
    .get("/config")
    .reply(200, { version: "2.0" })
    .persist(); // stays active until nock.cleanAll()

  const [r1, r2] = await Promise.all([
    axios.get("https://api.example.com/config"),
    axios.get("https://api.example.com/config"),
  ]);

  expect(r1.data).toEqual(r2.data);
});

// ── Query string matching ─────────────────────────────────────────
it("matches query parameters", async () => {
  nock("https://api.example.com")
    .get("/search")
    .query({ q: "json", page: "1" })
    .reply(200, { results: ["a", "b"], total: 2 });

  const res = await axios.get("https://api.example.com/search", {
    params: { q: "json", page: 1 },
  });
  expect(res.data.total).toBe(2);
});

// ── Request headers matching ──────────────────────────────────────
it("only matches requests with correct auth header", async () => {
  nock("https://api.example.com", {
    reqheaders: { Authorization: "Bearer secret-token" },
  })
    .get("/protected")
    .reply(200, { data: "secret" });

  const res = await axios.get("https://api.example.com/protected", {
    headers: { Authorization: "Bearer secret-token" },
  });
  expect(res.data).toEqual({ data: "secret" });
});

// ── Delay to test timeout handling ───────────────────────────────
it("times out after 500ms", async () => {
  nock("https://api.example.com")
    .get("/slow")
    .delay(1000) // respond after 1 second
    .reply(200, { ok: true });

  await expect(
    axios.get("https://api.example.com/slow", { timeout: 500 })
  ).rejects.toThrow("timeout");
});

Always call nock.disableNetConnect() at the start of test suites that use Nock — this ensures any un-mocked HTTP request throws immediately, making it obvious when a test accidentally reaches a real endpoint rather than silently passing with live data. Use nock.cleanAll() in afterEach rather than afterAll — interceptors left over from a failed test would affect subsequent tests. Nock intercepts the Node.js http and https modules, which means it works with axios, node-fetch, and any library built on those modules. It does not intercept the native fetch API in Node.js 18+ (which uses a different internal implementation) — use MSW (Mock Service Worker) or undici mocking for native fetch. See the JSON mock data guide for generating realistic test payloads.

AJV Schema Assertions: Validate JSON Structure in Tests

AJV (Another JSON Validator) compiles JSON Schema definitions into fast validator functions. In tests, schema assertions complement matcher-based tests: rather than enumerating every property with toMatchObject(), you assert that the response conforms to the same schema used by the API documentation. This catches type errors, missing required fields, and unexpected additional properties in a single assertion.

import Ajv, { JSONSchemaType } from "ajv";
import addFormats from "ajv-formats";
import request from "supertest";
import app from "../app";

// ── Setup AJV with format support (email, date-time, uri, etc.) ───
const ajv = new Ajv({ allErrors: true }); // collect all errors, not just first
addFormats(ajv); // adds "email", "date-time", "uri" format validators

// ── Define the schema ─────────────────────────────────────────────
interface User {
  id: number;
  name: string;
  email: string;
  createdAt: string;
  role: "admin" | "editor" | "viewer";
  metadata?: Record<string, unknown>;
}

const userSchema: JSONSchemaType<User> = {
  type: "object",
  required: ["id", "name", "email", "createdAt", "role"],
  properties: {
    id: { type: "integer", minimum: 1 },
    name: { type: "string", minLength: 1, maxLength: 100 },
    email: { type: "string", format: "email" },
    createdAt: { type: "string", format: "date-time" },
    role: { type: "string", enum: ["admin", "editor", "viewer"] },
    metadata: { type: "object", nullable: true },
  },
  additionalProperties: false, // fail if response has unexpected fields
};

// ── Compile once — reuse across tests ────────────────────────────
const validateUser = ajv.compile(userSchema);

// ── Custom Jest matcher ───────────────────────────────────────────
// Add to jest.setup.ts or a global setup file
expect.extend({
  toMatchSchema<T>(received: unknown, validate: (data: unknown) => data is T) {
    const valid = validate(received);
    if (valid) {
      return { pass: true, message: () => "expected not to match schema" };
    }
    const errors = (validate as ReturnType<typeof ajv.compile>).errors;
    const errorText = ajv.errorsText(errors, { separator: "
" });
    return {
      pass: false,
      message: () => `JSON Schema validation failed:
${errorText}`,
    };
  },
});

// ── Usage in tests ────────────────────────────────────────────────
describe("GET /api/users/:id", () => {
  it("response conforms to User schema", async () => {
    const res = await request(app).get("/api/users/1").expect(200);
    expect(res.body).toMatchSchema(validateUser);
  });
});

// ── Without custom matcher — inline schema assertion ──────────────
it("validates schema inline", async () => {
  const res = await request(app).get("/api/users/1").expect(200);
  const valid = validateUser(res.body);
  if (!valid) {
    throw new Error(`Schema invalid: ${ajv.errorsText(validateUser.errors)}`);
  }
  // TypeScript now knows res.body is User
  expect(res.body.role).toBe("admin");
});

// ── List schema ───────────────────────────────────────────────────
const usersListSchema = {
  type: "object",
  required: ["data", "total"],
  properties: {
    data: { type: "array", items: userSchema },
    total: { type: "integer", minimum: 0 },
    page: { type: "integer", minimum: 1 },
  },
};

const validateUsersList = ajv.compile(usersListSchema);

it("GET /api/users returns valid list schema", async () => {
  const res = await request(app).get("/api/users").expect(200);
  const valid = validateUsersList(res.body);
  expect(valid).toBe(true);
  if (!valid) console.error(ajv.errorsText(validateUsersList.errors));
});

// ── Reuse production schemas ──────────────────────────────────────
// If your API uses JSON Schema for documentation (OpenAPI / JSON Schema files),
// import the same schema objects in tests — single source of truth
import userSchemaFromDocs from "../schemas/user.json";
const validateFromDocs = ajv.compile(userSchemaFromDocs);

Set additionalProperties: false in schemas used for API contract testing — this catches accidental field leaks (internal database columns, sensitive fields) that toMatchObject() would silently pass. Use allErrors: true when compiling validators for tests so AJV reports all violations rather than stopping at the first — a single test run reveals all schema mismatches at once. Reuse production JSON Schema files in tests by importing them directly — if your API already uses AJV or Zod for request validation, the same schema object can be imported in tests to create a single source of truth. See the JSON Schema validation guide for full JSON Schema syntax reference.

Testing JSON Edge Cases: null, undefined, large numbers

JSON edge cases cause silent bugs in production: null vs undefined behave differently across serialization boundaries, JavaScript's number type loses precision for integers above 2^53, and JSON.parse() converts all numbers to floating-point. Explicit tests for these cases prevent regressions that pass type checking but fail at runtime.

import { describe, it, expect } from "@jest/globals";

// ── null vs undefined ─────────────────────────────────────────────
describe("null and undefined in JSON", () => {
  it("null survives JSON round-trip, undefined does not", () => {
    const obj = { a: null, b: undefined, c: "value" };
    const parsed = JSON.parse(JSON.stringify(obj));

    expect(parsed).toEqual({ a: null, c: "value" });
    // b: undefined is dropped by JSON.stringify
    expect("b" in parsed).toBe(false);
  });

  it("null is not the same as missing key", () => {
    expect({ a: null }).toMatchObject({ a: null });       // PASSES
    expect({ a: null }).toMatchObject({});                // PASSES — key ignored
    expect({}).not.toMatchObject({ a: null });            // PASSES — null required
  });

  it("JSON.parse converts null correctly", () => {
    expect(JSON.parse("null")).toBeNull();
    expect(JSON.parse('{"a":null}')).toEqual({ a: null });
  });
});

// ── Large integers: precision loss above Number.MAX_SAFE_INTEGER ──
describe("large number precision", () => {
  it("integers above 2^53 lose precision", () => {
    const largeId = 9007199254740993; // 2^53 + 1 — not representable exactly
    const json = `{"id": ${largeId}}`;
    const parsed = JSON.parse(json);

    // JavaScript silently rounds the number
    expect(parsed.id).not.toBe(largeId);
    expect(parsed.id).toBe(9007199254740992); // rounded to nearest float64
  });

  it("BigInt preserves precision for large IDs", () => {
    const json = '{"id": 9007199254740993}';
    // Use a reviver to parse large integers as BigInt
    const parsed = JSON.parse(json, (key, value) => {
      if (key === "id" && typeof value === "number") {
        return BigInt(json.match(/"id":\s*(\d+)/)?.[1] ?? "0");
      }
      return value;
    });
    // Note: this reviver approach is illustrative — use a library like
    // json-bigint (npm install json-bigint) for production use
    expect(typeof parsed.id).toBe("bigint");
  });

  it("float precision in JSON", () => {
    expect(JSON.parse("0.1")).toBe(0.1);
    expect(0.1 + 0.2).not.toBe(0.3); // classic float issue
    expect(0.1 + 0.2).toBeCloseTo(0.3, 10); // use toBeCloseTo for floats
  });
});

// ── Special JSON values ───────────────────────────────────────────
describe("special values", () => {
  it("NaN and Infinity serialize to null", () => {
    expect(JSON.stringify({ a: NaN, b: Infinity, c: -Infinity })).toBe(
      '{"a":null,"b":null,"c":null}'
    );
    // Test that your API handles these as null, not as the literal strings
  });

  it("Date serializes to ISO string", () => {
    const obj = { createdAt: new Date("2025-01-01T00:00:00Z") };
    const json = JSON.stringify(obj);
    expect(json).toBe('{"createdAt":"2025-01-01T00:00:00.000Z"}');

    const parsed = JSON.parse(json);
    expect(typeof parsed.createdAt).toBe("string");
    expect(new Date(parsed.createdAt).getTime()).toBe(
      new Date("2025-01-01T00:00:00Z").getTime()
    );
  });

  it("empty object and empty array are distinct", () => {
    expect(JSON.parse("{}")).toEqual({});
    expect(JSON.parse("[]")).toEqual([]);
    expect(JSON.parse("{}")).not.toEqual([]);
  });

  it("deeply nested object does not crash", () => {
    // Generate 1000 levels of nesting
    let nested: Record<string, unknown> = { value: 42 };
    for (let i = 0; i < 1000; i++) {
      nested = { child: nested };
    }
    // JSON.stringify handles deep nesting (unlike some recursive flatten fns)
    const json = JSON.stringify(nested);
    const parsed = JSON.parse(json);
    expect(parsed).toBeTruthy();
  });
});

// ── Unicode and special characters ────────────────────────────────
describe("string edge cases", () => {
  it("unicode characters survive JSON round-trip", () => {
    const obj = { name: "Ünïcödé 🎉", emoji: "🚀" };
    expect(JSON.parse(JSON.stringify(obj))).toEqual(obj);
  });

  it("backslashes and quotes are escaped", () => {
    const json = JSON.stringify({ path: 'C:\\Users\\alice', quote: '"hello"' });
    expect(JSON.parse(json)).toEqual({ path: 'C:\\Users\\alice', quote: '"hello"' });
  });
});

The large integer precision issue is particularly dangerous for database IDs — many databases use 64-bit integers for primary keys, and any ID above 9,007,199,254,740,991 (JavaScript's Number.MAX_SAFE_INTEGER) is silently rounded when parsed with JSON.parse(). Test for this explicitly if your API returns IDs from a database that could grow into that range. Use the json-bigint npm package or return large IDs as strings from the API. For monetary values, never use floating-point — store and return amounts as integers (cents) and test that your serialization preserves the exact integer value. See the JSON error handling guide for handling parse errors from malformed inputs.

Vitest: Vite-Native JSON Testing

Vitest is a test runner built on Vite that uses the same configuration file (vite.config.ts) and module resolution as the application. It is API-compatible with Jest — all JSON matchers, snapshot functions, and mock utilities work identically. The main advantage is native ESM support and elimination of separate Babel configuration for TypeScript and JSX transforms.

// ── Install Vitest ────────────────────────────────────────────────
// npm install --save-dev vitest @vitest/coverage-v8

// ── vite.config.ts ────────────────────────────────────────────────
import { defineConfig } from "vite";
import { configDefaults } from "vitest/config";

export default defineConfig({
  test: {
    globals: true,               // no need to import describe/it/expect
    environment: "node",         // use "jsdom" for browser-like tests
    coverage: {
      provider: "v8",
      reporter: ["text", "html"],
      exclude: [...configDefaults.exclude, "**/*.config.*"],
    },
    setupFiles: ["./vitest.setup.ts"],
  },
});

// ── vitest.setup.ts ───────────────────────────────────────────────
import { expect, afterEach } from "vitest";
import nock from "nock";

// Custom schema matcher (same as Jest version)
expect.extend({
  toMatchSchema(received: unknown, validate: (data: unknown) => boolean) {
    const valid = validate(received);
    return valid
      ? { pass: true, message: () => "expected not to match schema" }
      : { pass: false, message: () => "JSON Schema validation failed" };
  },
});

afterEach(() => {
  nock.cleanAll();
});

// ── Test file — same API as Jest ──────────────────────────────────
// import { describe, it, expect } from "vitest";  // or use globals: true
import request from "supertest";
import app from "../app";

describe("GET /api/users/:id", () => {
  it("returns JSON with correct shape", async () => {
    const res = await request(app).get("/api/users/1").expect(200);
    expect(res.body).toMatchObject({ id: 1, name: "Alice" });
  });

  it("matches snapshot", async () => {
    const res = await request(app).get("/api/users/1").expect(200);
    expect(res.body).toMatchSnapshot();
    // Snapshot files: __snapshots__/users.test.ts.snap (same format as Jest)
  });
});

// ── vi.mock: Vitest's module mock (replaces jest.mock) ───────────
import { vi } from "vitest";

vi.mock("../services/userService", () => ({
  getUserById: vi.fn().mockResolvedValue({ id: 1, name: "Alice" }),
}));

// ── vi.fn vs jest.fn ──────────────────────────────────────────────
const fetchUser = vi.fn().mockResolvedValue({ id: 1, name: "Alice" });
expect(fetchUser).not.toHaveBeenCalled();
await fetchUser(1);
expect(fetchUser).toHaveBeenCalledWith(1);
expect(fetchUser).toHaveBeenCalledTimes(1);

// ── Fake timers for JSON polling tests ───────────────────────────
it("polls API every 5 seconds", async () => {
  vi.useFakeTimers();
  const poll = vi.fn().mockResolvedValue({ data: [] });

  const interval = setInterval(poll, 5000);
  vi.advanceTimersByTime(15000); // advance 15 seconds
  clearInterval(interval);

  expect(poll).toHaveBeenCalledTimes(3);
  vi.useRealTimers();
});

// ── Run commands ──────────────────────────────────────────────────
// npx vitest                          — watch mode
// npx vitest run                      — single run (CI)
// npx vitest run --coverage           — with coverage
// npx vitest run --updateSnapshot     — update snapshots
// npx vitest --reporter=verbose       — detailed output

Vitest's globals: true configuration option eliminates the need to import describe, it, and expect in every test file — a drop-in compatibility layer for Jest codebases. The main incompatibility to watch for is Nock: Nock intercepts Node.js's http module, but Vitest runs tests with native ESM, and some versions of Node.js 18+ use the undici-based fetch implementation that bypasses the http module entirely. If Nock interceptors are not catching requests in Vitest, check whether the code under test uses native fetch (not intercepted by Nock) or axios/node-fetch v2 (intercepted by Nock). Use MSW (Mock Service Worker) as an alternative that intercepts at the fetch level and works correctly with both Jest and Vitest. For comprehensive API testing patterns, see the JSON API design guide.

Key Terms

toMatchObject()
A Jest matcher that performs a partial deep equality check. The received object must contain all properties specified in the expected object, but may have additional properties that are ignored. Nested objects are also checked partially — expect({ a: { b: 1, c: 2 } }).toMatchObject({ a: { b: 1 } }) passes because a.c is not required. Use toMatchObject() for API integration tests where responses may include fields not relevant to the current assertion. It can be combined with expect.any(), expect.arrayContaining(), and other asymmetric matchers for flexible assertions. Contrast with toEqual(), which requires both objects to be exactly equal with no extra properties.
toEqual()
A Jest matcher that performs deep equality comparison on all enumerable properties. Both the received and expected objects must have identical keys and values — extra properties in either object cause the assertion to fail. Unlike toStrictEqual(), toEqual() treats objects with the same properties as equal regardless of their constructor — a plain object { name: "Alice" } and a class instance new User("Alice") are considered equal if they have the same enumerable properties. toEqual() also ignores explicit undefined property values — { a: undefined } and {} are considered equal. Use toEqual() for unit tests on data transformation functions where the complete output shape is known.
toStrictEqual()
The strictest Jest equality matcher. It extends toEqual() with two additional checks: (1) object types — instances of different classes are not equal even if they have identical properties; (2) undefined properties — { a: undefined } is not equal to {} because one has the key a (set to undefined) and the other does not. Use toStrictEqual() when testing code that explicitly distinguishes between a missing property and a property set to undefined, or when testing class-based data models where type identity matters. In most API JSON testing scenarios, toEqual() or toMatchObject() is more appropriate.
snapshot testing
A testing technique where the output of a function or API endpoint is serialized to a file (.snap) on the first run and compared against that file on subsequent runs. Any difference between the current output and the stored snapshot causes the test to fail. Snapshot files live in a __snapshots__ directory next to the test file and should be committed to version control. Update snapshots with jest --updateSnapshot (or jest -u) when an intentional change is made. Inline snapshots (toMatchInlineSnapshot()) store the expected value as a string argument in the test file itself, updated by Jest automatically. Snapshot tests excel at catching structural regressions without writing verbose explicit assertions, but require discipline to review snapshot updates carefully.
Supertest
An npm package (npm install --save-dev supertest) for HTTP integration testing in Node.js. It accepts an Express, Fastify, or any Node.js HTTP server and makes real HTTP requests over an ephemeral localhost port — no manual server.listen() or port management required. The fluent API chains assertions: request(app).get("/path").expect(200).expect("Content-Type", /json/). Response body assertions use res.body (auto-parsed JSON) or res.text (raw string). Supertest works with async/await and returns a promise. It is framework-agnostic — the app argument can be any object that implements listen() or a Node.js http.Server instance.
Nock
An npm package (npm install --save-dev nock) that intercepts outgoing HTTP and HTTPS requests at the Node.js http/https module level, preventing real network calls and returning predefined responses. Interceptors are defined with nock(baseUrl).get(path).reply(statusCode, body) and are consumed once by default. Use .persist() for interceptors that should respond to multiple requests. nock.disableNetConnect() causes any un-intercepted request to throw, ensuring tests never make unintended network calls. nock.cleanAll() removes all interceptors. Nock works with axios, node-fetch, and any library that uses Node.js's http module, but does not intercept the native Fetch API in Node.js 18+.
schema assertion
A test assertion that validates a JSON value against a formal JSON Schema definition rather than comparing it to a specific expected value. Schema assertions check structure (required fields, property types, enum values, format constraints like email and date-time) without locking down the exact data. Implemented in tests using AJV: compile a schema with ajv.compile(schema), then call the resulting validator function. A schema assertion fails if the JSON violates any schema constraint and reports the specific violations. Schema assertions are particularly valuable for contract testing — the same schema used for API documentation (OpenAPI, JSON Schema) can be imported in tests to verify that responses conform to the published contract.

FAQ

What is the difference between toEqual and toMatchObject in Jest?

toEqual() checks deep equality — both the received and expected objects must have identical keys and values; extra properties in either object cause the assertion to fail. toMatchObject() checks a subset — the received object must contain all properties in the expected object, but may have additional properties that are ignored. Example: expect({ id: 1, name: "Alice", createdAt: "2025-01-01" }).toEqual({ id: 1, name: "Alice" }) fails because the received object has the extra createdAt property. The same assertion with toMatchObject() passes. Use toMatchObject() for API integration tests where responses include metadata fields you do not care about — it makes tests more resilient to API evolution. Use toEqual() for unit tests on pure functions where the complete output is deterministic and intentional. Both matchers perform deep equality on nested objects — a nested property in the expected object is also checked deeply. Combine toMatchObject() with expect.any(Number) for properties whose exact value is dynamic but whose type must be correct.

How do I snapshot test a JSON API response in Jest?

Use expect(value).toMatchSnapshot() after receiving the JSON response. On the first run Jest creates a .snap file; on subsequent runs it diffs the current value against the stored file. With Supertest: const res = await request(app).get("/api/users/1"); expect(res.body).toMatchSnapshot(). For stable snapshots, exclude dynamic fields like timestamps and auto-generated IDs using property matchers: expect(res.body).toMatchSnapshot({ id: expect.any(Number), createdAt: expect.any(String) }) — these fields are replaced with their type matchers in the snapshot, making them stable across runs while still verifying their types. For inline snapshots that live in the test file, use toMatchInlineSnapshot() — Jest updates the string argument automatically when you run --updateSnapshot. Snapshot only the relevant part of the response — expect(res.body.user).toMatchSnapshot() rather than expect(res.body).toMatchSnapshot() — to reduce the surface area of snapshot updates.

How do I test a REST API that returns JSON without running a server?

Use Supertest (npm install --save-dev supertest). Pass the Express or Fastify app object (not a running server) to request(app) — Supertest binds it to an ephemeral port internally, runs the test, and releases the port. No server.listen() call is needed. Basic pattern: import request from "supertest"; import app from "../app"; then it("returns user", async () => { const res = await request(app).get("/api/users/1").expect(200); expect(res.body).toMatchObject({ id: 1 }); }). For Fastify, call await fastify.ready() first to initialize plugins and routes, then pass fastify.server to Supertest. For Express apps with persistent connections, create a server handle with const server = app.listen() in beforeAll and close it in afterAll — this avoids Jest's open handle warning. Supertest supports all HTTP methods (.get(), .post(), .put(), .patch(), .delete()), setting headers with .set(), sending bodies with .send(), and asserting status codes and headers inline with .expect().

How do I mock HTTP requests in Jest JSON tests?

Use Nock (npm install --save-dev nock). Define interceptors before the code under test makes HTTP requests: nock("https://api.example.com").get("/users/1").reply(200, { id: 1, name: "Alice" }). Any request to that URL/path returns the mock response. Call nock.disableNetConnect() in a global setup file to ensure un-mocked requests throw — this prevents tests from accidentally hitting real APIs. Clean up with nock.cleanAll() in afterEach. For POST requests with body matching: nock("https://api.example.com").post("/users", { name: "Bob" }).reply(201, { id: 2 }). For persistent interceptors (called multiple times): add .persist() after .reply(). Nock intercepts the Node.js http/https module and works with axios and node-fetch v2. It does not intercept native fetch in Node.js 18+ — use MSW for fetch-based code. See the JSON mock data guide for generating realistic mock payloads.

How do I assert that a JSON response matches a JSON Schema?

Use AJV (npm install --save-dev ajv ajv-formats) to compile a schema and validate the response. Pattern: import Ajv from "ajv"; const ajv = new Ajv({ allErrors: true }); const validate = ajv.compile(mySchema); const valid = validate(res.body); if (!valid) throw new Error(ajv.errorsText(validate.errors)). For reusable assertions, add a custom Jest matcher in your jest.setup.ts using expect.extend() with a toMatchSchema function that runs the AJV validator and returns pass/fail with the error text as the message. Then in tests: expect(res.body).toMatchSchema(validateUser). Schema assertions are more robust than matcher-based tests for contract validation — they catch type errors, missing required fields, invalid enum values, and format violations (email, date-time) with descriptive error messages. Set additionalProperties: false to catch unexpected field leaks. Reuse the same schema files that document your API to maintain a single source of truth. See the JSON Schema validation guide for full schema syntax.

How do I update Jest snapshots when my API response changes?

Run jest --updateSnapshot (or jest -u) to regenerate all failing snapshots with the current values. To update snapshots for a specific file: jest --updateSnapshot path/to/test.spec.ts. To update by test name pattern: jest --updateSnapshot --testNamePattern "user endpoint". In interactive watch mode, press u to update all failing snapshots at once, or press i to step through them individually. Always review the diff carefully before updating — snapshot failures mean either a bug (do not update) or an intentional API change (update and commit the new .snap file). Commit .snap files to version control alongside your tests. To detect obsolete snapshots (for deleted tests), run jest --ci — it fails on obsolete snapshots without deleting them; run --updateSnapshot locally to remove them. For inline snapshots, Jest updates the string argument in the test file directly when --updateSnapshot is run — no separate .snap file is modified.

What is toStrictEqual and when should I use it?

toStrictEqual() extends toEqual() with two stricter checks. First, it distinguishes between a property explicitly set to undefined and a missing property — expect({ a: undefined }).toStrictEqual({}) fails, whereas the same assertion with toEqual() passes. Second, it checks that compared objects have the same constructor — a class instance is not strictly equal to a plain object with identical properties. Use toStrictEqual() when testing data transformation or parsing functions where the exact output type matters and undefined properties are semantically significant. Common cases: testing a function that parses form input and explicitly sets missing optional fields to undefined vs. omitting them; testing a factory function that returns class instances rather than plain objects. In API integration testing with JSON responses, toStrictEqual() is rarely the right choice — JSON responses are always plain objects, and undefined values do not survive JSON serialization. Use toMatchObject() or toEqual() for the vast majority of API JSON assertions.

How do I test JSON with Vitest?

Vitest's matcher API is a drop-in replacement for Jest — toEqual(), toMatchObject(), toStrictEqual(), toMatchSnapshot(), and toMatchInlineSnapshot() all work identically. Install: npm install --save-dev vitest. Replace the test script in package.json: "test": "vitest run". Change imports from "@jest/globals" to "vitest", or set globals: true in vite.config.ts to avoid imports entirely. Snapshot files use the same __snapshots__ directory and .snap format — snapshots are portable between Jest and Vitest. Supertest works with Vitest without any changes. For Nock, be aware that Vitest encourages native ESM, and Node.js 18+ native fetch bypasses the http module Nock intercepts — if your code uses axios or node-fetch v2, Nock works normally. Mock functions use vi.fn() instead of jest.fn(); module mocking uses vi.mock(). Coverage uses @vitest/coverage-v8: run vitest run --coverage. The primary benefit of Vitest over Jest is no separate Babel/transform configuration — it reuses the Vite config the application already has, making TypeScript and JSX work out of the box.

Further reading and primary sources

  • Jest expect documentationOfficial Jest API reference for all matchers including toMatchObject, toEqual, toStrictEqual, and toMatchSnapshot
  • Supertest GitHub repositorySupertest source, API documentation, and usage examples for HTTP integration testing in Node.js
  • Nock GitHub repositoryNock documentation covering interceptors, request matching, persistence, recording, and native fetch limitations
  • AJV documentationAJV JSON Schema validator — API reference, supported drafts, custom keywords, and performance benchmarks
  • Vitest documentationVitest official docs — configuration, Jest compatibility API, snapshot testing, and coverage setup