Global Setup and Teardown in Playwright: Seeding and Health Checks
If every test file in your suite re-seeds the database, re-logs in, and prays the backend is awake, you are paying that cost hundreds of times per run. Playwright global setup teardown lets you do expensive, environment-wide work exactly once before any test starts, and tear it down cleanly after the last test finishes. In this guide you will learn how to seed a database, run a pre-flight health check that fails fast, share an authenticated session across projects, and clean up reliably even when tests crash.
🎠Want to master this with real projects? Join the Playwright Automation Mastery course at The Testing Academy.
Contents
Why You Need Global Setup and Teardown
Playwright already gives you per-file and per-test hooks like beforeAll, beforeEach, and fixtures. Those are perfect for work scoped to a single spec. But some work is genuinely process-wide: confirming the API is reachable, applying database migrations, seeding reference data, or generating an auth token that every test reuses. Repeating that in every file is slow and flaky. Global setup runs in a separate Node.js context before the worker processes spawn, so it is the natural home for one-time, cross-cutting preparation.
There are two ways to wire this up. The classic approach uses the globalSetup and globalTeardown keys in playwright.config.ts. The modern approach uses setup projects with dependencies, which gives you trace, retries, and full fixtures inside your setup code. We will cover both, and explain when each one wins.
The Classic Approach: globalSetup and globalTeardown
The simplest form points your config at two files. Each is a Node module that default-exports an async function. The setup function receives the resolved FullConfig, and anything it returns is ignored, so it communicates with tests through side effects: environment variables, files on disk, or a seeded backend.
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
globalSetup: require.resolve('./global-setup'),
globalTeardown: require.resolve('./global-teardown'),
use: {
baseURL: process.env.BASE_URL ?? 'http://localhost:3000',
trace: 'on-first-retry',
},
});
Now the setup file itself. Below it performs a health check and seeds data, then stashes a value other tests can read. Note the signature: a function that takes FullConfig and returns a Promise.
// global-setup.ts
import type { FullConfig } from '@playwright/test';
import { request } from '@playwright/test';
async function globalSetup(config: FullConfig) {
const baseURL =
config.projects[0].use.baseURL ?? 'http://localhost:3000';
// 1) Health check: fail the whole run fast if the app is down.
const api = await request.newContext({ baseURL });
const health = await api.get('/api/health');
if (!health.ok()) {
throw new Error(
`Health check failed: ${health.status()} from ${baseURL}/api/health`,
);
}
// 2) Seed reference data once for the entire suite.
const seed = await api.post('/api/test/seed', {
data: { users: 5, products: 20 },
});
if (!seed.ok()) {
throw new Error(`Seeding failed: ${seed.status()}`);
}
// 3) Share state with tests via an env var (or write a file).
const body = await seed.json();
process.env.SEED_RUN_ID = body.runId;
await api.dispose();
}
export default globalSetup;
The matching teardown reads the same value and cleans up. Playwright runs globalTeardown after all tests complete, even if some failed, which makes it the right place to drop seeded rows or revoke tokens.
// global-teardown.ts
import { request } from '@playwright/test';
async function globalTeardown() {
const runId = process.env.SEED_RUN_ID;
if (!runId) return;
const api = await request.newContext({
baseURL: process.env.BASE_URL ?? 'http://localhost:3000',
});
await api.delete(`/api/test/seed/${runId}`);
await api.dispose();
}
export default globalTeardown;
A subtle but important detail: environment variables set in global setup are visible to teardown because they share the same Node process, but they are not automatically injected into the test worker processes in every runner version. For data you must read inside tests, prefer writing a JSON file to disk (for example .auth/state.json) and reading it in a fixture, which is robust across workers.
The Modern Approach: Setup Projects with Dependencies
The classic globalSetup file runs outside the test runner, so you do not get fixtures, retries, traces, or HTML-report visibility for that code. Playwright’s recommended pattern today is a dedicated setup project wired in as a dependency. Your setup logic becomes a normal test, with full access to page, expect, and tracing, and the report shows it as a step.
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
projects: [
{
name: 'setup',
testMatch: /global\.setup\.ts/,
},
{
name: 'chromium',
use: { browserName: 'chromium' },
dependencies: ['setup'],
teardown: 'cleanup',
},
{
name: 'cleanup',
testMatch: /global\.teardown\.ts/,
},
],
});
Here the chromium project depends on setup, so the setup tests always run first. The teardown key points the runner at a cleanup project that executes after chromium finishes. Because these are real tests, you write them with the standard API.
// global.setup.ts
import { test as setup, expect, request } from '@playwright/test';
setup('verify backend is healthy', async ({ baseURL }) => {
const api = await request.newContext({ baseURL });
const res = await api.get('/api/health');
expect(res.ok()).toBeTruthy();
await api.dispose();
});
setup('seed test data', async ({ baseURL }) => {
const api = await request.newContext({ baseURL });
const res = await api.post('/api/test/seed', {
data: { users: 5, products: 20 },
});
expect(res.ok()).toBeTruthy();
await api.dispose();
});
The setup-project style is the one to reach for in most new suites. It composes cleanly with authentication, which is the next pattern we will build.
Authentication: Log In Once, Reuse Everywhere
The single most common use of setup is authenticating one time and reusing the session. You log in inside the setup project, save the browser’s cookies and local storage with context.storageState(), then point every test project at that file through use.storageState. No more logging in per test.
// auth.setup.ts
import { test as setup, expect } from '@playwright/test';
import path from 'node:path';
const authFile = path.join(__dirname, '.auth/user.json');
setup('authenticate', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('qa@example.com');
await page.getByLabel('Password').fill('Sup3rSecret!');
await page.getByRole('button', { name: 'Sign in' }).click();
// Wait for a signal the login actually succeeded.
await expect(page.getByRole('heading', { name: 'Dashboard' }))
.toBeVisible();
// Persist cookies + localStorage to disk for reuse.
await page.context().storageState({ path: authFile });
});
Wire the saved state into the config so every authenticated project starts already logged in. Keep a separate unauthenticated project for login or signup tests that must begin from a clean slate.
// playwright.config.ts (excerpt)
import { defineConfig } from '@playwright/test';
import path from 'node:path';
export default defineConfig({
projects: [
{ name: 'setup', testMatch: /auth\.setup\.ts/ },
{
name: 'authenticated',
use: { storageState: path.join(__dirname, '.auth/user.json') },
dependencies: ['setup'],
testIgnore: /.*\.unauth\.spec\.ts/,
},
{
name: 'guest',
use: { storageState: { cookies: [], origins: [] } },
testMatch: /.*\.unauth\.spec\.ts/,
},
],
});
Always add .auth/ to your .gitignore. The state file contains live session tokens and must never be committed.
Robust Health Checks That Fail Fast
A health check is only useful if it waits sensibly for a service that is still warming up, then gives up with a clear error. Polling with a deadline beats a single request. The helper below retries the endpoint until it is ready or a timeout elapses, so a slow-to-boot container does not produce dozens of misleading test failures.
// global.setup.ts
import { test as setup, expect, request } from '@playwright/test';
async function waitForHealthy(
baseURL: string,
timeoutMs = 30_000,
): Promise<void> {
const api = await request.newContext({ baseURL });
const deadline = Date.now() + timeoutMs;
let lastStatus = 0;
while (Date.now() < deadline) {
try {
const res = await api.get('/api/health');
lastStatus = res.status();
if (res.ok()) {
await api.dispose();
return;
}
} catch {
// Connection refused while booting; retry below.
}
await new Promise((r) => setTimeout(r, 1_000));
}
await api.dispose();
throw new Error(
`Service at ${baseURL} not healthy in ${timeoutMs}ms ` +
`(last status ${lastStatus}).`,
);
}
setup('app is ready', async ({ baseURL }) => {
await waitForHealthy(baseURL!);
});
If your only goal is to start a dev server and wait for a URL, you often do not need a custom health check at all. The webServer option in the config launches your app and blocks tests until the URL responds, which covers many local and CI scenarios.
// playwright.config.ts (excerpt)
export default defineConfig({
webServer: {
command: 'npm run start',
url: 'http://localhost:3000/api/health',
timeout: 120_000,
reuseExistingServer: !process.env.CI,
},
});
globalSetup vs Setup Projects: Which to Use
Both mechanisms are valid and can even coexist. The table below summarizes the practical trade-offs so you can pick deliberately rather than by habit.
| Capability | globalSetup / globalTeardown | Setup & teardown projects |
|---|---|---|
| Runs before workers spawn | Yes (separate Node context) | Yes (as first project) |
| Access to fixtures (page, context) | No (manual browser launch) | Yes (full fixtures) |
| Traces & HTML report visibility | No | Yes |
| Retries on failure | No | Yes |
| Per-project dependencies | No (one global hook) | Yes (granular) |
| Best for | Pure Node work: migrations, env vars | Auth, seeding via UI/API, health checks |
Rule of thumb: if the work needs a browser, an authenticated page, or report visibility, use a setup project. If it is pure Node logic that must run before anything else, such as applying schema migrations through a database client, globalSetup is leaner.
🚀 Level Up Your Playwright
From locators to CI pipelines — build a production-grade Playwright + TypeScript framework step by step.
Making Teardown Bulletproof
Teardown only helps if it runs even when setup or tests blow up. With the classic approach, wrap risky steps so a partial failure still triggers cleanup, and guard against missing state. With setup projects, the teardown key already runs after the dependent project regardless of pass or fail. A few habits keep things clean:
- Make cleanup idempotent: deleting an already-deleted seed run should be a no-op, not an error.
- Tag seeded data with a unique run ID so parallel CI jobs never delete each other’s records.
- Always call
dispose()on request contexts and close any DB connections to avoid hanging the process. - Prefer transactional or schema-per-run isolation over global truncation when other suites share the database.
// global-teardown.ts (defensive version)
import { request } from '@playwright/test';
async function globalTeardown() {
const runId = process.env.SEED_RUN_ID;
if (!runId) return; // Setup never seeded; nothing to undo.
const api = await request.newContext({
baseURL: process.env.BASE_URL ?? 'http://localhost:3000',
});
try {
const res = await api.delete(`/api/test/seed/${runId}`);
if (!res.ok() && res.status() !== 404) {
console.warn(`Cleanup returned ${res.status()} for ${runId}`);
}
} finally {
await api.dispose(); // Runs even if delete throws.
}
}
export default globalTeardown;
Putting It All Together
A production-grade configuration usually layers all of these: a webServer to boot the app, a setup project that runs a health check and authenticates, authenticated test projects that consume the saved storage state, and a teardown project that removes seeded data. Mastering Playwright global setup teardown turns a slow, brittle suite into one that prepares its environment once, runs fast in parallel, and leaves no mess behind. Start with a setup project for auth and health, add seeding when your tests need shared data, and reserve the classic globalSetup hook for the rare pure-Node task that has to happen before everything else.
FAQ
What is the difference between globalSetup and a setup project in Playwright?
globalSetup is a single function that runs in a separate Node.js context before any worker spawns, with no access to fixtures, traces, or retries. A setup project is an ordinary test file wired in via dependencies, so it has full fixtures like page, shows up in the HTML report, and supports retries. Use a setup project for auth, seeding, and health checks; reserve globalSetup for pure-Node work such as database migrations.
The most reliable way across parallel workers is to write the data to a file on disk during setup, for example a JSON file under .auth/, then read it in a fixture or in the test. Setting process.env values works for communicating between globalSetup and globalTeardown in the same process, but it is not guaranteed to reach every worker process, so prefer a file or saved storageState for values tests must consume.
Does global teardown still run if my tests fail?
Yes. Both globalTeardown and a teardown project run after the suite completes, including when tests fail. To be safe, make teardown idempotent and wrap cleanup in try/finally so resources like request contexts and database connections are always disposed. Tag seeded records with a unique run ID so cleanup never deletes data from a parallel CI job.
🎓 Master Playwright End to End
Join hundreds of SDETs building real automation frameworks. Lifetime access, hands-on projects, and a job-ready portfolio.
