Multi-Tenant Test Fixtures: Seeding and Managing Test Data Across Tenants

Multi-Tenant Test Fixtures: Seeding and Managing Test Data Across Tenants

Test fixtures for multi-tenant applications need to create data within specific tenant contexts and clean up completely between tests. Poorly managed fixtures are the number one cause of flaky tests in multi-tenant systems — data from one test bleeding into another.

The Problem with Naive Fixtures

// ❌ Bad: no tenant isolation in fixtures
async function createProject(name) {
  return db.projects.create({ name })  // Which tenant?
}

// ❌ Bad: shared global IDs that cause conflicts
const PROJECT_ID = 'test-project-1'  // Conflicts across tests

Factory Pattern for Multi-Tenant Data

// test/factories/tenant-factory.js
let tenantCounter = 0

export function buildTenant(overrides = {}) {
  tenantCounter++
  return {
    id: `tenant-test-${tenantCounter}-${Date.now()}`,
    name: `Test Company ${tenantCounter}`,
    slug: `test-company-${tenantCounter}`,
    status: 'active',
    plan: 'starter',
    ...overrides,
  }
}

export async function createTenant(overrides = {}) {
  const tenant = buildTenant(overrides)
  return db.tenants.create(tenant)
}
// test/factories/user-factory.js
let userCounter = 0

export function buildUser(tenantId, overrides = {}) {
  userCounter++
  return {
    id: `user-test-${userCounter}-${Date.now()}`,
    tenantId,
    email: `test-user-${userCounter}@example.com`,
    name: `Test User ${userCounter}`,
    role: 'member',
    ...overrides,
  }
}

export async function createUser(tenantId, overrides = {}) {
  const user = buildUser(tenantId, overrides)
  return db.users.create(user)
}
// test/factories/project-factory.js
export function buildProject(tenantId, overrides = {}) {
  return {
    id: `proj-${Date.now()}-${Math.random().toString(36).slice(2)}`,
    tenantId,
    name: 'Test Project',
    status: 'active',
    ...overrides,
  }
}

export async function createProject(tenantId, overrides = {}) {
  const project = buildProject(tenantId, overrides)
  return db.projects.create(project)
}

Tenant Test Context

Create a helper that sets up a complete tenant context:

// test/helpers/tenant-context.js
import { createTenant } from '../factories/tenant-factory'
import { createUser } from '../factories/user-factory'
import { generateToken } from '../../src/auth'

export async function createTenantContext(options = {}) {
  const tenant = await createTenant(options.tenant)
  
  const admin = await createUser(tenant.id, { role: 'admin', ...options.admin })
  const member = await createUser(tenant.id, { role: 'member', ...options.member })
  const viewer = await createUser(tenant.id, { role: 'viewer', ...options.viewer })
  
  return {
    tenant,
    admin,
    member,
    viewer,
    adminToken: generateToken(admin),
    memberToken: generateToken(member),
    viewerToken: generateToken(viewer),
  }
}

Usage:

describe('Project isolation', () => {
  let ctxA, ctxB
  
  beforeEach(async () => {
    ctxA = await createTenantContext()
    ctxB = await createTenantContext()
  })
  
  test('tenant A cannot see tenant B projects', async () => {
    await createProject(ctxB.tenant.id, { name: 'B Project' })
    
    const response = await request(app)
      .get('/api/projects')
      .set('Authorization', `Bearer ${ctxA.adminToken}`)
    
    expect(response.body.data).toHaveLength(0)
  })
})

Database Cleanup Strategies

Strategy 1: Transaction Rollback (Fastest)

Wrap each test in a transaction and roll it back:

// test/helpers/db-isolation.js
let transaction

beforeEach(async () => {
  transaction = await db.transaction()
  // Override db to use transaction
  db.client = transaction.client
})

afterEach(async () => {
  await transaction.rollback()
})

Strategy 2: Truncate After Each Test

const TENANT_TABLES = ['projects', 'users', 'usage_records', 'subscriptions', 'tenants']

afterEach(async () => {
  // Disable FK checks, truncate, re-enable
  await db.raw('SET FOREIGN_KEY_CHECKS = 0')
  for (const table of TENANT_TABLES) {
    await db(table).truncate()
  }
  await db.raw('SET FOREIGN_KEY_CHECKS = 1')
})

Strategy 3: Delete by Test-Created IDs

const createdIds = { tenants: [], users: [], projects: [] }

afterEach(async () => {
  // Delete in reverse dependency order
  await db('projects').whereIn('id', createdIds.projects).delete()
  await db('users').whereIn('id', createdIds.users).delete()
  await db('tenants').whereIn('id', createdIds.tenants).delete()
  
  createdIds.tenants = []
  createdIds.users = []
  createdIds.projects = []
})

Preventing Fixture Pollution

Fixtures pollute tests when data persists across test runs. Use unique identifiers:

// ✓ Use timestamps + random suffix for uniqueness
const tenantId = `test-${Date.now()}-${crypto.randomUUID().slice(0, 8)}`

// ✓ Use a test-unique prefix for all created records
const PREFIX = `test-${process.env.TEST_WORKER_ID || 'main'}-${Date.now()}`

function prefixedSlug(name) {
  return `${PREFIX}-${name}`.toLowerCase().replace(/[^a-z0-9-]/g, '-')
}

Parallel Test Worker Isolation

When running tests in parallel (e.g., Vitest with multiple workers), fixtures from worker A must not conflict with worker B:

// vitest.config.js
export default {
  test: {
    pool: 'threads',
    poolOptions: {
      threads: {
        singleThread: false,
      }
    },
    // Each worker gets a unique ID
    env: {
      TEST_WORKER_ID: '{workerIndex}'
    }
  }
}
// test/helpers/fixture-id.js
const workerId = process.env.TEST_WORKER_ID || '0'
let counter = 0

export function uniqueId(prefix = 'test') {
  counter++
  return `${prefix}-w${workerId}-${counter}-${Date.now()}`
}

Fixture Relationships

For complex entity graphs (tenant → users → projects → tasks → comments), build a hierarchical fixture:

export async function createFullTenantFixture() {
  const tenant = await createTenant({ name: 'Full Test Tenant' })
  const admin = await createUser(tenant.id, { role: 'admin' })
  const member = await createUser(tenant.id, { role: 'member' })
  
  const project1 = await createProject(tenant.id, { name: 'Project One', ownerId: admin.id })
  const project2 = await createProject(tenant.id, { name: 'Project Two', ownerId: member.id })
  
  const tasks = await Promise.all([
    createTask(tenant.id, project1.id),
    createTask(tenant.id, project1.id),
    createTask(tenant.id, project2.id),
  ])
  
  return { tenant, admin, member, projects: [project1, project2], tasks }
}

Summary

Multi-tenant fixtures need:

  1. Unique IDs — use timestamps + random suffixes, never hardcoded IDs
  2. Tenant factory — creates complete tenant context in one call
  3. Cleanup — truncate or rollback after every test
  4. Worker isolation — unique prefixes per parallel worker

The goal is tests that never depend on or interfere with each other's data, regardless of execution order or parallelism.

Read more