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
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| Docker Compose service per run | 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 | 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 | 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.
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.
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 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
-
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
-
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:
+15005550006is valid and available,+15005550000is unavailable, and+15005550001is invalid. A sandbox that only existing holders can keep has a closing date, which the page does not state. ↩︎ Back to text -
Testcontainers starts a second container beside yours. Its Node configuration page lists
RYUK_CONTAINER_IMAGE,testcontainers/ryuk:0.14.0by default, and aTESTCONTAINERS_RYUK_DISABLEDswitch, 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 -
docker compose up --waitwaits 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 --volumeson the matching page removes the named volumes declared in the file, whichdownalone leaves in place, which is how a database volume survivesdownand the next run inherits its rows. ↩︎ Back to text