How-to › Test and mock integrations

How to mock a generated SDK in your application tests#

Run one set of assertions about your code against a vendor SDK four ways, then bump the SDK a minor version and see which strategy noticed the change.

Audience
API consumer
Level
intermediate
Topic
Mock an API
Languages
TypeScript, JavaScript and Python
Verified

Read first: Stub outbound HTTP calls in Node tests with nock

Your billing job calls a vendor’s generated SDK, and its tests reach the vendor’s sandbox. They fail on the vendor’s deploy day and pass when the sandbox answers 200 to everything. The one test that matters, whether a meter can be retired twice, has never run against the client code that ships to production.

What you get

You will end up with one set of assertions about your code, run four ways against the same vendor SDK. A minor-version bump of that SDK then shows which strategies noticed. This is for you if your tests touch a client you did not write.

Short answer

Intercept the SDK’s HTTP calls with nock or MSW when you want the vendor’s real client code inside the test, and turn every request you did not expect into a failure. Write a fake only for the methods you call, and pin its surface to the SDK’s with an arity check so a minor release cannot drift past it. Use the SDK’s own test mode where it has one, because keeping that in step is the vendor’s job.

You will need

Node 22 or later, with nock and MSW installed in the sample directory, and Python 3.11 or later for the last section. Verified 2026-09-24 against Node 22.22.2, nock 14.0.17, msw 2.15.0 and Python 3.11.15. The vendor SDK here is a stand-in: two versions of a small generated client, 1.2.0 and 1.3.0, committed under vendor/ so the bump runs offline. Its test mode copies the shape of the sdkgen test feature, MetercoSDK.test(seed), whose real behavior is recorded on the in-memory mock page.

Voxgig maintains sdkgen. This page compares its test feature with nock, with MSW, and with a fake you write yourself.

Approaches compared

ApproachWhen it fitsWhat it costs youWhen to pick something else
A hand-written fake clientThe SDK is large and you call three methods of it, and the tests must run with no HTTP layer at allEvery method you fake is a copy of the vendor’s behavior that stops being true on the next releaseThe SDK’s parsing, headers or retries are part of what you are testing
MSWThe same handlers should serve Node tests, browser tests and local developmentHandlers persist, so counting writes takes a counter, and a larger tool than a Node suite needsOnly Node tests need stubbing, and per-test interceptors are enough
nockNode suites where an interceptor per request is the assertion you wantAn interceptor is spent once, so a retry or a second read needs a persist or a countThe suite runs in a browser as well
The sdkgen test featureThe SDK was generated with the feature and the test is about argument handling, not the wireNothing is serialized and nothing is sent, so a wrong header or a broken retry passesThe SDK has no such mode, or the wire is the subject

The dividing line is how much of the vendor’s code runs in the test. An interceptor runs all of it, so a cache or a retry the vendor adds shows up in your suite. A fake runs none of it, so the suite is fast and stays green through changes it should have caught. A vendor’s test mode runs the client’s argument handling and skips its transport, and the vendor maintains it.

Write the assertions once

The application code reads a meter, and updates it unless it is already retired.

export async function retireMeter(sdk, id) {
  const meter = await sdk.getMeter(id)
  if (meter.state === 'retired') return { changed: false, serial: meter.serial }
  const updated = await sdk.updateMeter(id, { state: 'retired' })
  return { changed: true, serial: updated.serial }
}

The five assertions about it live in one file and name no mock. Each strategy supplies a client() that returns a fresh SDK, and that is the whole difference between the four suites.

