How-to › Test and mock integrations

How to seed a sandbox account for SDK integration tests#

Create every record an integration suite needs under a run id, delete them newest first in a hook that runs on failure too, and sweep what earlier runs left.

Audience
API consumer
Level
intermediate
Topic
Run SDK test suites
Languages
TypeScript and JavaScript
Verified

Read first: Layer unit, integration, and live tests for an SDK

Your integration suite creates a customer called billing-test@example.com in the vendor’s sandbox, fails on an assertion two steps later, and never reaches its delete. The next run cannot create that customer, because the email is taken, so a suite that was red for a real reason is red the next morning for a reason nobody wrote. Two CI jobs on the same sandbox do the same to each other.

What you get

You will end up with fixtures that stamp every record with a run id and delete newest first in a hook that runs on failure too. A sweep removes what a crashed run left. This is for you if your SDK’s integration tests share a sandbox account.

Short answer

Prefix every record the suite creates with a run id, it- plus eight hex characters, so parallel jobs never collide and a leftover names the run that left it. Register the teardown with t.after before the first assertion, delete newest first so invoices go before their customer, and report what could not be deleted. Sweep records with the prefix from earlier runs before the suite starts, because a crashed run cannot clean up.

You will need

Node 22 or later, credentials for a sandbox account, and an SDK or client with create and delete operations for the records the suite needs. Verified 2026-10-21 against Node 22.22.0. The sandbox here is sandbox.mjs, a stand-in on 127.0.0.1 that keeps the two rules that make leftovers bite: a customer’s email is unique, and a customer with invoices cannot be deleted.

The vendor sandboxes it stands for differ in what they promise. Stripe tells you to use separate sandboxes for local development and CI, so that automated tests do not affect your settings or data.1 Twilio’s test credentials answer magic inputs without touching live resources or charging the account.2

Approaches compared

ApproachWhen it fitsWhat it costs youWhen to pick something else
Docker Compose service per runThe API you integrate with is yours to run, and it has a Compose fileA Docker daemon on the runner, images to build or pull per run, and a copy of the API that never sees the vendor’s own data or limitsThe API belongs to a vendor and exists only as their sandbox
Shared vendor sandboxThe vendor is the only place the real behavior lives, including its limits and its quirksRecords that accumulate, cross-talk between jobs, rate limits shared with every user of the key, and a cleanup discipline you must keepYou can run the API yourself and a fresh instance matters more than fidelity
TestcontainersThe API ships as an image and each test file wants a fresh instance it starts and stops itselfDocker available to the test process, image pulls that dominate a cold run, and startup time paid once per containerThe API is a vendor’s, or you want one long-lived service for a whole CI job

A fresh instance per run, from Compose or Testcontainers,3 starts clean every time, but it exists only for an API you can run yourself. The shared sandbox is the vendor’s real behavior, limits and all, and every record you create in it stays until something deletes it. The fixtures below are what keeps a shared sandbox usable run after run, and they cost you a naming rule, a hook in every test, and a sweep.

Scope every record to the run

The run id is it- and eight hex characters from randomBytes, minted once per process. It goes into the name and the email of every customer the run creates. Two jobs on one sandbox therefore never ask for the same email, and a leftover record says which run left it.

export const runId = () => `it-${randomBytes(4).toString('hex')}`

// One count for the whole process, not one per Fixtures: two tests in one run would
// otherwise both mint <run>-1, and a cleanup that failed in the first fails the second.
let minted = 0

export class Fixtures {
  constructor(api, run) {
    this.api = api
    this.run = run
    this.created = []
  }

