Global Setup and Teardown

8 min read

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:

  • setup project runs first. Its specs (matched by the .setup.ts filename pattern) run before any project that lists setup in dependencies. Two parallel auth setup specs run simultaneously, producing two storage state files.
  • admin and user projects start authenticated. Each test in those projects loads the corresponding storage state into its BrowserContext, so every test starts with cookies + localStorage already populated.
  • guest runs 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).
  • setup project (with dependencies) — 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:run plugin event → Playwright's globalSetup.
  • Cypress's after:run plugin event → Playwright's globalTeardown.
  • Cypress's cy.session for auth caching → Playwright's setup project + 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 — globalSetup is a single function. The setup project pattern parallelises auth setup by role and integrates with Playwright's project dependencies. Reach for globalSetup only 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, the setup project 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.

  1. 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 });
    });
  2. 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"] }
        }
      ]
    });
  3. Add tests/.auth/ to .gitignore.

  4. 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");
    });
  5. 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();
    });
  6. 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.

  7. 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.

  8. Stretch: add a globalSetup.ts that prints console.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 a globalTeardown.ts that 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.

// tip to track lessons you complete and pick up where you left off across devices.