# 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.

Source: https://voxgig.com/howto/mock-a-generated-sdk-in-application-tests

- Audience: api-consumer
- Level: intermediate
- Languages: typescript, javascript, python
- Verified: 2026-09-24
- Published: 2026-09-24

## 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](/howto/run-an-in-memory-mock-inside-sdk-tests).

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

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [A hand-written fake client](https://martinfowler.com/articles/mocksArentStubs.html) | The SDK is large and you call three methods of it, and the tests must run with no HTTP layer at all | Every method you fake is a copy of the vendor's behavior that stops being true on the next release | The SDK's parsing, headers or retries are part of what you are testing |
| [MSW](https://mswjs.io/docs/) | The same handlers should serve Node tests, browser tests and local development | Handlers persist, so counting writes takes a counter, and a larger tool than a Node suite needs | Only Node tests need stubbing, and per-test interceptors are enough |
| [nock](https://github.com/nock/nock) | Node suites where an interceptor per request is the assertion you want | An interceptor is spent once, so a retry or a second read needs a `persist` or a count | The suite runs in a browser as well |
| [The sdkgen test feature](https://voxgig.com/sdk/features) | The SDK was generated with the feature and the test is about argument handling, not the wire | Nothing is serialized and nothing is sent, so a wrong header or a broken retry passes | The 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.

```js title="billing.mjs"
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.

```js title="cases.mjs"
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.

```js title="nock.test.mjs"
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'`](https://mswjs.io/docs/api/setup-server/listen) does the job
`nock.disableNetConnect` does: a request no handler matches fails the test instead of leaving the
machine.

```js title="msw.test.mjs"
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.

```js title="fake.test.mjs"
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.

```js title="testmode.test.mjs"
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]

```bash
node bump.mjs
```

```text output
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.

```bash
METERCO_SDK=1.3.0 node pitfall.mjs
```

```text output
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](https://mswjs.io/docs/best-practices/avoid-request-assertions):
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`](https://docs.python.org/3/library/unittest.mock.html#autospeccing) 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.

```python title="autospec.py"
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"))
```

```bash
python3 autospec.py
```

```text output
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`](https://pypi.org/project/responses/) patches the `requests` adapter
and [`respx`](https://pypi.org/project/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.

## Related how-tos

- [Stub outbound HTTP calls in Node tests with nock](/howto/stub-outbound-node-http-calls-with-nock)

- [Run an in-memory mock of your API inside SDK tests](/howto/run-an-in-memory-mock-inside-sdk-tests)

- [Contract test an API you do not own](/howto/contract-test-an-api-you-do-not-own)

## 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.

[^1]: The two interception tools share their interception. nock's
[package.json](https://cdn.jsdelivr.net/npm/nock@14.0.17/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.

[^2]: [Semantic Versioning](https://semver.org/) 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.

[^3]: [Hyrum's law](https://www.hyrumslaw.com/) 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.

[^4]: The standard library was once vulnerable to the typo it now guards against. The
[`unittest.mock` documentation](https://docs.python.org/3/library/unittest.mock.html) 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.