  async customer(fields = {}) {
    const n = ++minted
    const customer = await this.api.create('customers', {
      name: `${this.run} customer ${n}`,
      email: `${this.run}-${n}@example.com`,
      ...fields,
    })
    this.created.push({ kind: 'customers', id: customer.id })
    return customer
  }

The prefix is also what the sweep keys on. Anything carrying it- and another run’s id is a leftover by definition, and anything without the prefix belongs to somebody else.

Register the teardown before the first assertion

t.after in Node’s test runner registers a hook that runs when the test ends, whether it passed, failed, or threw halfway. Create the fixtures object, register its cleanup on the next line, and only then create records, so an assertion that fails on line six still gets its cleanup.

test('an invoice is created for a customer this run owns', async (t) => {
  const fixtures = new Fixtures(api, run)
  t.after(() => fixtures.cleanup())
  const customer = await fixtures.customer()
  const invoice = await fixtures.invoice(customer, { amount: 1250 })
  assert.equal(invoice.customer_id, customer.id)
  assert.equal(invoice.amount, 1250)
})

cleanup deletes newest first, so an invoice goes before the customer it belongs to. It treats a 404 as already done, and it reports anything else after trying every record, rather than stopping at the first refusal.

  async cleanup() {
    const failures = []
    for (const record of [...this.created].reverse()) {
      try {
        await this.api.remove(record.kind, record.id)
      } catch (err) {
        if (err.status !== 404) failures.push(`${record.kind}/${record.id}: ${err.code}`)
      }
    }
    this.created = []
    if (failures.length) throw new Error(`cleanup left records behind: ${failures.join(', ')}`)
  }
node --test integration.test.mjs
1..5
# tests 5
# suites 0
# pass 5
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 242.816218

Five tests. Two of them prove the sandbox rules the fixtures are built around: a duplicate email is refused with email_taken, and a customer with invoices is refused with customer_has_invoices. The fourth proves that cleanup names a record it could not remove instead of returning as if it had. The fifth holds two tests in one run to different emails, so one refused cleanup cannot fail the next test at setup.

Sweep what earlier runs left

A teardown cannot run in a process that was killed, lost its network, or hit the job’s timeout. sweep runs in before, lists every customer with the it- prefix that does not carry this run’s id, deletes their invoices and then the customers, and reports the count.

export async function sweep(api, { keep }) {
  const stale = (await api.list('customers', { email_prefix: 'it-' })).data
    .filter((customer) => !customer.email.startsWith(`${keep}-`))
  let invoices = 0
  for (const customer of stale) {
    for (const invoice of (await api.list('invoices', { customer_id: customer.id })).data) {
      await api.remove('invoices', invoice.id)
      invoices++
    }
    await api.remove('customers', customer.id)
  }
  return { customers: stale.length, invoices }
}
node sweep.mjs
customers with the it- prefix before the sweep: 2
swept: 2 customers and 3 invoices left by earlier runs
customers with the it- prefix after the sweep: 0

The script seeds the sandbox the way a crashed run would: two customers under another run’s id with three invoices between them, left without cleanup. Then it sweeps. A record without the prefix is left alone, so a colleague’s hand-made customers in the same sandbox survive. A vendor’s list API returns pages, unlike the stand-in, so a port of api.list has to follow the cursor to the last page or each run sweeps only the first.

Check it worked

twice.mjs starts the sandbox as a separate process, runs the suite twice against it with SANDBOX_URL set, and counts the customers left after each run.

node twice.mjs
run 1: pass 5  fail 0  exit code 0  customers left in the sandbox: 0
run 2: pass 5  fail 0  exit code 0  customers left in the sandbox: 0

pass 5 twice and customers left in the sandbox: 0 twice is the result to look for. The second run is the one that matters, because it is the run a shared sandbox sees every day. The script exits 1 when a run fails or leaves a customer.

sample.test.mjs tests the code under the suite. The client keeps an id to one path segment, so .. cannot reach the API root with the key. With cleanup broken in a copy, the suite fails in seconds rather than hanging, and twice.mjs exits 1, even when another test runs it.

node --test sample.test.mjs
1..10
# tests 10
# suites 0
# pass 10
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 2124.225256

When it goes wrong

The suite is red for a reason nobody wrote. pitfall.mjs runs two versions of the same wrong test, twice each, against a sandbox that keeps its records between runs.

node pitfall.mjs
teardown only on success, fixed email
  run 1: assertion failed (expected 1300, got 1250); customers left behind: 1
  run 2: setup failed: 409 email_taken; customers left behind: 1
teardown in finally, email scoped to the run
  run 1: assertion failed (expected 1300, got 1250); customers left behind: 0
  run 2: assertion failed (expected 1300, got 1250); customers left behind: 0

The first version uses a fixed email and cleans up after its assertions, so run 1 leaves the customer behind. Run 2 then fails at setup with 409 email_taken, a failure about the sandbox’s state rather than about the code. The second version fails the same assertion both times, which is the failure the test was written to find, and leaves the sandbox as it found it.

Two jobs share one key and one sandbox. The run id stops them colliding on a name. The sweep as written removes every other run’s records at startup, which is right for one pipeline running serially and wrong for a second job still in flight. For parallel jobs, move the sweep to a scheduled job that runs when nothing else does, or record a creation time and sweep only records older than your longest job.

The cleanup itself is refused. A sandbox that rate limits deletes, or a record that grew a dependent the suite did not create, leaves cleanup with failures to report. It throws with the list, so the test is red for that reason, and the next run’s sweep removes what it could not.

When not to do this

Do not seed through the database. A vendor sandbox has no database you can reach. For an API you run yourself, a row inserted behind the API’s back skips the validation the API would have applied, so the suite tests records the API would never have created.

Do not share one sandbox across teams or pipelines when the vendor lets you create more. Stripe’s documentation asks for separate sandboxes for local development and CI, and lets a team test in separate sandboxes so that data and actions stay isolated. Isolation by account beats isolation by prefix.

Do not rely on a nightly wipe instead of per-test cleanup. A record left for a day is a record another test can trip over for a day, and a nightly job that fails leaves two days of leftovers.

Do not start a fresh instance per test with Testcontainers for an API that takes a minute to boot. Pay the startup once per job with a Compose service,4 or once per file, and keep the per-test cleanup for the records inside it.

Last verified

Verified 2026-10-21 against Node 22.22.0. Every output block is what the command preceding it printed. The sandbox is the committed stand-in: no vendor sandbox was called, and the Stripe and Twilio statements are read from their documentation. Testcontainers and Docker Compose were not run, because this environment has no Docker daemon, and their rows come from their documentation.

Footnotes

