Your SDK’s suite is one command and two hundred tests, and a red build names a test without
saying whether your paging code, the vendor’s sandbox, or an expired key broke it. Half of the
tests need a sandbox credential, so a contributor without one cannot run npm test, and a vendor
outage blocks every merge for as long as it lasts.
What you get
You will end up with three commands, test:unit, test:integration, and test:live, each
proving one thing and each with its own CI trigger, so a failure names its layer. This is for you
if you ship a client library and one red suite tells you nothing.
Short answer
Give the client an injected fetch and test request building, error mapping, paging, and retry
against a scripted one, with no socket. Run the same client over HTTP against a sandbox, or a
committed stand-in, in a second command. Put the real API behind a third command that skips
without ORDERS_LIVE_URL and ORDERS_LIVE_KEY, and trigger it on a schedule rather than on
every pull request, so one red layer names one cause.
You will need
Node 22 or later, an SDK with at least one working operation, and a CI pipeline that runs one
test command. Verified 2026-10-21 against Node 22.22.0. The sample SDK is src/client.mjs, an
eighty-line orders client with paging, error mapping, and one retry. The sandbox is
sandbox/server.mjs, a stand-in on 127.0.0.1 that speaks the vendor’s wire format from memory.
No vendor API is called: the live layer is shown skipping, which is what it does without
credentials.
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| Hand-layered test pyramid | A hand-written client, or any client whose retry, paging, and error mapping are yours | Every layer is code you write and keep, and the injected transport is a seam you must design in | The client is generated and the generator ships a suite that covers the wire format |
| sdkgen generated suites and live flow | A client generated by sdkgen, with its in-memory test transport and entity flows | A generated suite tests what the generator emits, so behavior you added around it still needs tests you write | You did not generate the client, or your contributors want a framework they know |
| Speakeasy generated contract tests | A Speakeasy SDK in one of its six languages, with examples in the document | An Enterprise tier and an add-on, a Docker runtime for the mock locally, and success paths only, since error assertions are not generated | You need error paths covered, or the SDK is not Speakeasy’s |
| Stainless generated tests against a mock server | A Stainless SDK whose endpoint tests can run against the mock built from the document | A paid mock server feature, tests that stay off until you remove disable_mock_tests, and a mock that may not handle every edge case | Your own retry or paging code, which the generated tests never reach |
Voxgig maintains sdkgen. It is one of four options here, not the recommendation.
The three generated suites cover the wire format for the code the generator wrote, at a price each. Speakeasy’s needs an Enterprise plan and stops at success paths, Stainless’s needs its paid mock server and ships switched off,1 and sdkgen’s live tests need the project’s credentials in the environment. None of them tests a retry loop, a paging helper, or an error mapping you wrote yourself, which is what the hand-layered pyramid2 below is for.
Inject the transport and keep the unit layer off the network
The seam is the fetch parameter. The client takes it as an option, defaults to the global one,
and never imports it. A test can therefore hand it a function that answers from memory and
records what it was asked.
export function createClient({ baseUrl, apiKey, fetch = globalThis.fetch, sleep = wait }) {
if (!baseUrl) throw new Error('baseUrl is required')
// The trailing slash keeps a base path: orders/1 against https://host/v1/ is /v1/orders/1.
const base = new URL(String(baseUrl).replace(/\/*$/, '/'))
async function call(method, path, body, attempt = 1) {
const url = new URL(path, base)
const res = await fetch(url, {
method,
headers: { authorization: `Bearer ${apiKey}`, 'content-type': 'application/json', accept: 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
})
// One retry on 429, after the pause the API asked for. The pause is injected too.
if (res.status === 429 && attempt === 1) {
await res.body?.cancel()
await sleep(retryAfterMs(res.headers.get('retry-after')))
return call(method, path, body, attempt + 1)
}
The pause is injected the same way, which is what lets the retry test assert a three-second wait without waiting three seconds.
test('retries once on 429 after the Retry-After pause, with the pause injected', async () => {
const { fetch, calls } = scriptedFetch([
{ status: 429, headers: { 'retry-after': '3' }, body: { code: 'rate_limited', message: 'slow down' } },
{ status: 200, body: { id: 'ord_1', sku: 'SKU-1', quantity: 1, status: 'placed' } },
])
const pauses = []
const client = createClient({ baseUrl: BASE, apiKey: 'sk_test_9', fetch, sleep: async (ms) => { pauses.push(ms) } })
const order = await client.getOrder('ord_1')
assert.equal(order.id, 'ord_1')
assert.equal(calls.length, 2)
assert.deepEqual(pauses, [3000])
})
node --test "test/unit/*.test.mjs"
1..11
# tests 11
# suites 0
# pass 11
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 142.318178
Eleven tests, no port, and well under a second. This layer proves the code that is yours: the
path and header a request carries, the error an unhappy status becomes, how paging follows
next_cursor, and what a 429 costs. Two tests keep an id of .. from resolving to the API root
with the key attached. The layer cannot prove that the vendor’s JSON looks the way the script says
it does, which is the next layer’s job.
Point the integration layer at a sandbox, or the stand-in
The integration layer uses the same client with the real fetch, over a socket, against the
vendor’s sandbox when ORDERS_SANDBOX_URL and ORDERS_SANDBOX_KEY are set. Without them it
starts the committed stand-in, which answers with the vendor’s wire format from memory, so the
layer runs on every pull request with no credential. Node’s test runner has
before and after hooks for the lifecycle.
let sandbox
let client
before(async () => {
sandbox = process.env.ORDERS_SANDBOX_URL
? { url: process.env.ORDERS_SANDBOX_URL, apiKey: process.env.ORDERS_SANDBOX_KEY, stop: async () => {} }
: await startSandbox()
client = createClient({ baseUrl: sandbox.url, apiKey: sandbox.apiKey })
})
after(() => sandbox.stop())
node --test "test/integration/*.test.mjs"
1..3
# tests 3
# suites 0
# pass 3
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 220.3894
Three tests, each of which catches a wire-format change the unit layer cannot see. They check
the id a create answers with, the shape of a page, and the body and x-request-id header of a
404. Against a real sandbox the same tests need records to read and a way to remove the ones they
create, which is a discipline of its own.
Guard the live layer and give it its own trigger
The live layer is the only one that talks to the vendor. It is also the one that costs: a request against quota, a credential in the environment, and a record left in the account. Three decisions keep it cheap. It skips, with a reason, when the two variables are absent. It runs on a schedule and on demand, never on a pull request. Its credentials sit in a GitHub environment, where a required reviewer can gate them.
const url = process.env.ORDERS_LIVE_URL
const key = process.env.ORDERS_LIVE_KEY
const skip = url && key ? false : 'ORDERS_LIVE_URL and ORDERS_LIVE_KEY are not set'
if (skip && process.env.ORDERS_LIVE_REQUIRED === 'true') {
throw new Error(`${skip}, and ORDERS_LIVE_REQUIRED says this run must reach the API`)
}
const client = skip ? null : createClient({ baseUrl: url, apiKey: key })
test('an authenticated read against the live API', { skip }, async () => {
const order = await client.getOrder(process.env.ORDERS_LIVE_ORDER_ID)
assert.equal(order.status, 'placed')
})
node --test "test/live/*.test.mjs"
TAP version 13
# Subtest: an authenticated read against the live API
ok 1 - an authenticated read against the live API # SKIP ORDERS_LIVE_URL and ORDERS_LIVE_KEY are not set
---
duration_ms: 0.757202
type: 'test'
...
# Subtest: a write against the live API, with the record it leaves behind named
ok 2 - a write against the live API, with the record it leaves behind named # SKIP ORDERS_LIVE_URL and ORDERS_LIVE_KEY are not set
---
duration_ms: 0.37624
type: 'test'
...
1..2
# tests 2
# suites 0
# pass 0
# fail 0
# cancelled 0
# skipped 2
# todo 0
# duration_ms 94.870379
# SKIP with the reason is what a contributor without credentials sees, and the exit code is 0.
In CI the live job runs on schedule and workflow_dispatch, two
events
a pull request never fires, and takes its key from an environment secret. It also sets
ORDERS_LIVE_REQUIRED, so a missing secret fails the job instead of skipping it.
live:
# Never on a pull request: a fork gets no secrets, and every push would spend quota.
if: github.event_name != 'pull_request'
runs-on: ubuntu-latest
environment: sandbox
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm ci
- run: npm run test:live
env:
ORDERS_LIVE_REQUIRED: 'true'
ORDERS_LIVE_URL: ${{ vars.ORDERS_LIVE_URL }}
ORDERS_LIVE_KEY: ${{ secrets.ORDERS_LIVE_KEY }}
ORDERS_LIVE_ORDER_ID: ${{ vars.ORDERS_LIVE_ORDER_ID }}
sdkgen’s generated suite draws the same line with an environment variable. Its live tests run
when <PROJECT>_TEST_LIVE is TRUE and read the credential from <PROJECT>_APIKEY, pacing
their calls 500 ms apart by default. main.kit.test.live.strict decides whether a failing live
request fails the test or skips it with the reason.3
Check it worked
layers.mjs runs the three commands the way CI would and prints one line per layer, with the
counts read from the TAP summary and the reason for any skip.
node layers.mjs
unit pass 11 fail 0 skip 0 proves request building, error mapping, paging, retry
integration pass 3 fail 0 skip 0 proves the wire format, against a sandbox
live pass 0 fail 0 skip 2 skipped: ORDERS_LIVE_URL and ORDERS_LIVE_KEY are not set
red: none
pass 11, pass 3, and skip 2 with the reason spelled out is the green state for a machine
without credentials. red: none is the literal to look for. layers.test.mjs checks that the
script exits 1 on a red layer and on one that ran no tests, since node --test exits 0 on a glob
that matches nothing. It also checks ORDERS_LIVE_REQUIRED, and that a layer run from inside
another test still reports its failure.
node --test layers.test.mjs
1..5
# tests 5
# suites 0
# pass 5
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 2357.325062
When it goes wrong
One layer goes red, and the layer names the cause. layers.mjs --drift starts the stand-in with
its list responses renamed from next_cursor to nextCursor, which is what a vendor’s
wire-format change looks like from the client.
node layers.mjs --drift
unit pass 11 fail 0 skip 0 proves request building, error mapping, paging, retry
integration pass 2 fail 1 skip 0 failed: the list is paged and the client sees every order once
live pass 0 fail 0 skip 2 skipped: ORDERS_LIVE_URL and ORDERS_LIVE_KEY are not set
red: integration
The unit layer stays green because its scripted fetch still says next_cursor, the integration
layer fails on the one test that pages, and the live layer is untouched. The vendor moved. Fix
the client against the renamed field, then change the unit script to match.
The live suite fails for everyone. pitfall.mjs runs a live test with no guard, the way such a
test is usually first written, beside the guarded one.
node pitfall.mjs
unguarded live suite, no credentials: pass 0 fail 1 exit code 1
error: baseUrl is required
guarded live suite, no credentials: pass 0 fail 0 skip 2 exit code 0
skipped because: ORDERS_LIVE_URL and ORDERS_LIVE_KEY are not set
Without the guard the file fails at import, so npm test is red on every laptop that lacks the
key, and every contributor ends up holding a credential to make it pass. GitHub adds the second
half of the problem. With the exception of GITHUB_TOKEN,
secrets are not passed
to the runner when a workflow is triggered from a forked repository.4 A live job on
pull_request can therefore never pass for an outside contributor.
The live suite burns the sandbox. A suite on pull_request runs on every push to every open
branch, and each run spends the vendor’s quota and leaves records in the account. Move it to the
schedule, and give the write test a name it can leave behind, as SKU-LIVE-CHECK does here.
When not to do this
Do not build the pyramid under a generated client whose generator ships the bottom layer. A
Stainless or Speakeasy SDK arrives with endpoint tests against a mock built from the document.
sdkgen’s test feature answers every operation from a seeded map with no server at all, which
the in-memory mock page shows running.
Write unit tests for the behavior you added around the generated code.
Do not point the integration layer at production with a test key and call it live. The two layers exist because they fail for different reasons: a sandbox is yours to seed and reset, and the live layer is the one that notices the vendor deploying.
Do not let the live layer skip silently. A skip with a reason is a decision a reader sees in the
log. A scheduled job whose output carries # SKIP has reached nothing, so treat that line as red
there and check the variable names.
Do not run the live layer on every pull request, even with the secret available. The quota and the leftover records are per run, and a flaky vendor blocks merges that have nothing to do with the vendor.
Related how-tos
Last verified
Verified 2026-10-21 against Node 22.22.0. Every output block is what the command preceding it printed. The live layer ran against no vendor: it is shown skipping, which is its behavior without credentials, and the sandbox in the integration layer is the committed stand-in. The sdkgen figures are read from the model documentation and test templates in the @voxgig/sdkgen 4.35.0 package, and the Speakeasy and Stainless rows from their documentation. None of the three generated suites was run.
Footnotes
-
Stainless published a fork of Prism,
@stainless-api/prism-cli, which the registry lists at 5.16.1, last modified in December 2025. Its configuration reference names one supported mock server, steady, and thescripts/mockin a generated SDK such as the Anthropic TypeScript SDK runs@stdy/cli0.22.2 on port 4010. ↩︎ Back to text -
Martin Fowler’s entry of May 1, 2012 credits the pyramid to Mike Cohn, who described it in his 2009 book Succeeding with Agile after drawing it in 2003-4. Ham Vocke’s longer treatment on the same site advises readers not to become attached to the names of the layers, since Cohn’s middle one was called service tests. Google’s testing blog suggested a 70/20/10 split as a first guess in 2015, with the caveat that the exact mix differs for each team. ↩︎ Back to text
-
The model documentation in the @voxgig/sdkgen package gives
main.kit.test.live.stricta default oftrue. Under it a live request that fails, or a live test missing an input it needs, fails the test;falseskips it with the reason. Under either value, an account holding no record for a test to read skips that test, and each generated live test states the setting it was generated with in a comment. The features page describes thetestfeature the offline half of the same suite runs on. ↩︎ Back to text -
The sentence is GitHub’s own, from its page on using secrets. With the exception of
GITHUB_TOKEN, secrets are not passed to the runner when a workflow is triggered from a forked repository. A live job that needs a key and runs onpull_requestis therefore a job outside contributors can only watch fail. The sample’s live job runs on the schedule instead. ↩︎ Back to text