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 testsFactory 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:
- Unique IDs — use timestamps + random suffixes, never hardcoded IDs
- Tenant factory — creates complete tenant context in one call
- Cleanup — truncate or rollback after every test
- 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.