ProductHow it worksPricingBlogDocsLoginFind Your First Bug
Two test runs writing to the same table, one scoped to tenant A and one to tenant B, with a tenant_id boundary keeping each test's rows from being visible to the other
TestingTest Data IsolationMulti-Tenant Testing

How to Isolate Test Data Per Tenant

Tom Piaggio
Tom PiaggioCo-Founder at Autonoma

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:

import Database from "better-sqlite3";
import { randomUUID } from "node:crypto";

/**
 * A single shared database connection.
 *
 * Every module in this project (the seed factory, the teardown helper, and the
 * test file) imports THIS db instance, so all reads and writes hit the same
 * underlying `projects` table. Isolation between tenants is enforced entirely by
 * scoping every query to a `tenant_id`, never by handing each test its own
 * database. That is the whole point: prove isolation on shared state.
 */
export const db = new Database(":memory:");

db.exec(`
  CREATE TABLE IF NOT EXISTS projects (
    id        TEXT PRIMARY KEY,
    tenant_id TEXT NOT NULL,
    name      TEXT NOT NULL
  );
`);

export interface Project {
  id: string;
  tenant_id: string;
  name: string;
}

/**
 * Insert a project owned by `tenantId`. The tenant id is the first argument and
 * is stamped onto the row directly, so a caller can never create an unscoped
 * project by forgetting a parameter.
 */
export function createProject(tenantId: string, name: string): Project {
  const project: Project = { id: randomUUID(), tenant_id: tenantId, name };
  db.prepare(
    `INSERT INTO projects (id, tenant_id, name) VALUES (?, ?, ?)`
  ).run(project.id, project.tenant_id, project.name);
  return project;
}

/**
 * Return every project belonging to `tenantId`, and only that tenant. The
 * `WHERE tenant_id = ?` clause is not optional: without it this query would
 * return every tenant's rows at once.
 */
export function listProjects(tenantId: string): Project[] {
  return db
    .prepare(`SELECT id, tenant_id, name FROM projects WHERE tenant_id = ?`)
    .all(tenantId) as Project[];
}

/**
 * Fetch a single project by id, scoped to `tenantId`.
 *
 * The id AND the tenant_id must both match in the same query. If a project id
 * from another tenant is passed in, the row simply does not match and the
 * function returns `undefined` rather than returning a record the caller then
 * has to remember to reject.
 */
export function getProject(
  tenantId: string,
  projectId: string
): Project | undefined {
  return db
    .prepare(
      `SELECT id, tenant_id, name FROM projects WHERE id = ? AND tenant_id = ?`
    )
    .get(projectId, tenantId) as Project | undefined;
}

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.

Test Atenant_id = ATest Btenant_id = Bprojectstenant AAcme Launchtenant BBeta Launchtenant C

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:

import { randomUUID } from "node:crypto";
import { createProject } from "../src/tenantRepository";

export interface SeededTenant {
  tenantId: string;
  projectIds: string[];
}

/**
 * Create a brand-new tenant and seed it with a few projects.
 *
 * Tenant identity is produced here, not passed in by the caller and never
 * defaulted to a shared "default" tenant. Every call mints a fresh, independent
 * tenant id, so two callers can never accidentally share tenant-scoped state.
 */
export function seedTenant(): SeededTenant {
  const tenantId = randomUUID();
  const names = ["Acme Launch", "Internal Tools", "Q3 Roadmap"];
  const projectIds = names.map((name) => createProject(tenantId, name).id);
  return { tenantId, projectIds };
}

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.

import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { seedTenant } from "../fixtures/seedTenant";
import { teardownTenant } from "../fixtures/teardownTenant";
import { listProjects } from "../src/tenantRepository";

