Stateful Testing Systems
Most test suites assume a fresh, disposable world every time they run. Spin up a database, seed some fixtures, run the assertions, throw it all away. That model works beautifully right up until the thing you need to test doesn’t fit inside a single process lifetime.
Take a system that sends a real verification email as part of signing up, and enforces a real rate limit on how many signups can happen per hour. There is no bypass for either of those, because bypassing them would mean not testing the real thing. You have to actually wait for the email to arrive, and you have to actually respect the rate limit. Neither of those fits into “run a function, assert on the result, tear everything down.”
Why stateless breaks down here
Three constraints, each ordinary on its own, become a problem together.
First, some outcomes are asynchronous and external. A verification code arriving by email might take seconds or might take minutes, and nothing in the system under test can tell you it happened — you have to go look. A stateless test either polls in a tight loop until it gives up (slow, flaky, and it still throws away everything it learned when it times out) or it fakes the email away entirely, which means it stopped testing the real flow.
Second, real limits mean you can’t have a fresh fixture per test. If the system only allows a handful of signups per hour from a given source, “create a brand new account for every single test run” runs out of budget almost immediately. You either share a much smaller number of accounts across many tests, or you stop testing the real limit at all.
Third, some behavior only shows up over real time. A soft-delete window that gives a user 24 hours to change their mind is not meaningfully tested by mocking the clock forward — you learn a lot more by having a test that is still alive, waiting, a day later.
At first glance this looks like a job for property-based testing: generate a lot of different paths through the system, look for the one that breaks. I started down that road. It’s a good fit when the thing under test is a fast, repeatable, roughly pure function — feed it inputs, check a property, repeat thousands of times, throw everything away between runs. But none of the three constraints above are about generating more inputs. They’re about one world that keeps evolving, observed over real, asynchronous, rate-limited time. That’s a different axis. Trying to force it into a property-testing shape mostly produced elaborate mocks of the very things that needed to be real.
The stateful model, in principle
Once “we need many random inputs” stopped being the right frame, the shape that actually fit was much closer to a small state machine than a test runner: state lives in the database, the state machine that interprets it lives in code, and the two are deliberately kept separate.
Every fact about a test in flight — what it’s currently doing, what it’s waiting on, what it has already created or claimed — gets written to durable storage the moment it happens, rather than living only in the memory of whatever process is currently running the test. A long-running test is naturally a sequence of stages, and some stages only make sense once something happens in the outside world: an email lands, a status flips from “pending” to “ready.” Treat reaching such a stage as an ordinary paused state, not a failure and not a timeout to defend against. The test just waits, durably, until a matching event turns up. Nothing is polling in a tight loop holding a process open; the wait is recorded, and whatever is watching the outside world wakes the test up when there’s a match.
Because the data and the interpreter are separate, they can change on different schedules. Restarting or crashing the thing driving the tests is not a special case to design around — it comes back up, reads the same durable state, and continues, even if the code doing the reading has been redeployed in between. That cuts both ways, and the useful direction is the one that’s easy to miss: you can fix the code without touching the state. A bug in how a stage is interpreted, a wrong timeout, a matcher that’s too strict — fix it and let paused tests resume under the corrected logic, instead of throwing away every test that happened to be mid-flight when you shipped the fix. The same durability that survives a crash also survives a deploy. It also means state can be repaired directly, or a test can be nudged past a stage it’s stuck on, without pretending the fix has to go through the state machine’s normal transitions.
The same separation has a sharp edge worth naming honestly: if a paused test can be picked back up later, “picked back up” often really means “run from the top again until you reach the point you’d already gotten to.” Anything with a side effect — creating an account, generating a random identity, sending a request — has to be safe to repeat, or it needs to remember that it already happened. The general answer is to make “I already did this” part of the same durable state as everything else, rather than assuming replay is free. It usually isn’t, and pretending otherwise is where this pattern quietly breaks if you’re not careful.
Fixtures deserve the same rethink. Instead of creating a fresh one and discarding it per test, pool them and hand one out for a whole test’s lifetime, not just the moment it’s touched. This is not a performance shortcut — it’s closer to a feature. A fixture that has been through a handful of real scenarios looks like the accounts and records a live system actually accumulates, in a way a pristine, minutes-old fixture never will, and that accumulated mess is exactly where complex, emergent behavior tends to hide. A clean system starts every test from the one state nobody’s users are actually in. How many tests currently hold a given fixture is best derived from who’s holding it, rather than tracked as a separate counter, so the two can never quietly drift apart. And when a test crashes instead of finishing cleanly, releasing whatever it was holding has to be someone else’s job — a crashed test never reaches its own cleanup code.
A durable, shared test database is also something you can query, not just populate — it becomes a knowledge pool a test can consult before deciding what to assert. Testing tenant isolation is the clean example: rather than constructing two synthetic accounts and hoping the fixture is representative, a test can query what data already exists across different tenants in the pool and use those real records as the assertion’s input — check that account A’s query genuinely never surfaces anything belonging to account B, using whatever B actually happens to have accumulated. The test doesn’t need to know in advance what that data looks like; it just needs to look.
The only bridge back to that outside async world is a small number of generic watchers: something that notices a matching email, something that polls a status until it changes, something that just tracks a deadline. Keeping these generic and reusable, rather than bespoke per test, is what makes the “wait for an event” idea cheap to reach for.
A rough sketch of what a paused test’s durable record might hold:
{
"stage": "awaiting_verification_email",
"started_at": "2026-08-06T09:12:00Z",
"waiting_for": { "kind": "email", "to": "test-run-482@example.com" },
"already_done": ["created_account"]
}
And the shape of “is anything ready to resume” is close to a simple match:
for each paused test:
if an unconsumed event matches test.waiting_for:
mark it consumed
resume the test from where it left off
Nothing in either sketch is clever. That’s rather the point — the state machine itself can stay small; the value is entirely in treating durability, resumability, and pooled, aging fixtures as the default, instead of something bolted on for the awkward tests.
What this buys you long-term
A crash halfway through a long scenario stops being an incident and becomes an ordinary resumed case, and a bad deploy stops being a reason to lose every test in flight. Real time windows — a delay, a cooldown, a grace period — get tested as themselves, waiting the actual duration, rather than simulated with a mocked clock that can drift from how the real one behaves. And the harder class of bugs — the ones that only show up as data accumulates and interacts in ways a hand-written fixture never anticipated — gets a test population that actually looks like that, instead of a suite that structurally cannot produce it.
Where this could go: migration testing
Nothing about this pattern is specific to verifying a live API. Once test state is durable, external, and shaped like the real thing, the same architecture is a natural fit for testing how a system behaves when its data outlives a single version of the code — replaying an old, accumulated shape of state through a new migration and watching it resolve, rather than only ever migrating freshly seeded fixtures. I haven’t built that part yet, but the durability this pattern already requires is most of what it would take.
TL;DR
Stateless, fresh-fixture testing is the right default until the system under test is itself long-running, asynchronous, and constrained by real rate limits or real time — and until the bugs you’re chasing are emergent ones that only show up in accumulated, aged state. At that point, splitting durable state in the database from a state machine in code — rather than fighting to keep tests fast and disposable — stops being a workaround and becomes the more honest way to test the thing you actually built.