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

Source: https://voxgig.com/howto/seed-a-sandbox-account-for-sdk-integration-tests

- Audience: api-consumer
- Level: intermediate
- Languages: typescript, javascript
- Verified: 2026-10-21
- Published: 2026-10-19

## 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](https://docs.stripe.com/sandboxes) for local development and CI, so that
automated tests do not affect your settings or data.[^1] Twilio's
[test credentials](https://www.twilio.com/docs/iam/test-credentials) answer magic inputs without
touching live resources or charging the account.[^2]

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [Docker Compose service per run](https://docs.docker.com/reference/cli/docker/compose/up/) | The API you integrate with is yours to run, and it has a Compose file | A 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 limits | The API belongs to a vendor and exists only as their sandbox |
| [Shared vendor sandbox](https://docs.stripe.com/sandboxes) | The vendor is the only place the real behavior lives, including its limits and its quirks | Records that accumulate, cross-talk between jobs, rate limits shared with every user of the key, and a cleanup discipline you must keep | You can run the API yourself and a fresh instance matters more than fidelity |
| [Testcontainers](https://node.testcontainers.org/) | The API ships as an image and each test file wants a fresh instance it starts and stops itself | Docker available to the test process, image pulls that dominate a cold run, and startup time paid once per container | The 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.

```js title="fixtures.mjs"
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`](https://nodejs.org/docs/latest-v22.x/api/test.html#contextafterfn-options) 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.

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

```js title="fixtures.mjs"
  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(', ')}`)
  }
```

```bash
node --test integration.test.mjs
```

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

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

```bash
node sweep.mjs
```

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

```bash
node twice.mjs
```

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

```bash
node --test sample.test.mjs
```

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

```bash
node pitfall.mjs
```

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

## Related how-tos

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

- [Test stateful endpoints with Dredd hooks](/howto/stateful-api-tests-with-dredd-hooks)

- [Mock a generated SDK in your application tests](/howto/mock-a-generated-sdk-in-application-tests)

- [Layer unit, integration, and live tests for an SDK](/howto/layer-unit-integration-and-live-sdk-tests)

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

[^1]: Stripe's [sandboxes page](https://docs.stripe.com/sandboxes) 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.

[^2]: Twilio's page on [test credentials](https://www.twilio.com/docs/iam/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.

[^3]: Testcontainers starts a second container beside yours. Its Node
[configuration page](https://node.testcontainers.org/configuration/) lists `RYUK_CONTAINER_IMAGE`,
`testcontainers/ryuk:0.14.0` by default, and a `TESTCONTAINERS_RYUK_DISABLED` switch, and the
[containers page](https://node.testcontainers.org/features/containers/) 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.

[^4]: `docker compose up --wait` waits for services to be running or healthy and, the
[reference](https://docs.docker.com/reference/cli/docker/compose/up/) 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](https://docs.docker.com/reference/cli/docker/compose/down/)
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.