describe("tenant-scoped test data isolation", () => {
  // Each test gets its own tenant, minted fresh in beforeEach and torn down in
  // afterEach. All tests share the same underlying sqlite table; isolation comes
  // purely from never letting two tests share a tenant id.
  let tenantId: string;
  let seededProjectIds: string[];

  beforeEach(() => {
    const seeded = seedTenant();
    tenantId = seeded.tenantId;
    seededProjectIds = seeded.projectIds;
  });

  afterEach(() => {
    teardownTenant(tenantId);
  });

  test("test A sees exactly its own tenant's projects", () => {
    const projects = listProjects(tenantId);

    expect(projects).toHaveLength(seededProjectIds.length);
    expect(projects.every((p) => p.tenant_id === tenantId)).toBe(true);
    expect(projects.map((p) => p.id).sort()).toEqual(
      [...seededProjectIds].sort()
    );
  });

  test("test B sees exactly its own tenant's projects, never test A's", () => {
    const projects = listProjects(tenantId);

    expect(projects).toHaveLength(seededProjectIds.length);
    expect(projects.map((p) => p.id).sort()).toEqual(
      [...seededProjectIds].sort()
    );
  });

  test("a second tenant's rows never leak into this tenant's view", () => {
    // Seed a completely independent tenant alongside this test's tenant, on the
    // same shared table. Neither tenant should ever see the other's rows.
    const other = seedTenant();
    try {
      const mine = listProjects(tenantId);
      const theirs = listProjects(other.tenantId);

      expect(mine.map((p) => p.id).sort()).toEqual(
        [...seededProjectIds].sort()
      );
      expect(theirs.map((p) => p.id).sort()).toEqual(
        [...other.projectIds].sort()
      );

      const overlap = mine.filter((p) => theirs.some((t) => t.id === p.id));
      expect(overlap).toHaveLength(0);
    } finally {
      teardownTenant(other.tenantId);
    }
  });
});

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.

1Create tenanttenant A2Seed rowstenant A3Run testtenant A4Delete rowstenant A

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:

import { db } from "../src/tenantRepository";

/**
 * Delete only the rows that belong to `tenantId`.
 *
 * This deletes by tenant_id specifically and never truncates the whole table.
 * A shared table under a `TRUNCATE`/`DELETE FROM projects` is exactly what
 * breaks a different test running in parallel; scoping the delete keeps every
 * other tenant's rows untouched.
 */
export function teardownTenant(tenantId: string): void {
  db.prepare(`DELETE FROM projects WHERE tenant_id = ?`).run(tenantId);
}

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.

PatternPreventsAdd it when
Tenant-scoped queriesCross-tenant readsAlways, from the first table
Seed factoryShared, leaking fixturesAs soon as a second test seeds data
Fresh tenant per testOrder-dependent flakinessOnce tests run in parallel
Teardown by tenant_idRows polluting the next runAs 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.

Related articles

A preview environment's database moving through create, live, and merge stages, then being deleted, with nothing carried forward into production

What Happens to Preview Environment Data After You Merge?

Preview environment data after merge is torn down, not promoted to production. The full lifecycle, from creation to teardown, and what should never persist.

Three parallel test runs writing to one shared database, with one run's leftover row causing another run's assertion to fail

The Shared-State Problem in Test Environments (and How to Kill It)

The shared-state problem: parallel tests or previews writing to one database, making failures non-deterministic. Three fixes, and which one fits your team.

A horizontal agent trajectory diagram showing a tool call passing a right-tool checkpoint but failing an argument-accuracy checkpoint

How to Test AI Agents That Take Actions (Tool Calls)

A runnable guide to testing tool-calling agents: right tool, right order, right arguments, mocked vs live calls, failure handling, and non-determinism.

A chatbot test pipeline moving from manual QA through scripted and semantic assertions into an automated CI gate that samples the model N times before allowing a merge

Chatbot Automation Testing: Why Assertions Fail

Chatbot automation testing that survives non-deterministic replies: the migration to a CI gate, n-run sampling, threshold gating, and real GitHub Actions YAML.