Isolating test data per tenant means every row a test creates, reads, or deletes carries a tenant id, so no test, whether it runs alone or alongside a hundred others, can see or corrupt a row that belongs to a different tenant. In a multi-tenant codebase this comes down to four mechanical patterns: scope every query by tenant_id, seed through a factory that stamps that id automatically, mint a fresh tenant per test, and tear down only that tenant's rows when the test ends.
Two tests run back to back. Test A creates a project called "Acme Launch." Test B asserts a tenant has exactly one project. It sees two. Nobody touched Test B's code. Test A's row just leaked into it, because both tests wrote to the same table with nothing between them.
That's not a flaky test. It's what happens when isolation is optional. The goal, stated as plainly as possible, is to keep one test's data out of another's, no matter how many tests run in parallel or how many times the suite runs today. Four patterns get you there, and they build on each other in order.
The same constraint applies when several pull requests are being validated at once. A test can have perfectly scoped rows and still produce misleading evidence if its branch shares a mutable environment with another branch. Isolated previews keep parallel development from turning test state into a hidden dependency before merge. We built Autonoma with managed preview environments and end-to-end testing in one product so that a pull request can validate in its own runtime.
Scope Every Row to a Tenant ID
This is the foundation everything else sits on. Every table that holds tenant-owned data gets a tenant_id column, and every query that reads or writes that table, without exception, filters on it. Not "most queries." Every query. A single unscoped SELECT * FROM projects anywhere in the codebase is a hole every pattern below fails to cover.
The cleanest way to enforce that in practice is a repository layer where tenantId isn't an optional parameter, it's the first argument every function takes, and it's baked into the WHERE clause rather than left to whoever calls the function to remember. Here's that repository, including the schema it queries against:
Notice that getProject doesn't just fetch by id and check the tenant afterward. It requires both the id and the tenant_id to match in the same query. That distinction matters: if a project id ever leaks (a support screenshot, a shared link, a sequential id someone guesses), asking for it under the wrong tenant returns nothing, not a record you then have to remember to reject.
Tenant-scoped reads and writes enforce the boundary at the point every test actually touches data.
Where Hand-Rolled Isolation Stops Scaling
That one guarantee, every query scoped by tenant id, already has to hold on every table and every query path, forever, as the schema keeps growing. The three patterns that follow (seeding, fresh tenants, teardown) add more surface area to keep correct, not less. PreviewKit, our managed preview-environments layer, provisions, routes, and tears down a full-stack environment per pull request with database isolation, environment routing, and secrets propagation. That gives each branch its own runtime for validation; it does not remove the need to scope tenant-owned rows correctly inside the application.
Per-Tenant Seed Factories
Scoped queries only help if the data underneath is scoped too. A seed script that inserts test fixtures into one shared "default" tenant works for exactly one test, then quietly becomes shared state every test after it reads, mutates, and depends on without anyone deciding that on purpose. Six months in, nobody can delete a row from that fixture without breaking a test three files away.
A seed factory fixes this by taking tenant identity as the thing it produces, not the thing you remember to pass in correctly. Call it, and it mints a brand-new tenant id, then stamps every row it creates with that id:
Contrast that with a naive global seed function that takes no tenant argument at all and defaults to inserting into whatever tenant happens to already exist. That function works in isolation and fails the moment a second test calls it, because now two tests share one tenant's rows and neither one owns the cleanup.
Give Every Test Its Own Throwaway Tenant
With a seed factory in place, minting a fresh tenant per test is nearly free: instead of reusing a tenant across the whole suite, call the factory inside a beforeEach hook so every test gets a tenant nobody else has ever touched. Two tests can now run in parallel, hit the exact same table, and never see each other's rows, because they were never scoped to the same tenant id in the first place.
Both tests in that file call the same seed factory and get back a different tenant id every time. That's the whole trick: isolation here isn't a locking mechanism or a separate database per test, it's just refusing to let two tests ever share a tenant id.
A fresh tenant gives a test exclusive state, then targeted teardown removes only the rows that test created.
How Autonoma validates tenant isolation per pull request
Fresh tenants and targeted teardown protect tests that share an application environment. The remaining gap is whether the environment itself represents only the pull request under review, rather than carrying mutable state from another branch or an already-merged change.
Autonoma's managed preview environments make that boundary part of the workflow: PreviewKit provides the isolated full-stack runtime, the Planner handles the database state each end-to-end test needs, and the Diffs Agent maintains test cases as each pull request changes. The result is not a substitute for tenant_id filters or seed factories. It is a way to exercise those safeguards against the exact change before merge.
Teardown Between Tests: Removing Only That Tenant's Rows
Minting a fresh tenant per test solves leakage between tests running at the same time. It doesn't solve leakage between test runs, unless something deletes each test's rows when it finishes. The teardown step has to delete by tenant_id specifically, never truncate the whole table, because a shared table under a truncate is exactly the kind of thing that breaks a different test running in parallel:
Skip this step, or get it wrong, and the symptom is order-dependent flakiness: a test passes alone, fails in the full suite, and passes again if you run it twice, because the second run's teardown quietly cleaned up the mess the first run left behind.
Which Pattern When
All four patterns matter, but they don't all guard against the same failure, and it's worth being precise about which one to reach for first.
| Pattern | Prevents | Add it when |
|---|---|---|
| Tenant-scoped queries | Cross-tenant reads | Always, from the first table |
| Seed factory | Shared, leaking fixtures | As soon as a second test seeds data |
| Fresh tenant per test | Order-dependent flakiness | Once tests run in parallel |
| Teardown by tenant_id | Rows polluting the next run | As soon as teardown exists at all |
Make isolation a pre-merge contract
Start at the top and do not skip it. Tenant-scoped queries are non-negotiable in a multi-tenant schema; the other three patterns prevent tests from interfering with each other, but none can repair an unscoped query underneath. For the broader failure modes this set is designed to catch, including cross-tenant reads, missing tenant_id filters, leaking background jobs, and cache collisions, see testing multi-tenant SaaS applications. For the limits of a fresh-tenant-per-test strategy against a genuinely shared database, see the shared-state problem in test environments.
In a modern delivery workflow, the question is not only whether the suite passes. It is whether the suite passes in a runtime that belongs to the pull request being reviewed. PreviewKit provides that isolated per-pull-request environment, so parallel work can validate real tenant boundaries before merge instead of discovering shared-state failures after branches have already converged.
FAQ
It means every row a test creates, reads, or deletes is scoped to a specific tenant id, so that test's data is never visible to, or affected by, any other test's tenant, regardless of whether the tests run sequentially or in parallel against the same database.
Share a tenant only for tests that are deliberately testing interactions within that tenant. For everything else, a fresh tenant per test is cheap once you have a seed factory, and it removes an entire category of order-dependent flakiness where a test's outcome depends on what ran before it.
Tenant-scoped queries prevent one test from reading another tenant's data while both exist at the same time. Teardown prevents a finished test's leftover rows from polluting a future test run. You need both: scoping without teardown still leaks state across runs, and teardown without scoping still leaks state across concurrent tests.
Yes. All four patterns assume a single shared database with a tenant_id column, which is how most multi-tenant SaaS applications are actually built. A database-per-tenant or branch-per-tenant architecture changes the isolation mechanism, but the query-scoping, seeding, and teardown discipline in this article still applies to whatever tests run inside each of those databases.




