Your API suite runs on every commit against a shared database. How do you keep the tests from corrupting each other's data?
Make every test create its own preconditions under a generated identity, scope each assertion to records that test owns, and treat cleanup as housekeeping rather than as the isolation mechanism, because teardown does not run when a test is killed.
What the interviewer is scoring
- Does the candidate make each test create its own preconditions rather than lean on a shared seeded fixture set
- Whether assertions are scoped to records the test owns instead of asserting on counts in a shared table
- That isolation is designed to survive a test being killed before its cleanup runs
- Whether the answer separates reference data everyone reads from transactional data every test writes
- Does the candidate name what parallel execution breaks that serial execution was hiding
Answer
The database is the dependency, not the API
An API suite looks stateless from the outside. Each test sends requests and asserts on responses, and nothing in the test code mentions storage. But every write endpoint mutates rows the next test will read, so the suite has a shared mutable global whether or not anybody designed one. The symptoms are recognisable: a test that passes alone and fails in the suite, a suite that passes on a fresh database and fails on the second run, a failure that only appears when somebody else's pipeline is running at the same time.
The reframing that makes the problem tractable is that test data is not a setup chore, it is the isolation boundary. Every decision below is about where that boundary sits, because the two properties you cannot have at once are tests that share state and tests that are independent.
Reference data and transactional data want opposite treatment
Split the data by who writes it. Reference data — currencies, country codes, product categories, permission definitions, tax bands — is read by nearly every test and written by almost none. It belongs in migrations or in a seed applied once when the environment is built, it should be identical everywhere including production, and no test should modify it. If a test needs a category that does not exist, adding it to the shared seed is usually right; mutating an existing one never is.
Transactional data — accounts, orders, subscriptions, uploaded files — is what tests create and change. This is the part that must not be shared. The moment two tests operate on the same order they are coupled through the database, and no amount of naming discipline keeps them apart once the suite runs in parallel.
The drift worth naming explicitly is the fixture set that starts as reference data and quietly becomes transactional. A seed file gains test_user_3 because one test needed a user with a lapsed subscription. A second test reuses it because it is convenient. A third renews the subscription to cover renewal, and now the second test fails for a reason that is invisible anywhere in its own source file. Every subsequent debugging session starts with reading a fixture file to work out who else depends on it, which is exactly the cost you were trying to avoid by sharing.
Each test creates what it needs, under a name only it uses
The design that survives is per-test creation through the service's own endpoints, with every identifying field generated rather than chosen. Using the API rather than inserting rows means the setup exercises the same validation and side effects a real caller would trigger, so your preconditions cannot be states the product could never reach.
// The run id makes the identity unique per run AND per parallel worker,
// so the same test executing on two workers never collides.
const email = `qa-${runId}-${crypto.randomUUID()}@example.test`;
const account = await api.post("/accounts", { email, plan: "standard" });
Two caveats keep this honest. Building preconditions through the API means a broken create endpoint fails every test at once, which is noisy but not wrong: it is the truth, and the mitigation is that the create endpoint has its own narrow test that fails first and names the fault clearly. And where a precondition genuinely cannot be reached through the API — an expired trial, a record in a state only a nightly job produces — you write it directly, accepting that this is the one place your tests know the schema and will need maintenance when it moves. Keep those direct writes in a small, named, deliberately awkward helper so nobody reaches for one out of laziness.
Assertions a neighbour cannot break
Setup is only half of it. An assertion scoped to a whole table couples a test to every other test even when its own data is perfectly isolated. GET /orders returning three items is not a property of your test, it is a property of the database at that instant. Assert that the collection contains the order you created, filter by the identity you generated, and never assert on a total count or on the first element of an unfiltered list.
The same rule extends to anything global the service maintains. A test that asserts a counter incremented by exactly one, or that an outbound queue holds exactly one message, is asserting about the entire system. Scope it to a correlation identifier you control, or measure the value immediately before you act and assert the delta.
Cleanup is best-effort, and building isolation on it is the mistake
The tempting model is create-then-delete, with a teardown hook removing everything a test made. It fails for reasons unrelated to how carefully the teardown is written. Teardown does not run when the process is killed, when the runner times out, when the machine is reclaimed mid-run, when the test throws in a place the framework does not treat as recoverable, or when a foreign-key constraint refuses the delete. So the suite works for months, one cancelled pipeline leaves a row behind, and the failure surfaces days later in a test that never touched it.
Design instead so that leftover data is inert. If every test generates its own identity and filters every assertion by it, an abandoned row costs disk and nothing else. Then keep cleanup, but for the sake of the database's size rather than the suite's correctness: a scheduled sweep deleting records older than a day whose identifiers carry the test prefix. A bulk sweep is also far cheaper than thousands of individual deletes on the critical path of every run.
Where real isolation is worth paying for, the stronger options each buy something different. A transaction per test rolled back at the end is genuinely clean, but only works when the test and the service share a connection, which rules out testing through HTTP. A schema or a database per worker costs provisioning time and gives real independence. A container started per suite from a known image is the usual answer when the pipeline can afford the startup, and it has the side benefit of pinning the engine version.
What parallelism exposes
Turning on parallel workers does not create these problems, it reveals the ones already there. Tests that relied on running after another test now run before it. Tests that shared a fixture now race. Tests that asserted on counts now see other workers' rows. This is why the honest order of work is isolation first, parallelism second: parallelising a suite with shared data produces intermittent failures that look like flakiness, get papered over with automatic retries, and hide the coupling until somebody spends a week on it.
The check that proves isolation holds is worth offering unprompted, because it is objective rather than a claim about discipline. Run one test twice in a row without resetting anything. Run the whole suite twice in a row. Run it again with the worker count doubled. A suite that passes all three has no shared transactional state left.
Likely follow-ups
- Two tests need the same account in two different states at the same time. What changes?
- One row left behind makes the suite fail on Mondays only. How would you track that down?
- The service writes more data asynchronously after your assertion has already passed. How do you test that without a sleep?
- When is resetting the entire database between tests the right answer despite the cost?
Related questions
- How do you run a Selenium suite in parallel across a grid without the tests interfering with each other?hardAlso on parallel-execution and test-isolation5 min
- You own the API test suite for a service from scratch. How do you structure it, and what do you mock?hardAlso on api-testing and test-data7 min
- You are shipping an LLM feature and there is no evaluation set. How do you build one?hardAlso on test-data5 min
- Two teams own two services that talk to each other. How do you stop one breaking the other without running both together in a shared environment?mediumAlso on api-testing4 min
- Which tests should run on every commit and which should run nightly, and how do you decide?mediumAlso on test-data6 min
- A test passes on your machine and fails in CI roughly once a week. How do you track it down?mediumAlso on test-isolation5 min
- No environment in the estate contains the whole stack, so nobody can test an order end to end before release. How do you get assurance anyway?hardAlso on test-data6 min
- Your test suite fails randomly about one run in five. How do you get it under control?hardAlso on test-isolation7 min