Skip to content

Recording and Replaying OpenAI API Calls With nock

10 min read · updated August 11, 2026

The most common nock failure in an LLM project is not a bad stub. It is a stub that is never consulted, because the request left through a door nock is not standing at.

Check the seam before you write a stub

nock works by replacing Node’s http.request and http.ClientRequest. That covers axios, got, anything on node-fetch, and any library that ultimately calls the built-in HTTP module. It does not, on its own, cover the global fetch that Node has shipped since v18, because that is undici and it does not go through http.ClientRequest.

The official openai Node SDK uses fetch. So on nock 13 and earlier, a perfectly correct-looking stub sits there unmatched while your test makes a real, billed call to the provider — or, more often, fails with an authentication error in CI where no key exists, which is at least a loud failure. nock 14 added interception of native fetch and dropped support for Node below 18. If you are mocking the OpenAI SDK, that version floor is the whole story; nock’s repository documents the current interception surface.

Two minutes of verification saves an afternoon. Before writing anything else, stub a request, make it, and assert that the scope is done:

import nock from "nock";
import OpenAI from "openai";

test("nock actually intercepts this client", async () => {
  const scope = nock("https://api.openai.com")
    .post("/v1/chat/completions")
    .reply(200, { object: "chat.completion", id: "x", choices: [] });

  const client = new OpenAI({ apiKey: "test-key" });
  await client.chat.completions.create({
    model: "gpt-4o-mini",
    messages: [{ role: "user", content: "ping" }],
  });

  expect(scope.isDone()).toBe(true);
});

If that fails, no amount of body-matching cleverness will help. Fix the seam first: upgrade nock, or move the boundary to your own client wrapper as described in injecting the LLM client.

What to assert on

nock gives you two kinds of assertion and the second is the valuable one. The first is on the response path: feed a fixed response body in and check that your code turns it into the right object. The second is on the request path: check that what your code sent was what you meant to send. That is where nock earns its place, because the request is the part you wrote and the part that regresses.

Assert that the tool schema you registered actually reached the provider, that a retry carried the same idempotency key, that a user’s document did not end up in a system prompt it should not be in, that max_tokens is set at all. And assert on the call count — scope.isDone() plus a second interceptor that is expected not to fire is how you prove a cache actually prevented a second request.

What not to assert on is the assistant’s text. You wrote it into the fixture, so an assertion that it comes back tells you that nock works. The interesting response-side assertions are all about shape: that a tool_calls response with a null content does not produce an empty message in your UI, that a finish_reason of content_filter is handled distinctly from stop, that a 400 with a provider error envelope becomes a typed error rather than an unhandled rejection. Fixtures are cheap, so write one per awkward shape rather than one happy path with several assertions hung off it.

A stub that matches

nock’s body matching for a JSON object is deep equality. The object you pass must equal the whole parsed body, key for key. SDKs add fields you did not set, so an object matcher written from what you think you sent will fail, and nock’s error message tells you only that no interceptor matched.

Use the function form, which receives the parsed body and returns a boolean. It matches on what you care about and ignores the rest:

const scope = nock("https://api.openai.com")
  .post("/v1/chat/completions", (body) => {
    expect(body.model).toBe("gpt-4o-mini");
    expect(body.tools.map((t) => t.function.name)).toEqual(["lookup_order"]);
    expect(body.messages[0].role).toBe("system");
    return true;
  })
  .reply(200, {
    object: "chat.completion",
    id: "chatcmpl-abc",
    model: "gpt-4o-mini",
    choices: [
      {
        index: 0,
        finish_reason: "tool_calls",
        message: {
          role: "assistant",
          content: null,
          tool_calls: [
            {
              type: "function",
              id: "call_1",
              function: {
                name: "lookup_order",
                arguments: '{"order_id":"A-1"}',
              },
            },
          ],
        },
      },
    ],
    usage: { prompt_tokens: 91, completion_tokens: 12, total_tokens: 103 },
  });

Putting the expectations inside the matcher is a deliberate trick: when they fail you get a diff naming the field, instead of a generic no-match error thrown from inside the SDK. Note also content: null in the fixture. A real tool call has no text content, and a parser that assumes a string there is the bug this fixture exists to catch.

Replying with a stream

nock can reply with a readable stream by passing a function that returns one, which is how you replay server-sent events. The body is an SSE text stream: lines of data: followed by a JSON chunk, separated by blank lines, ending with a literal "data: [DONE]" line. Set the content type or the SDK will not treat it as a stream:

import { Readable } from "node:stream";

const chunks = [
  'data: {"choices":[{"delta":{"role":"assistant"},"index":0}]}\n\n',
  'data: {"choices":[{"delta":{"content":"Hel"},"index":0}]}\n\n',
  'data: {"choices":[{"delta":{"content":"lo"},"index":0}]}\n\n',
  'data: {"choices":[{"delta":{},"finish_reason":"stop","index":0}]}\n\n',
  "data: [DONE]\n\n",
];

nock("https://api.openai.com")
  .post("/v1/chat/completions")
  .reply(200, () => Readable.from(chunks), {
    "content-type": "text/event-stream",
  });

Splitting the array at chunk boundaries is the easy case. The interesting fixture splits one data: line across two pushes, so the parser has to buffer a partial line — recording streaming responses as fixtures is about building that on purpose.

Recording instead of hand-writing

For a response shape you do not want to type out, nock has a recorder and a fixture layer. nock.recorder.rec() takes output_objects: true and dont_print: true so you can capture interceptors as data and write them to a file yourself. nock.back is the cassette equivalent, with modes wild, dryrun, record, update and lockdown.

Run CI in lockdown. It replays recorded fixtures and refuses all real HTTP, which is the Node equivalent of VCR.py’s none and gives you the same guarantee: a test without a fixture fails rather than dials out. update is the deliberate refresh. And recorded fixtures carry your Authorization header unless you strip it, so read the redaction page before the first recording, not after.

Teardown that makes failures legible

  1. Call nock.disableNetConnect() once in your test setup file. Any request you did not stub now throws a NetConnectNotAllowedError naming the host, which is a far better failure than a timeout or a surprise invoice.
  2. Call nock.cleanAll() in an afterEach. Interceptors are consumed on match but an unmatched one persists, and a leftover interceptor from a failed test will answer a request in the next test, producing a failure in a file you did not touch.
  3. Assert scope.isDone() in every test that sets up a scope. Without it, a test where the request was never made passes, and a code path you deleted looks tested.
  4. Re-enable with nock.enableNetConnect() in global teardown if anything else in the process needs the network.
  5. Allow 127.0.0.1 explicitly if your test runner talks to a local server. disableNetConnect() blocks everything, and the resulting failure in an unrelated integration test is easy to misattribute to the change you were making.