Per-test fixtures handle most setup. But every real suite has a small set of operations that should happen exactly once per run — seed a database, log in once for everyone, start a backend service, generate a fresh API token, kick off a metrics collector. Doing those per-test wastes minutes; doing them per-worker still duplicates the cost. Playwright's answer is global setup and teardown: scripts that run once at the start of the entire suite and once at the end. This lesson is how to wire them up, the canonical "auth setup project" pattern that's now the recommended way to handle authentication, and how globalSetup/globalTeardown/beforeAll/afterAll differ.
globalSetup — once before everything
A globalSetup is a TypeScript function that runs once before any test, in its own Node process:
// global-setup.ts
import { FullConfig, chromium } from "@playwright/test";
async function globalSetup(config: FullConfig) {
console.log("Global setup — runs once before all tests");
// Reset the test database
await fetch("http://localhost:3001/test-utils/reset-database", { method: "POST" });
// Seed a baseline catalogue of products
await fetch("http://localhost:3001/test-utils/seed-products", {
method: "POST",
body: JSON.stringify({ count: 50 })
});
}
export default globalSetup;Wire it into playwright.config.ts:
import { defineConfig } from "@playwright/test";
export default defineConfig({
globalSetup: require.resolve("./global-setup"),
globalTeardown: require.resolve("./global-teardown"),
// ...
});The setup runs before any test, in any worker, in any project. By the time any worker starts running tests, the database is fresh and the products are seeded.
globalTeardown — once after everything
The mirror: a function that runs after every test in the run has finished, in any project:
// global-teardown.ts
async function globalTeardown() {
console.log("Global teardown — runs once after all tests");
// Stop a service we started
await fetch("http://localhost:3001/test-utils/stop-mock-server", { method: "POST" });
// Generate a custom report
await generateMetricsReport();
}
export default globalTeardown;When to use it:
- Stopping services you started in setup.
- Cleaning up persistent state (deleting rows tagged with the run ID).
- Generating reports beyond what Playwright's reporters produce.
- Notifying external systems that a test run finished (Slack, dashboards).
Don't use it for per-test cleanup — that's what fixture teardown is for. Global teardown is for run-level cleanup only.
Project-based authentication — the canonical pattern
The most-used global-setup pattern doesn't use globalSetup at all. Instead, it uses Playwright's project dependencies to define a "setup project" that runs first and produces a saved storage state:
// tests/auth.setup.ts
import { test as setup } from "@playwright/test";
const adminAuthFile = "tests/.auth/admin.json";
const userAuthFile = "tests/.auth/user.json";
setup("authenticate as admin", async ({ page }) => {
await page.goto("/login");
await page.getByLabel("Email").fill("admin@test.com");
await page.getByLabel("Password").fill("AdminPass");
await page.getByRole("button", { name: "Sign in" }).click();
await page.waitForURL(/admin/);
await page.context().storageState({ path: adminAuthFile });
});
setup("authenticate as user", async ({ page }) => {
await page.goto("/login");
await page.getByLabel("Email").fill("alice@test.com");
await page.getByLabel("Password").fill("UserPass");
await page.getByRole("button", { name: "Sign in" }).click();
await page.waitForURL(/dashboard/);
await page.context().storageState({ path: userAuthFile });
});// playwright.config.ts
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
projects: [
{
name: "setup",
testMatch: /.*\.setup\.ts/
},
{
name: "admin",
testDir: "./tests/admin",
use: { ...devices["Desktop Chrome"], storageState: "tests/.auth/admin.json" },
dependencies: ["setup"]
},
{
name: "user",
testDir: "./tests/user",
use: { ...devices["Desktop Chrome"], storageState: "tests/.auth/user.json" },
dependencies: ["setup"]
},
{
name: "guest",
testDir: "./tests/guest"
// No storageState — runs unauthenticated
}
]
});What this gives you:
setupproject runs first. Its specs (matched by the.setup.tsfilename pattern) run before any project that listssetupindependencies. Two parallel auth setup specs run simultaneously, producing two storage state files.adminanduserprojects start authenticated. Each test in those projects loads the corresponding storage state into its BrowserContext, so every test starts with cookies + localStorage already populated.guestruns unauthenticated. Tests for the logged-out experience (login form, signup flow) live there.
This pattern is now the Playwright-recommended approach to authentication — it's faster than logging in per-test, more granular than globalSetup, and it parallelises beautifully (each project shards independently).
Don't forget .gitignore
Storage state files contain real cookies. Add to your repo's .gitignore:
tests/.auth/
Setup specs regenerate the state on every CI run; never commit .json files that contain auth tokens.
API-based auth — even faster
For complex apps, even the UI-based auth setup is slow. If the backend exposes a login API, skip the UI entirely:
// tests/auth.setup.ts
import { test as setup, request as apiRequest } from "@playwright/test";
setup("authenticate via API", async ({ playwright }) => {
const apiContext = await playwright.request.newContext();
const loginRes = await apiContext.post("http://localhost:3000/api/login", {
data: { email: "alice@test.com", password: "pw123" }
});
// Persist the resulting cookies + tokens
await apiContext.storageState({ path: "tests/.auth/user.json" });
await apiContext.dispose();
});No browser launches — the entire auth setup takes under 100ms. The storage state file is functionally equivalent to the UI-based version. Use this whenever the API is available.
test.beforeAll / test.afterAll — file-scoped, not global
Don't confuse globalSetup with test.beforeAll. They have different scopes:
// File-scoped — runs once per test FILE, in each worker that handles that file
test.beforeAll(async ({ browser }) => {
// Runs once per file (or describe block)
});
// Test-scoped — runs before EVERY test
test.beforeEach(async ({ page }) => {
// Runs before every test in the suite
});
// File-level cleanup
test.afterAll(async () => {
// Runs after the last test in the file
});Quick rule:
globalSetup— once per entire run (all projects, all workers).setupproject (withdependencies) — once per project run (parallel-safe, produces artefacts).test.beforeAll— once per test file, in whichever worker runs that file.test.beforeEach— before every test.
Reach for the lowest-cost level that satisfies the requirement.
The full lifecycle, visualised
Step 1 of 6
globalSetup runs
Once per run, in its own Node process. Reset database, seed shared data, start mock services. Sets the baseline every test depends on.
A complete config — multi-role e-commerce suite
Putting every pattern together:
// playwright.config.ts
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./tests",
fullyParallel: true,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: [["html"], ["list"]],
globalSetup: require.resolve("./global-setup"),
globalTeardown: require.resolve("./global-teardown"),
use: {
baseURL: process.env.BASE_URL || "http://localhost:3000",
trace: "on-first-retry",
screenshot: "only-on-failure"
},
projects: [
// Step 1: auth setup
{ name: "setup", testMatch: /.*\.setup\.ts/ },
// Step 2: tests for each role, all dependent on setup
{
name: "admin",
testDir: "./tests/admin",
use: { ...devices["Desktop Chrome"], storageState: "tests/.auth/admin.json" },
dependencies: ["setup"]
},
{
name: "user",
testDir: "./tests/user",
use: { ...devices["Desktop Chrome"], storageState: "tests/.auth/user.json" },
dependencies: ["setup"]
},
// Step 3: unauthenticated paths in parallel
{
name: "guest",
testDir: "./tests/guest",
use: { ...devices["Desktop Chrome"] }
},
// Step 4: cross-browser regression on the user surface
{
name: "user-firefox",
testDir: "./tests/user",
use: { ...devices["Desktop Firefox"], storageState: "tests/.auth/user.json" },
dependencies: ["setup"]
},
{
name: "user-webkit",
testDir: "./tests/user",
use: { ...devices["Desktop Safari"], storageState: "tests/.auth/user.json" },
dependencies: ["setup"]
}
]
});globalSetup resets the DB. The setup project produces storage state files. Five test projects run in parallel — admin, user, guest, plus user-Firefox and user-WebKit for cross-browser. globalTeardown cleans up at the end. The whole suite of (say) 200 tests across two roles and three browsers runs in 3-5 minutes on CI instead of 15-20.
Coming from Cypress?
The mappings:
- Cypress's
before:runplugin event → Playwright'sglobalSetup. - Cypress's
after:runplugin event → Playwright'sglobalTeardown. - Cypress's
cy.sessionfor auth caching → Playwright'ssetupproject +storageState. - Cypress doesn't have a project-based dependency graph; you'd implement one with custom plugin code. Playwright's
dependencies: ['setup']makes the same intent first-class.
If your Cypress suite has a before:run hook that seeds a database and a per-test cy.session() for auth, the migration shape is: globalSetup for the DB seed, setup project for the auth, storageState per project. Same intent, less plumbing.
⚠️ Common mistakes
- Putting auth logic in
globalSetup. It works, but it doesn't parallelise —globalSetupis a single function. Thesetupproject pattern parallelises auth setup by role and integrates with Playwright's project dependencies. Reach forglobalSetuponly when the work can't be expressed as a setup spec (e.g., starting a Docker container). - Committing
tests/.auth/*.json. Auth state files contain session tokens. If you push them, you've leaked credentials to anyone who can clone the repo. Always gitignore the auth folder; the setup spec regenerates the files on every CI run. - Forgetting
dependencies: ['setup']on the test projects. Without it, thesetupproject still runs (because it's listed), but the test projects don't wait for it. They might start before the storage state file exists, and load a missing or stale file. The dependency arrow is what enforces the order.
🎯 Practice task
Build a multi-project config with auth setup. 30-40 minutes.
-
Create the auth setup spec at
tests/auth.setup.ts:import { test as setup } from "@playwright/test"; const standardUserAuth = "tests/.auth/standard-user.json"; setup("authenticate as standard_user", async ({ page }) => { await page.goto("https://www.saucedemo.com"); await page.getByPlaceholder("Username").fill("standard_user"); await page.getByPlaceholder("Password").fill("secret_sauce"); await page.getByRole("button", { name: "Login" }).click(); await page.waitForURL(/inventory/); await page.context().storageState({ path: standardUserAuth }); }); -
Update
playwright.config.ts:import { defineConfig, devices } from "@playwright/test"; export default defineConfig({ testDir: "./tests", use: { baseURL: "https://www.saucedemo.com", trace: "on-first-retry" }, projects: [ { name: "setup", testMatch: /.*\.setup\.ts/ }, { name: "authenticated", testDir: "./tests/authenticated", use: { ...devices["Desktop Chrome"], storageState: "tests/.auth/standard-user.json" }, dependencies: ["setup"] }, { name: "guest", testDir: "./tests/guest", use: { ...devices["Desktop Chrome"] } } ] }); -
Add
tests/.auth/to.gitignore. -
Create
tests/authenticated/inventory.spec.ts:import { test, expect } from "@playwright/test"; test("inventory page loads — already authenticated", async ({ page }) => { await page.goto("/inventory.html"); await expect(page.locator(".inventory_item")).toHaveCount(6); }); test("can add items to cart", async ({ page }) => { await page.goto("/inventory.html"); await page.locator(".inventory_item").first().getByRole("button", { name: "Add to cart" }).click(); await expect(page.locator(".shopping_cart_badge")).toHaveText("1"); }); -
Create
tests/guest/login.spec.ts:import { test, expect } from "@playwright/test"; test("login page renders for unauthenticated users", async ({ page }) => { await page.goto("/"); await expect(page.getByPlaceholder("Username")).toBeVisible(); await expect(page.getByPlaceholder("Password")).toBeVisible(); }); -
Run
npx playwright test. Watch the order: setup runs first, then authenticated and guest projects run in parallel. Both authenticated tests start already on the inventory page — no per-test login. -
Delete the auth file (
rm tests/.auth/standard-user.json) and run again. The setup project regenerates it. This is what happens on every CI run. -
Stretch: add a
globalSetup.tsthat printsconsole.log('Global setup ran at', new Date().toISOString()). Run with--workers=4. Confirm the message prints exactly once, even though four workers ran. Then add aglobalTeardown.tsthat prints similarly. Confirm both run exactly once.
That closes Chapter 5 — fixtures, hooks, and data management. You now have every primitive a real QA team uses to structure a serious Playwright suite. The next chapter is advanced patterns: the page object model, multi-browser orchestration, mobile emulation, the storage-state authentication flow you just touched, and testing web components with Shadow DOM. The fixtures and projects you've built here are the foundation everything else builds on top of.