  1. Stripe’s sandboxes page tells new integrations to use general sandboxes instead of the test mode sandbox. The test mode sandbox is kept for existing integrations that depend on it, or for a feature general sandboxes do not support. Test mode is therefore filed under the plural, with its own list of exceptions. ↩︎ Back to text

  2. Twilio’s page on test credentials opens by saying that you cannot create test credentials in the new Twilio console. Credentials created in the legacy console before the migration continue to work. The magic numbers remain documented: +15005550006 is valid and available, +15005550000 is unavailable, and +15005550001 is invalid. A sandbox that only existing holders can keep has a closing date, which the page does not state. ↩︎ Back to text

  3. Testcontainers starts a second container beside yours. Its Node configuration page lists RYUK_CONTAINER_IMAGE, testcontainers/ryuk:0.14.0 by default, and a TESTCONTAINERS_RYUK_DISABLED switch, and the containers page notes that a pull policy on your own image does not stop Testcontainers pulling Ryuk. The reaper’s job is the sweep’s, done for containers rather than records. ↩︎ Back to text

  4. docker compose up --wait waits for services to be running or healthy and, the reference notes, implies detached mode. The flag that makes a CI step wait is therefore also the flag that stops it watching the logs. down --volumes on the matching page removes the named volumes declared in the file, which down alone leaves in place, which is how a database volume survives down and the next run inherits its rows. ↩︎ 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.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.