How-to › Test and mock integrations

How to run an in-memory mock of your API inside SDK tests#

Build the client in test mode, seed it with records, and let the generated SDK answer its own calls from memory instead of reaching the network.

Audience
API producer
Level
intermediate
Topic
Mock an API
Languages
TypeScript and JavaScript
Verified

Your SDK’s test suite starts a server on a port, seeds it over HTTP, and fails on a developer machine where that port is taken. Half the tests exist to check argument handling and never needed a socket at all. The suite takes ninety seconds and the failures are about the harness rather than about the client.

What you get

You will end up with a client that answers its own calls from a map you seeded, so a test exercises the real generated code path with no server and no port. This is for you if you ship a generated SDK whose tests need something listening.

Short answer

Construct the client with MetercoSDK.test(seed) rather than new MetercoSDK(), where seed holds records keyed by id under each entity. The generated test feature then answers list, load and create from that map, so a test needs no server, no port, and no fixture files. Assert the mode as well, because a client that fell back to live looks identical from outside.

You will need

Node 22 or later, and an SDK generated with the test feature enabled. The sample was generated with create-sdkgen and its compiled output is committed, so the commands here run offline. One thing to know first: apidef refuses a description with no servers[0].url, and says so rather than guessing.

Voxgig maintains sdkgen. This page compares its test feature with MSW, nock, and a mock server you run.1

Approaches compared

ApproachWhen it fitsWhat it costs youWhen to pick something else
MSW at the fetch layerThe client uses fetch and you want the same handlers in a browserHandlers written per endpoint, and a layer below the SDK to keep in stepThe SDK offers a mode that skips the transport entirely
A local mock serverYou want to exercise headers, status codes and the wire formatA port, a lifecycle, and flakiness on machines where the port is busyThe test is about argument handling rather than the wire
nock at the transport layerA client on Node’s http module, with recordings of real trafficInterceptors that go stale, and a layer the generated client may not useThe client calls fetch rather than http
The sdkgen test featureThe SDK is generated and you want no transport in the test at allA seed shaped the way the mock expects, and behavior to pin per versionYou need to assert on headers, retries or status codes

The dividing line is which layer you replace. MSW and nock replace the transport, so the whole client sits on top of them and the wire format still matters.2 The test feature replaces the request itself, so nothing is serialized and nothing is parsed, and a test runs in milliseconds.

That is also its limit. A mode that never builds a request cannot tell you the authentication header was wrong. A suite built only on it passes while the client cannot talk to the service at all. Keep a small number of tests at the transport layer, or against a sandbox.

Seed by the record id, and assert the mode

Two things belong in every test that uses it.

export const seed = () => ({
  entity: {
    meter: {
      mtr_8f2: { id: 'mtr_8f2', serial: 'SN-40199', state: 'installed' },
      mtr_31a: { id: 'mtr_31a', serial: 'SN-40200', state: 'retired' },
    },
  },
})

Each record carries an id, and the map key matches it. That is what the mock selects on, and the next section shows what happens when a caller addresses a record some other way.

Assert _mode in at least one test. A client that was meant to be in test mode and is not looks exactly the same until something reaches the network, which on a developer machine may even succeed.

Address records the way the client does

The mock matches on the record’s own fields, not on the route’s path parameters.

await show("load({ id })", async () => (await sdk.Meter().load({ id: 'mtr_8f2' })).data().serial)
await show("load({ meterId })", async () => (await sdk.Meter().load({ meterId: 'mtr_8f2' })).data().serial)

The description’s path is /meters/{meterId}, so passing meterId looks right and is not what the generated client asks for. The entity operations take the record’s own key, which is what the README the generator writes shows.

Check it worked

Run both shapes against a seeded client.

node demo.mjs
test client mode               test
ordinary client mode           live
list                           ["SN-40200","SN-40199"]
load({ id })                   "SN-40199"
load({ meterId })              "SN-40200"
create, then list again        ["SN-40200","SN-40199","SN-40201"]
load({ id: 'mtr_nope' })       error: MetercoSDK: load: request: 404: Not found

Line five is the finding worth carrying away. load({ meterId }) returned a record, and it is the wrong one: the mock matched nothing on that field and answered with whatever was first. It does not raise, so a test written that way passes while asserting something untrue. Address records by id, and assert on a field that differs between your seeded records so a wrong match fails.

The rest behaves as you would want. A create is readable afterwards, an id nobody seeded raises a 404 from the mock rather than a network error, and the ordinary client reports live so a missed test-mode construction is visible.

The list order is the seed’s insertion order reversed, which is worth knowing before you assert on it. Sort in the test, or compare as a set, and the suite survives a seed somebody reorders.

node --test sdk.test.mjs
1..6
# tests 6
# suites 0
# pass 6
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 170.380797

When it goes wrong

The mock answers nothing and every call is a 404. The seed is keyed under the wrong entity name. Use the name the generated client uses, which comes from the model rather than from the path segment.

A create vanishes. The client was built with no seed at all, so each operation reads a fresh empty map. Pass a seed object, even an empty one per entity, and the store persists for that client.

Two tests interfere. They share one seeded client. Build a client per test, which costs nothing because there is no server to start and no port to wait for.

The suite passes and the released SDK cannot authenticate. Nothing in test mode builds a request. Add a handful of transport-level tests, and run them against a sandbox before a release. Three is usually enough: one authenticated read, one write, and one deliberate failure.

When not to do this

Do not test retries, timeouts or rate limit handling with the test feature. Those live in the transport, and a mode that skips the transport cannot exercise them. Use nock, MSW or a mock server for that layer.

Do not seed a fixture large enough to need its own maintenance. A mock with two hundred records is a second database, and the tests that use it start depending on data nobody can explain.

Do not rely on sdkgen’s test feature behaving the same across versions without pinning it. The load result shown here is version-specific, and the versions are recorded in this page’s frontmatter. Run the same check in your own project before your tests depend on it.

Last verified

Verified 2026-09-14 against Node 22.22.2, @voxgig/create-sdkgen 0.21.0 and @voxgig/sdkgen 4.17.1. Both output blocks are what the preceding command printed.

Footnotes

  1. The name describes the browser half. The MSW philosophy page says it intercepts traffic by using a designated Service Worker in the browser, or by implementing custom request interception algorithms in Node.js. A Service Worker is a browser API, and the second clause is what runs in a test suite. Mock Service Worker in Node is therefore a mock, of a service, without the worker. ↩︎ Back to text

  2. The two rows share a layer. The nock README says it works by overriding Node’s http.request function, and the registry record for nock 14.0.0 lists @mswjs/interceptors among its three dependencies. The package document dates that release to January 2025. One of the two transport-layer rows in the table is now built on interception code published by the other. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Get the Voxgig dispatch

Short notes on building SDKs, CLIs, REPLs, and MCPs for API-first teams, plus the occasional Fireside episode pick.

By signing up you agree to our Terms and Conditions.