export function appCases(name, client) {
  test(`${name}: retiring an installed meter sends the update`, async () => {
    assert.deepEqual(await retireMeter(client(), 'mtr_1'), { changed: true, serial: 'SN-40199' })
  })

  test(`${name}: retiring a retired meter sends nothing`, async () => {
    assert.deepEqual(await retireMeter(client(), 'mtr_2'), { changed: false, serial: 'SN-40200' })
  })

  test(`${name}: retiring the same meter twice on one client changes it once`, async () => {
    const sdk = client()
    assert.equal((await retireMeter(sdk, 'mtr_1')).changed, true)
    assert.equal((await retireMeter(sdk, 'mtr_1')).changed, false)
  })

The third test is the one to keep an eye on. It reuses one client for two calls, which is what a long-running job does, and it is the test that moves when the SDK moves.

Intercept at the HTTP layer

With nock, reads are declared as persistent and the write is declared once. A second PATCH has nothing to match, and with the network turned off it fails the test, which is the assertion that the application writes once.

function client() {
  const byId = Object.fromEntries(rows.map((r) => [r.id, { ...r }]))
  nock(API)
    .persist()
    .get('/v1/meters')
    .reply(200, () => Object.values(byId))
    .get(/\/v1\/meters\/\w+$/)
    .reply((uri) => {
      const row = byId[uri.split('/').pop()]
      return row ? [200, row] : [404, { error: 'not found' }]
    })
  nock(API)
    .patch(/\/v1\/meters\/\w+$/)
    .reply((uri, body) => [200, Object.assign(byId[uri.split('/').pop()], body)])
  return new MetercoSDK({ apiKey: 'sk_test_1' })
}

MSW says the same thing in a different vocabulary. Handlers persist until resetHandlers, and onUnhandledRequest: 'error' does the job nock.disableNetConnect does: a request no handler matches fails the test instead of leaving the machine.

before(() => server.listen({ onUnhandledRequest: 'error' }))
afterEach(() => server.resetHandlers())
after(() => server.close())

function client() {
  const byId = Object.fromEntries(rows.map((r) => [r.id, { ...r }]))
  server.use(
    http.get(`${API}/meters`, () => HttpResponse.json(Object.values(byId))),
    http.get(`${API}/meters/:id`, ({ params }) =>
      byId[params.id] ? HttpResponse.json(byId[params.id]) : HttpResponse.json({ error: 'not found' }, { status: 404 }),
    ),
    http.patch(`${API}/meters/:id`, async ({ params, request }) =>
      HttpResponse.json(Object.assign(byId[params.id], await request.json())),
    ),
  )
  return new MetercoSDK({ apiKey: 'sk_test_1' })
}

Both intercept the global fetch the SDK calls, in process, with no port.1 Both keep the vendor’s parsing, error mapping and headers inside the test, so a sixth test in each file asserts the bearer token went out, which no fake can check.

Fake the interface, and pin it to the real one

A fake is the SDK’s methods over a map. It is the fastest of the four and the only one with no vendor code in it, and that second property is the risk.

test('fake: the fake covers every SDK method the application calls, with the same arity', () => {
  for (const name of ['listMeters', 'getMeter', 'updateMeter']) {
    assert.equal(typeof MetercoSDK.prototype[name], 'function', `${name} exists on the SDK`)
    assert.equal(FakeMeterco.prototype[name].length, MetercoSDK.prototype[name].length, `${name} arity`)
  }
})

Nothing else in a suite checks that the fake still resembles the SDK. This test does, cheaply: a method the application calls must exist on both, with the same number of declared parameters. It cannot see a changed return shape, and it says so by being the only test that mentions the SDK’s prototype.

The vendor’s own mode needs no such check, because the vendor ships it with the client.

const client = () => MetercoSDK.test({ meters: Object.fromEntries(rows.map((r) => [r.id, r])) })

appCases('test mode', client)

test('test mode: the client reports the mode, because a live one looks the same until it connects', () => {
  assert.equal(client()._mode, 'test')
  assert.equal(new MetercoSDK({ apiKey: 'sk_test_1' })._mode, 'live')
})

Check it worked

Bump the SDK from 1.2.0 to 1.3.0 and rerun all four suites unchanged. The release adds a cache for GET responses per client and a query argument on listMeters, which is what a minor version is for.2

node bump.mjs
meterco-sdk 1.2.0
  nock.test.mjs       6 pass  0 fail
  msw.test.mjs        6 pass  0 fail
  fake.test.mjs       6 pass  0 fail
  testmode.test.mjs   6 pass  0 fail

meterco-sdk 1.3.0 (adds a GET cache per client and a query argument on listMeters)
  nock.test.mjs       5 pass  1 fail  retiring the same meter twice on one client changes it once
  msw.test.mjs        5 pass  1 fail  retiring the same meter twice on one client changes it once
  fake.test.mjs       5 pass  1 fail  the fake covers every SDK method the application calls, with the same arity
  testmode.test.mjs   6 pass  0 fail

Read the second block row by row. The two interception suites fail the same test, because the real client now answers the second getMeter from its cache, still reads installed, and sends a second PATCH. nock reports ERR_NOCK_NO_MATCH, since the one write interceptor was spent; MSW answers the second write from its persistent handler and the assertion changed === false fails. That is a bug the application would have in production, found by the test that ran the vendor’s code.

The fake passed every application test, because no vendor code ran. Its arity test failed instead, on listMeters, which is the drift alarm doing its job: the fake needs a look, and the suite said so rather than staying green.

The test mode passed unchanged. The vendor changed the client and the mode together, which is the property you are paying for when you use one. It also never saw the cache, because the cache lives in the transport it skips.

When it goes wrong

The interceptor sees fewer requests than the code made. One client, one meter, loaded twice.

METERCO_SDK=1.3.0 node pitfall.mjs
meterco-sdk 1.3.0
one client, two loads
  requests the interceptor saw: 1
a client per load
  requests the interceptor saw: 2
one client, cache turned off
  requests the interceptor saw: 2

An interceptor sits below the SDK, so anything the SDK answers from memory never reaches it. Build a client per test, which costs nothing when there is no server to start. Where the SDK exposes the cache, turn it off in tests. And assert on what your code did rather than on how many requests it took, which is MSW’s own advice: a request count is an implementation detail of somebody else’s code.3

The mirror failure is more requests than expected. An SDK that retries on a 503 sends the request again, and a nock interceptor that answered once is gone by the second attempt. Declare the failing reply with .times(n), or let the handler persist, and assert on the outcome.

Connection pooling is the one people expect here and it does not apply. Interception happens per request, above the socket, so a pooled connection changes nothing an interceptor sees. It changes what a mock server on a port sees, which counts connections and not calls.

The same choice in Python

Python’s standard library ships both halves of the fake problem in one module. create_autospec builds a fake from the real class, so it answers only the methods the SDK has, with the SDK’s signatures. A plain Mock answers anything.

loose = Mock()
strict = create_autospec(MetercoSDK, instance=True)

print("sdk.get_metre('mtr_1'), a method the SDK does not have")
attempt("Mock()", lambda: loose.get_metre("mtr_1"))
attempt("create_autospec()", lambda: strict.get_metre("mtr_1"))

print("\nsdk.update_meter('mtr_1'), one argument short")
attempt("Mock()", lambda: loose.update_meter("mtr_1"))
attempt("create_autospec()", lambda: strict.update_meter("mtr_1"))
python3 autospec.py
sdk.get_metre('mtr_1'), a method the SDK does not have
Mock()               accepted
create_autospec()    AttributeError: Mock object has no attribute 'get_metre'

sdk.update_meter('mtr_1'), one argument short
Mock()               accepted
create_autospec()    TypeError: missing a required argument: 'data'

That is the arity test from fake.test.mjs, done by the library on every call.4 For interception, responses patches the requests adapter and respx patches httpx, so the choice depends on which transport the vendor’s SDK was generated on. Neither is on this page’s runner, so the section stops at the fake.

When not to do this

Do not fake an SDK you can intercept. The fake here passed every application test against a release that would have double-written in production, and only an extra test of its own caught that anything had moved. Reach for a fake when the HTTP layer cannot run in the test at all, and pin it when you do.

Do not use the sdkgen test feature, or any vendor’s test mode, to test authentication, retries or timeouts. Those live in the transport the mode skips, and a suite built on it alone passes while the client cannot reach the service. Keep a few interception tests beside it, and one against a sandbox before a release.

Do not assert on request counts as a proxy for correctness. The count is the vendor’s business, it changed in a minor release here, and the test that counted became the one that failed for the wrong reason.

Do not point the application at the vendor’s sandbox from the unit suite. That is a contract test with a schedule of its own, and the page on testing an API you do not own covers it.

Last verified

Verified 2026-09-24 against Node 22.22.2, nock 14.0.17, msw 2.15.0 and Python 3.11.15. Every output block is what the command preceding it printed. The vendor SDK is a stand-in written for this page at two versions, so the bump reproduces offline. The sdkgen test feature was not run here, and its behavior is recorded on the in-memory mock page against @voxgig/sdkgen 4.17.1.

Footnotes

  1. The two interception tools share their interception. nock’s package.json at 14.0.17 lists three dependencies, and one of them is @mswjs/interceptors, the package MSW is built on. Above that layer they disagree about everything: nock speaks of interceptors that are spent, MSW of handlers that persist. Below it they are one mechanism, and a page comparing them is comparing two vocabularies for it. ↩︎ Back to text

  2. Semantic Versioning reserves the minor version for functionality added in a backward compatible manner. A cache is functionality, and it is backward compatible in the sense the specification means: every call that compiled still compiles and returns the same type. What it returns may be thirty seconds old. The specification is about interfaces, and a test suite is a consumer of behavior. ↩︎ Back to text

  3. Hyrum’s law holds that with a sufficient number of users of an API, every observable behavior of a system will be depended on by somebody, whatever the contract promises. A test that counts requests depends on a behavior the SDK never promised, namely that a read is a request. The 1.3.0 release kept every promise and broke the test anyway, which is the law working as stated. ↩︎ Back to text

  4. The standard library was once vulnerable to the typo it now guards against. The unittest.mock documentation records that before Python 3.5, a test with a typo in the word assert, such as assret_called_with, would silently pass. A Mock answers any attribute, including a misspelled assertion about itself. The fix was to make the mock refuse names that start with assert, assret, asert, aseert or assrt, and the documentation keeps the first misspelling as its example, marked as an intentional typo. ↩︎ 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.