Test data is where API test suites quietly go to die. The tests are well-written, the framework is solid, the CI is green — and then a flaky failure shows up because two tests both expected user 42 to exist with email = alice@test.com. The first test deleted Alice. The second test failed. The root cause isn't your test code; it's your test data. This lesson covers the strategies for getting data right, the ones that look easy and aren't, and the patterns that scale to many tests, many engineers, and many environments.
Why this is hard
Three properties of test data fight each other:
- Independence — each test should run regardless of what other tests did.
- Determinism — given the same starting state, the test should pass or fail the same way every time.
- Speed — setup and teardown should be fast enough that engineers run tests freely.
Optimising one usually costs another. Per-test setup is independent and deterministic but slow. Shared seed data is fast but couples tests together. Database snapshots are deterministic and reasonably fast but require infrastructure. Pick a strategy aware of the trade-offs.
The four strategies
Test data strategies side by side
Per-test setup
Each test creates its own data
POST /users at the start, DELETE at the end.
Pro: independent and parallel-safe
Tests can't clobber each other; order doesn't matter.
Pro: data is always fresh
No risk of stale state polluting later tests.
Con: extra API calls per test
Slow when setup is heavy (multi-step chains).
Best for: most modern API test suites
Default unless you have a strong reason otherwise.
Shared seed data
Pre-populated dataset all tests read
User 42, product ABC, fixed accounts.
Pro: fast — no setup per test
Useful for read-heavy GET test suites.
Con: tests can break each other
One mutation poisons others; parallel runs unsafe.
Con: tests assume specific ids
Hard-coded "user 42" couples test code to the dataset.
Best for: read-only smoke tests
Or stable reference data (countries, currencies).
DB snapshots / resets
Restore DB to a known state per run
Before each test, before each suite, or nightly.
Pro: perfectly reproducible
No drift, no accumulated cruft.
Con: requires DB control
Restore privileges, backup files, schema discipline.
Con: slow for large datasets
Multi-GB restores aren't per-test friendly.
Best for: nightly or end-of-suite resets
Pair with per-test setup for the best of both.
Factories / generators
Code that builds unique data on demand
Faker libraries, factory_boy, factory-bot.
Pro: unique data per test
Email = test-{uuid}@example.com — no collisions.
Pro: parallel-safe by default
Two parallel runs generate different values.
Con: needs a small amount of glue
Wire factories to your API client or DB.
Best for: most projects, layered with per-test setup
The pragmatic default.
In practice, most teams settle on per-test setup using factories for unique data, with a nightly DB reset as insurance. That combination gives you independence, parallel safety, and a way to recover when something inevitably leaks.
Unique data per test
The single most useful habit. Instead of alice@test.com, use:
import uuid
def unique_email():
return f"test-{uuid.uuid4()}@example.com"Now tests can run in parallel without colliding. They can run twice in a row without "user already exists" errors. They can run in any environment without depending on data that may or may not be there.
Other unique-able fields:
- Names:
f"User {timestamp}". - Order ids: caller-supplied UUIDs (idempotency-key style).
- File names:
report-{uuid}.csv. - API keys, tokens, tenant ids — anything where the API needs a unique identifier.
Skip uniqueness only for truly invariant fields — country codes, currencies, role names from a fixed enum.
Cleanup is half the job
A test that creates User test-abc-123@example.com and never deletes it pollutes the database, the search results, and any UI test that walks the user list. Multiplied across thousands of test runs, the staging database becomes a graveyard.
Three cleanup patterns:
- In-test teardown. Each test deletes what it created in a
try/finallyor framework-level fixture.def test_create_user(): email = unique_email() try: response = api.post("/users", json={"email": email}) assert response.status_code == 201 # ... rest of test ... finally: api.delete(f"/users?email={email}") - Suite-level teardown. Track all created entities in a shared list, delete them in one pass at suite end. Faster than per-test if you're creating dozens of entities.
- Periodic environment-wide cleanup. A nightly job deletes anything older than 24 hours with a
test-prefix. Safety net, not primary mechanism.
Any team running automated tests against shared environments needs at least the third. Many also need the first.
Building data chains
Some tests need data that depends on other data. To test "user creates an order with a discount code," you need:
- A user.
- A product.
- An active discount code.
- The order itself.
A common pattern is a builder that handles the chain:
class TestScenario:
def with_user(self):
self.user = api.post("/users", json={"email": unique_email()}).json()
self._cleanup.append(("user", self.user["id"]))
return self
def with_product(self):
self.product = api.post("/products", json={"name": f"P-{uuid.uuid4()}"}).json()
self._cleanup.append(("product", self.product["id"]))
return self
def with_discount(self, percent=10):
self.discount = api.post("/discounts", json={"code": f"DC-{uuid.uuid4()}", "percent": percent}).json()
self._cleanup.append(("discount", self.discount["id"]))
return self
def cleanup(self):
for kind, id in reversed(self._cleanup):
api.delete(f"/{kind}s/{id}")Tests then read fluently:
scenario = TestScenario().with_user().with_product().with_discount(20)
# ... use scenario.user, scenario.product, scenario.discount ...
scenario.cleanup()Reverse-order cleanup matters: delete the discount before the product (in case of FK constraints), the user last. Mirroring the creation order gets this right by default.
Sensitive data
Two rules with no exceptions:
- Never use real customer data in tests. Even on staging, even in dev. The risk-to-reward ratio is terrible — a leak ruins a year of trust with users.
- Generate fake-but-realistic data. Libraries like
faker(Python, JS, Java) produce plausible names, addresses, emails, IBANs, dates. Pair a faker with your unique-id helper and you have indistinguishable-from-real test data without the risk.
For PII-heavy domains (healthcare, finance), a separate scrubbed dataset may be needed — coordinated with security or compliance.
Parallel-safe by construction
If you want tests to parallelise, every data decision must work when N copies run simultaneously:
- Unique identifiers everywhere — covered above.
- No "first user" or "last record" assertions — both are race conditions.
- Per-tenant isolation if your API supports tenants — each test creates its own tenant.
- Don't depend on global counters or sequences that other tests can advance.
A good check: run your suite with pytest -n 4 (or your framework's parallel mode). Failures usually point straight at shared-state bugs.
⚠️ Common mistakes
- Hardcoded ids in test code.
api.get("/users/42")works once and breaks the day someone deletes user 42. Always create the data the test needs. - No cleanup, "we'll wipe nightly." The nightly wipe always misses something. In-test cleanup is non-negotiable for any test that writes data.
- Using production data because it's "just for staging." This is the single largest source of accidental data leaks. Never.
🎯 Practice task
Refactor a test to use factories and cleanup. 30-40 minutes.
- Pick one of your team's existing API tests that uses shared/hardcoded data.
- Identify each piece of data it depends on. List which are genuinely shared (enums, references) vs incidentally shared (a specific user that happens to exist).
- Replace the incidentally-shared data with on-the-fly creation using unique identifiers.
- Add a
try/finally(or your framework's teardown) that deletes everything the test created. - Run the test twice in a row. Run it in parallel with itself. Both should pass cleanly.
- Stretch: if you have a faker library handy (
pip install faker), replace any plausible-but-fake data with faker-generated values. Notice how much more realistic the data feels — useful for end-to-end validation that catches "we never tested non-ASCII names" bugs.
The next lesson handles the question of when to use fake services entirely: mocks and virtualisation.