E2E Test Isolation: Strategies That Actually Work in Production
E2E tests that share state are the source of most test suite nightmares: tests that pass in isolation but fail in CI, tests that must run in a specific order, tests that corrupt data for tests that run later. The root cause is almost always isolation failure—tests that don't properly clean up after themselves or that depend on state created by earlier tests.
This guide covers practical isolation strategies for E2E tests that run against real backends, covering database isolation, user/tenant isolation, network boundary isolation, and test data management patterns that scale.
Why E2E Isolation Is Different
Unit and integration tests have well-understood isolation patterns: mock dependencies, use in-memory databases, roll back transactions. E2E tests are different because they're supposed to exercise the full stack—which means they need real databases, real backends, and real network calls.
This creates a fundamental tension: full-stack testing requires real infrastructure, but real infrastructure makes isolation difficult. The goal isn't to eliminate real infrastructure—it's to make each test own its data and state so tests don't interfere with each other.
Strategy 1: Tenant-Based Isolation
For multi-tenant applications, the cleanest isolation strategy is to give each test run its own tenant. Tests write and read data within their tenant without touching other tenants' data.
// fixtures/isolated-tenant.fixture.ts
import { test as base } from '@playwright/test';
interface TenantFixture {
tenantId: string;
tenantApiKey: string;
}
export const test = base.extend<TenantFixture>({
tenantId: async ({}, use) => {
// Create an isolated tenant for this test
const response = await fetch('http://localhost:3001/test-api/tenants', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: `test-tenant-${Date.now()}-${Math.random().toString(36).slice(2)}`,
plan: 'trial',
}),
});
const tenant = await response.json();
await use(tenant.id);
// Cleanup after test
await fetch(`http://localhost:3001/test-api/tenants/${tenant.id}`, {
method: 'DELETE',
});
},
tenantApiKey: async ({ tenantId }, use) => {
const response = await fetch(
`http://localhost:3001/test-api/tenants/${tenantId}/api-key`
);
const { apiKey } = await response.json();
await use(apiKey);
},
});This requires a test API endpoint that can create and delete tenants. The overhead is worth it: tests can run in parallel without data conflicts.
Strategy 2: Database Snapshots and Restore
When tenant isolation isn't available, database snapshots provide isolation by resetting the database to a known state before each test (or test suite).
PostgreSQL Snapshot Pattern
# scripts/snapshot-db.sh
pg_dump --format=custom testdb > /tmp/testdb.snapshot
# scripts/restore-db.sh
pg_restore --clean --if-exists --format=custom -d testdb /tmp/testdb.snapshot// playwright.config.ts
export default defineConfig({
globalSetup: './global-setup.ts', // create snapshot
globalTeardown: './global-teardown.ts',
projects: [
{
name: 'checkout-tests',
use: { storageState: 'auth/checkout-user.json' },
testDir: './tests/checkout',
},
],
});
// global-setup.ts
import { exec } from 'child_process';
import { promisify } from 'util';
const execAsync = promisify(exec);
export default async function globalSetup() {
// Seed the database to a known state
await execAsync('npm run db:seed:test');
// Take a snapshot
await execAsync('npm run db:snapshot');
}
// In each test file or fixture:
test.beforeEach(async () => {
// Restore to snapshot
await execAsync('npm run db:restore');
});Snapshot restore is fast for small databases (sub-second for <100MB) but becomes a bottleneck for large datasets. For large databases, consider restoring at the test suite level rather than per-test.
Transaction Rollback Pattern
For tests that talk directly to the database (or for API-level integration tests), wrapping each test in a transaction and rolling back is faster than snapshot restore:
// This pattern works when your test can intercept database connections
import { Pool } from 'pg';
import { test as base } from '@playwright/test';
export const test = base.extend({
db: async ({}, use) => {
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const client = await pool.connect();
await client.query('BEGIN');
await use(client);
await client.query('ROLLBACK');
client.release();
await pool.end();
},
});Strategy 3: User-Based Isolation
When database isolation isn't practical, user-based isolation is often the next best option: each test creates its own user account and all data is scoped to that account.
// fixtures/isolated-user.fixture.ts
export const test = base.extend<{ user: { email: string; password: string } }>({
user: async ({ request }, use) => {
const email = `test-${Date.now()}@example.com`;
const password = 'Test1234!';
// Create user via API (faster than UI registration)
await request.post('/api/auth/register', {
data: { email, password, name: 'Test User' },
});
await use({ email, password });
// Cleanup: delete user and all their data
const loginResponse = await request.post('/api/auth/login', {
data: { email, password },
});
const { token } = await loginResponse.json();
await request.delete('/api/users/me', {
headers: { Authorization: `Bearer ${token}` },
});
},
});
// In tests:
test('user can create and delete a project', async ({ page, user }) => {
// Login as the isolated user
await page.goto('/login');
await page.getByLabel('Email').fill(user.email);
await page.getByLabel('Password').fill(user.password);
await page.getByRole('button', { name: 'Sign in' }).click();
// All actions are scoped to this user—no interference with other tests
await page.getByRole('button', { name: 'New Project' }).click();
// ...
});Strategy 4: Seed Factories
Rather than importing a monolithic SQL dump, seed factories let you create exactly the data each test needs—no more, no less.
// factories/user.factory.ts
interface UserFactoryOptions {
email?: string;
role?: 'admin' | 'user' | 'viewer';
plan?: 'free' | 'pro' | 'enterprise';
}
export async function createUser(
options: UserFactoryOptions = {}
): Promise<{ id: string; email: string; token: string }> {
const email = options.email ?? `user-${Date.now()}@example.com`;
const response = await fetch('http://localhost:3001/test-api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email,
role: options.role ?? 'user',
plan: options.plan ?? 'free',
}),
});
return response.json();
}
// factories/project.factory.ts
export async function createProject(
ownerId: string,
options: { name?: string; isPublic?: boolean } = {}
): Promise<{ id: string; slug: string }> {
const response = await fetch('http://localhost:3001/test-api/projects', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
ownerId,
name: options.name ?? `Project ${Date.now()}`,
isPublic: options.isPublic ?? false,
}),
});
return response.json();
}
// In tests:
test('project owner can invite collaborators', async ({ page }) => {
const owner = await createUser({ plan: 'pro' });
const collaborator = await createUser();
const project = await createProject(owner.id, { name: 'My Project' });
// Navigate directly to the page that needs testing—no setup via UI
await page.goto(`/projects/${project.slug}/settings/members`);
// ...
});Factories are faster than UI setup because they skip the browser entirely for test data creation. A test that would take 30 seconds to set up via UI (register, create project, add members) can set up in under a second via API.
Strategy 5: Network Boundary Isolation
Sometimes you need to isolate external dependencies (payment processors, email services, analytics). Network-level mocking prevents real charges, real emails, and real external API calls during tests.
Playwright Route Interception
// In your test or a fixture:
test('successful payment shows confirmation', async ({ page }) => {
// Intercept the Stripe API call and return a mock success response
await page.route('https://api.stripe.com/v1/payment_intents', route => {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
id: 'pi_test_1234',
status: 'succeeded',
amount: 9999,
}),
});
});
// Your app makes a server-side Stripe call—this won't be intercepted
// by browser-level routing. For server-side calls, use a proxy or env vars.
await page.goto('/checkout');
// ...
});For server-side calls, environment variables that point to mock services work better than browser route interception:
// .env.test
STRIPE_API_URL=http://localhost:12111 # stripe-mock running locally
SENDGRID_API_URL=http://localhost:8025 # MailHog or Mailpit
SLACK_WEBHOOK_URL=http://localhost:4040 # ngrok or similarUsing WireMock or similar HTTP mock servers
For complex scenarios with multiple external dependencies, a dedicated HTTP mock server gives you more control:
// fixtures/wiremock.fixture.ts
export const test = base.extend({
wireMock: async ({}, use) => {
// WireMock running as a test dependency
const wireMock = new WireMockClient('http://localhost:8080');
// Clear all stubs before the test
await wireMock.resetAll();
await use(wireMock);
},
});
// In tests:
test('payment failure shows error message', async ({ page, wireMock }) => {
await wireMock.stubFor({
request: { method: 'POST', url: '/v1/charges' },
response: {
status: 402,
body: JSON.stringify({ error: { code: 'card_declined' } }),
},
});
await page.goto('/checkout');
await page.getByRole('button', { name: 'Pay Now' }).click();
await expect(page.getByText('Your card was declined')).toBeVisible();
});Strategy 6: Storage State Isolation
Browser storage state (cookies, localStorage, sessionStorage) leaks between tests if you're not careful. Playwright's storage state feature manages this properly.
// playwright.config.ts
export default defineConfig({
use: {
// Each test starts with a clean browser context by default
// Don't share storage state between tests unless explicitly needed
},
projects: [
{
name: 'authenticated',
use: {
// Share auth state for this project, but each test gets
// its own browser context (storage state is copied, not shared)
storageState: 'auth/user.json',
},
},
{
name: 'admin',
use: {
storageState: 'auth/admin.json',
},
},
],
});For tests that need different auth states within the same file, use browser contexts:
test('admin sees delete button, user does not', async ({ browser }) => {
// Admin context
const adminContext = await browser.newContext({
storageState: 'auth/admin.json',
});
const adminPage = await adminContext.newPage();
await adminPage.goto('/projects/123');
await expect(adminPage.getByRole('button', { name: 'Delete' })).toBeVisible();
// User context (separate, isolated)
const userContext = await browser.newContext({
storageState: 'auth/user.json',
});
const userPage = await userContext.newPage();
await userPage.goto('/projects/123');
await expect(userPage.getByRole('button', { name: 'Delete' })).not.toBeVisible();
await adminContext.close();
await userContext.close();
});Choosing the Right Strategy
| Scenario | Recommended Strategy |
|---|---|
| Multi-tenant SaaS | Tenant-based isolation |
| Single-tenant app, small DB | Database snapshot/restore |
| Large database, slow restore | User-based isolation |
| Complex test data setup | Seed factories via API |
| External payment/email/SMS | Network boundary mocking |
| Auth state variation | Storage state isolation |
Most production test suites combine several of these strategies. A typical setup might use:
- Tenant isolation for data ownership
- Seed factories for test data creation
- Network mocking for Stripe and email
- Storage state isolation for different user roles
Common Isolation Failures
Shared sequences/counters: Auto-incrementing IDs or counters that tests read (e.g., "the next order number will be 1001") break when tests run in parallel. Use random or UUID-based identifiers instead.
Shared file system state: Tests that write to a shared uploads directory, cache, or temp folder will interfere. Use per-test directories or clean up in teardown.
Background jobs: If your app has background job queues, jobs created by one test can execute during another test. Use a separate queue or disable background processing in tests.
Time-dependent behavior: Tests that assert on timestamps or relative times ("created 5 minutes ago") are fragile. Mock the clock or use relative assertions.
Measuring Isolation Quality
A well-isolated test suite has these properties:
- Order independence: Tests pass in any order. Shuffle your test order occasionally to verify.
- Parallel safety: Tests pass when run with maximum parallelism. Try
--workers=maxin CI. - Repeatability: A test that fails once fails consistently. No "works on second run" behavior.
- Clean state: The database/filesystem/cache is the same state before and after each test.
If any of these properties fail, you have an isolation problem. Track "flaky test" rates—flakiness is often a symptom of isolation failures rather than infrastructure issues.
Summary
E2E test isolation doesn't require choosing between "real infrastructure" and "reliable tests." The strategies above let you have both:
- Tenant isolation for the strongest data isolation with parallel safety
- Database snapshots for fast, complete state resets
- User-based isolation when tenant isolation isn't available
- Seed factories for fast, precise test data creation
- Network mocking for external dependency control
- Storage state for auth and browser state isolation
Invest in isolation infrastructure early. A suite with strong isolation stays maintainable as it grows; a suite without it becomes increasingly painful as tests accumulate state.