Ask anyone who maintains an automation suite what the biggest cause of unstable tests is, and the answer is rarely "the test logic". It's almost always data: the user another test deleted, the coupon that was already used, the record that existed yesterday and is gone today. Writing the assertion is the easy part; making sure the system is in the right state before it runs is the real work, and the most neglected.
Why data is as hard as logic
A test is only reliable if the state it depends on is predictable. The problem is that data has three natural enemies: it changes (someone edited the record), it disappears (it was deleted), and it collides (two tests touch the same record at the same time). When a green test suddenly turns red "without anyone touching anything", the culprit is almost always the data, and this is one of the biggest sources of flaky tests.
Where data comes from: the four strategies
There are four ways to put the system in the required state, from most fragile to most robust:
| Strategy | What it looks like | Problem / when to use |
|---|---|---|
| Production data | (Masked) copy of the real database | Realistic, but unstable and risky (privacy laws); avoid depending on it |
| Shared seed | Fixed data loaded once for everyone | Convenient, but becomes "global state" that tests corrupt for each other |
| Fixture | Predefined set per test/scenario | Good for static read-only data |
| Factory (on demand) | The test creates what it needs on the spot, with unique data | The most robust for data the test modifies |
The modern trend is clear: less global seed, more factory. Instead of "these 50 users exist for everyone", each test creates its own user at the start, with a unique identifier. Nobody steps on anyone's toes.
The golden rule: each test takes care of its own state
The principle that solves most problems: a test should create what it needs, use it, and clean up afterwards, without depending on anything another test did. This guarantees the independence that lets you run tests in any order and in parallel.
// Pattern: arrange → act → assert → cleanup
beforeEach: user = createUser({ email: `test+${uuid()}@ex.com` })
test: login(user); buy(product)
expect(order).toBe("confirmed")
afterEach: deleteUser(user) // leaves no trash behind
The uuid() in the email is the detail that avoids collisions: even running 10 copies of the test in parallel, each one has its own unique user. And the cleanup in afterEach keeps today's failure from becoming the orphaned data that breaks tomorrow's test.
A test that depends on data it didn't create is a time bomb. It works until the day that record changes, and then you lose an afternoon chasing a "bug" that was never in the code.
Create through the API, not the UI
To prepare state, prefer the cheapest and most stable route. If your checkout test needs a user with products in the cart, don't automate the whole sign-up and navigation through the screen just to get there. Create the user and the cart directly through API calls (or straight in the database) in the setup, and reserve the UI for what you actually want to test. It's faster, and the preparation doesn't break when the sign-up screen changes. Heavy setup through the UI is one of the most common recipes for a slow, brittle suite.
How to keep the environment clean
Even with each test cleaning up after itself, the test database accumulates dirt over time. Three approaches, from strongest to most practical:
- Rolled-back transaction: run the test inside a transaction and rollback at the end. Perfect, instant cleanup, but it only works for tests that hit the same database directly, not across the network.
- Reset between runs: recreate the schema or reload a clean snapshot before the suite. Guarantees a known starting point on every run.
- Explicit cleanup:
afterEach/afterAlldeletes what it created. More work, but it's what works in end-to-end E2E tests.
Ephemeral containers (spinning up a Docker database just for a pipeline run and destroying it at the end) combine the best of both worlds: an isolated environment, clean by construction, discarded when done.
Conclusion
Test data is the invisible bottleneck: it doesn't show up in the assertion, but it's where most suites rot. Prefer on-demand factories to a global seed, make each test create and clean up its own state with unique identifiers, prepare the scenario through the API instead of the UI, and keep the environment clean with transactions, resets, or ephemeral containers. Solve the data and you eliminate the biggest slice of flaky tests in one go.