Introduction
By Day 88, you've built solid Playwright test suites that handle individual scenarios well. But as your test portfolio grows across multiple projects and environments, you'll face a critical question: how do you set up and tear down state that spans your entire test run without bottlenecking CI pipelines or introducing flaky dependencies between tests? Global setup and teardown: practical tips and trade-offs is the framework that separates teams running efficient, maintainable test suites from those wrestling with slow builds and shared-state bugs.
Today we'll explore when to use global setup (and when not to), how to seed databases effectively, manage teardown without leaving behind debris, and keep your initialization fast enough that it doesn't become the reason developers skip running tests locally.
Setup Projects — Preferred Over globalSetup for Traces and Fixtures
Playwright offers two paths for test initialization: globalSetup hooks and setup projects. While globalSetup seems simpler at first, setup projects provide better observability, better failure diagnostics, and integrate cleanly with Playwright's reporting and trace collection.
The key difference: Setup projects run as first-class test files within Playwright's test runner. They generate traces, screenshots, and logs you can review. Global setup runs outside the test framework and offers no built-in reporting.
Consider a scenario where your test suite needs to create an admin API token before any test runs. With a setup project, you get a dedicated test report entry showing exactly when and how token creation happened. If it fails, you see the full trace. With globalSetup, you see a cryptic error or silent failure.
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
projects: [
{
name: 'setup',
testMatch: /.*\.setup\.ts/,
fullyParallel: false, // Run setup serially
},
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
dependencies: ['setup'], // Wait for setup to complete
},
],
});
// tests/auth.setup.ts
import { test as setup } from '@playwright/test';
setup('create admin token', async ({ request }) => {
const response = await request.post('/api/auth/token', {
data: { username: 'admin', password: process.env.ADMIN_PASSWORD },
});
const { token } = await response.json();
process.env.API_TOKEN = token;
});
// tests/dashboard.spec.ts
import { test, expect } from '@playwright/test';
test('load dashboard with auth token', async ({ request, page }) => {
await page.setExtraHTTPHeaders({
Authorization: `Bearer ${process.env.API_TOKEN}`,
});
await page.goto('/dashboard');
await expect(page.locator('[data-testid="user-profile"]')).toBeVisible();
});
Setup projects also compose naturally with fixtures. You can inject setup state into your test context without global variables, making tests more portable and easier to debug.
Seed Databases — Create Baseline Data Before Tests
Most realistic test scenarios require a known starting state: users, products, orders, or configurations. Seeding happens before tests run and should be idempotent—running it twice produces the same result as running it once.
The pattern we've found most reliable is to connect directly to the test database and execute seed scripts. Avoid seeding through the API when possible; direct database access is faster and doesn't depend on API behavior you might be testing.
// tests/seed.setup.ts
import { test as setup } from '@playwright/test';
import { Client } from 'pg';
setup('seed test database', async () => {
const client = new Client({
connectionString: process.env.TEST_DATABASE_URL,
});
await client.connect();
try {
// Clear existing data
await client.query('TRUNCATE users, products, orders CASCADE');
// Insert baseline users
await client.query(
`INSERT INTO users (id, email, name, role) VALUES
($1, $2, $3, $4),
($5, $6, $7, $8)`,
[
'1',
'admin@test.local',
'Admin User',
'admin',
'2',
'customer@test.local',
'Customer User',
'customer',
]
);
// Insert baseline products
await client.query(
`INSERT INTO products (id, name, price, inventory) VALUES
($1, $2, $3, $4),
($5, $6, $7, $8)`,
[
'101',
'Widget A',
2999,
100,
'102',
'Widget B',
4999,
50,
]
);
console.log('Database seeded successfully');
} finally {
await client.end();
}
});
Idempotency is essential. If your CI reruns a test and setup runs again, your data shouldn't corrupt or conflict. Use TRUNCATE CASCADE to clear tables in dependency order, then insert fresh seed data. Avoid DELETE without truncating sequences; you'll end up with ID collisions.
Store seed data in version control. A JSON or CSV file in your repo makes it easy for new team members to understand what test data looks like, and you can review seed changes in pull requests.
// seeds/users.json
[
{ id: 1, email: 'admin@test.local', name: 'Admin User', role: 'admin' },
{ id: 2, email: 'customer@test.local', name: 'Customer User', role: 'customer' }
]
For larger datasets, generate seed data programmatically. Our teams often use a factory library to create consistent test fixtures.
Teardown — Clean Up After the Run
Teardown runs after all tests complete. Its job is to leave your environment in a predictable state for the next run and to capture diagnostics if needed.
In most cases, teardown is simpler than setup. For in-memory or ephemeral test databases, you often don't need explicit teardown—containers or test databases are destroyed automatically. But for shared environments or long-running services, teardown prevents test pollution.
// tests/cleanup.teardown.ts
import { test as teardown } from '@playwright/test';
import { Client } from 'pg';
teardown('cleanup test data', async () => {
const client = new Client({
connectionString: process.env.TEST_DATABASE_URL,
});
await client.connect();
try {
// Clear all test data
await client.query('TRUNCATE users, products, orders CASCADE');
console.log('Test data cleaned up');
} catch (error) {
console.error('Cleanup failed:', error);
// Don't throw; let the run complete even if cleanup fails
} finally {
await client.end();
}
});
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
projects: [
{
name: 'setup',
testMatch: /.*\.setup\.ts/,
fullyParallel: false,
},
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
dependencies: ['setup'],
},
{
name: 'teardown',
testMatch: /.*\.teardown\.ts/,
fullyParallel: false,
// No explicit dependency; runs after all other projects
},
],
});
Key principle: teardown should never fail the test run. If cleanup fails, log it and continue. The tests passed; teardown is hygiene, not correctness.
Capture logs and snapshots during teardown for debugging. If tests leave behind orphaned records, your teardown logs should show what was cleaned up—valuable for postmortems.
Keep It Fast — Heavy Setup Slows Every CI Run
Every second of setup time multiplies across your entire CI pipeline. If setup takes 30 seconds and you run it 10 times per day across different branches, that's 5 minutes of developer time spent waiting. Scale that across a team and the inefficiency becomes real.
Optimize for speed:
- Parallelize setup where safe. Database seeding and token creation usually don't depend on each other. Run them concurrently if your test database supports it.
- Avoid heavy computations. If setup involves cryptographic operations, image processing, or external API calls, cache the result or mock them for tests.
- Use lighter databases for CI. SQLite is faster than PostgreSQL for small test datasets. Consider it for local and CI runs, reserve production databases for staging tests.
- Lazy-load data. Don't seed every possible record upfront. Create what tests need on demand using fixtures or factory functions.
Example: instead of seeding 1000 users before tests run, create users in individual tests or per-suite setup hooks.
// tests/helpers/factories.ts
import { Client } from 'pg';
export async function createUser(client: Client, overrides = {}) {
const defaults = {
email: `user-${Date.now()}@test.local`,
name: 'Test User',
role: 'customer',
};
const user = { ...defaults, ...overrides };
const result = await client.query(
'INSERT INTO users (email, name, role) VALUES ($1, $2, $3) RETURNING id',
[user.email, user.name, user.role]
);
return { id: result.rows[0].id, ...user };
}
// tests/order.spec.ts
import { test, expect } from '@playwright/test';
import { Client } from 'pg';
import { createUser } from './helpers/factories';
test('create order for customer', async () => {
const client = new Client({
connectionString: process.env.TEST_DATABASE_URL,
});
await client.connect();
try {
const user = await createUser(client, { role: 'customer' });
// Test logic using user
} finally {
await client.end();
}
});
Monitor setup time. Add logging to track how long initialization takes, and set performance budgets—if setup exceeds 60 seconds, it's worth investigating.
Common Mistakes
- Relying on globalSetup for everything. Use setup projects instead; you'll get better diagnostics and trace collection.
- Sharing mutable state between tests via setup. If setup creates objects and tests modify them, later tests get unexpected behavior. Use immutable seed data or fixtures.
- Making teardown mandatory for correctness. If tests fail because teardown didn't run, your test design is fragile. Isolation should come from setup and test data, not cleanup.
- Seeding through the application layer. Calling your API to create test data is slow, unreliable, and couples tests to API behavior. Direct database access is faster and safer.
- Waiting for setup to finish before any test can run. If you have independent test suites, separate their setup. Setup for checkout tests doesn't need to block dashboard tests.
Key Takeaways
- Prefer setup projects over globalSetup for better traceability, reporting, and failure diagnostics.
- Seed databases directly before tests run; avoid seeding through the API when possible.
- Make seeding idempotent using
TRUNCATE CASCADEand store seed data in version control. - Keep teardown lightweight and non-blocking; don't let cleanup failures fail your test run.
- Parallelize setup tasks, cache expensive computations, and monitor initialization time to keep CI fast.
- Use fixtures and factories to create test data on demand rather than seeding massive datasets upfront.






Comments
Loading comments…