Playwright Global Setup and Teardown with TypeScript
Day 58 of the Playwright + TypeScript series. Playwright global setup is the piece most teams bolt on after their suite gets slow. They log in on every single test, seed the same rows a hundred times, and then wonder why a 200-test suite takes 14 minutes. globalSetup and globalTeardown fix exactly this: they run once per run, before and after your tests, so expensive one-time work happens exactly once.
Table of Contents
- What Is Playwright Global Setup?
- Why Global Setup Matters: Auth, DB Seeding, and One-Time Work
- Wiring globalSetup Into playwright.config.ts
- Login Once, Save the Storage State
- Seeding a Database Before the Run
- globalTeardown: Cleanup After the Run
- Passing Data From globalSetup to Your Tests
- globalSetup vs Test Fixtures vs beforeAll
- Playwright Global Setup Pitfalls
- The India SDET Interview Angle
- Key Takeaways
- FAQ
Contents
What Is Playwright Global Setup?
In Playwright, globalSetup is a function you point to from your config. It runs a single time in a Node.js process before any test file starts, and its sibling globalTeardown runs a single time after the last test finishes. They are not tests. They cannot use your page objects or fixtures directly. What they can do is anything a plain Node script can do: launch a browser manually, hit an API, talk to a database, or write a file to disk.
I describe it to my team like this: beforeAll runs once per worker file, beforeEach runs once per test, and globalSetup runs once per entire run. The distinction matters because most people reach for beforeAll when they actually want globalSetup, and then they pay for it with duplicated login calls and repeated database resets.
The most common real-world uses are:
- Log in once and save a
storageStateJSON so every test starts already authenticated. - Seed or reset a test database to a known state before the suite.
- Fetch a one-time auth token or API key that tests then consume.
- Build or start shared fixtures, like compiling test assets or generating a large data file.
Because globalSetup is plain Node and not a browser test, anything you can script, you can run there: shell commands, HTTP calls, database migrations, even spawning a child process. That freedom is what makes it the right home for work that has nothing to do with a single page.
Why Global Setup Matters: Auth, DB Seeding, and One-Time Work
The payoff is measurable. If your login takes 3 seconds and you do it in a beforeEach across 200 tests, that is 600 seconds, roughly 10 minutes, of pure login before a single assertion runs. Move it into globalSetup and you do it once. I have cut regression suites from 47 minutes to 9 minutes on projects where login and data seeding were the bottleneck, and the change was not a new framework. It was moving one-time work out of the hot path.
None of this is a niche trick, either. Playwright has crossed 94,000 stars on GitHub, and the @playwright/test package now logs over 210 million downloads a month on the npm registry. The framework patterns around it, global setup included, are exactly what hiring managers and senior QA leads expect you to know.
There are three places this matters most in a real QA suite:
1. Authentication. Instead of typing credentials in every test, globalSetup signs in once, captures cookies and localStorage, and saves them to a storageState file. Every test then loads that file and starts logged in. This is the same storageState mechanism I covered in the Playwright Authentication (Day 10) article, but moved from per-test to per-run.
2. Database seeding. Integration tests need known rows: a user with an existing order, a locked account, a product with zero stock. Running INSERT statements in beforeEach works but is slow and leaves state drifting between tests. Seeding once in globalSetup gives you a clean, deterministic baseline.
3. One-time tokens and assets. If your app needs a JWT from an identity provider, or your visual tests need a built fixture bundle, doing that per test is wasteful. Do it once.
The trade-off is that globalSetup makes your suite less isolated. If one test mutates shared data, other tests can see it. I come back to this in the pitfalls section, because it is the number one reason people move away from global setup after adopting it naively.
Wiring globalSetup Into playwright.config.ts
You register both hooks in the config. The key detail most people miss is require.resolve(). Playwright resolves the path relative to the config file, and wrapping it in require.resolve() avoids “cannot find module” errors when your project has a non-standard root.
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
globalSetup: require.resolve('./global-setup'),
globalTeardown: require.resolve('./global-teardown'),
use: {
baseURL: 'https://app.example.com',
storageState: 'playwright/.auth/user.json',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
],
});
Notice the storageState path in the top-level use block. Your globalSetup writes to that exact same path. If the two paths drift, the setup runs and saves a file, but your tests never load it, and every test starts logged out. This is a silent failure, which is why I make the path a single constant both places read from.
If your tests need a running backend, add a webServer entry too. Playwright boots the webServer before globalSetup runs, so your setup code can hit a live URL. That ordering is useful when your login step depends on the app being up.
Login Once, Save the Storage State
This is the most common globalSetup on the planet. Here is the full TypeScript version: launch a browser, navigate, log in, and save the session.
// global-setup.ts
import { chromium, FullConfig } from '@playwright/test';
async function globalSetup(config: FullConfig) {
const { baseURL, storageState } = config.projects[0].use;
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto(baseURL!);
await page.getByLabel('Email').fill(process.env.E2E_USER!);
await page.getByLabel('Password').fill(process.env.E2E_PASSWORD!);
await page.getByRole('button', { name: 'Sign in' }).click();
await page.waitForURL('**/dashboard');
await page.context().storageState({ path: storageState as string });
await browser.close();
}
export default globalSetup;
Three things to note. First, the credentials come from process.env, never hardcoded, because this file often runs in CI. Second, I wait for a known URL after login so the session is fully settled before I capture it, otherwise you save a half-finished cookie jar. Third, I read baseURL and storageState off the config so the setup and the test runner can never disagree.
On the test side, nothing special is required. Because the config declares use.storageState, every test starts authenticated. If you have multiple roles, you create multiple storage states, either by running several logins inside one globalSetup or by pointing different projects at different state files. The Playwright auth guide documents this multi-role pattern in detail.
One more thing: storage states expire. A saved cookie jar from last week is useless once the token hits its 24-hour TTL, which is exactly why the login lives in globalSetup and runs fresh on every CI run instead of being committed to the repo. If you commit a state file and reuse it for days, you will eventually spend an hour debugging tests that fail only because the session silently went stale.
Seeding a Database Before the Run
Authentication is only half the story. For integration suites, you also need known data. Here is a globalSetup that resets and seeds a Postgres database using the pg driver.
// global-setup.ts (database seed)
import { Client } from 'pg';
async function seedDatabase() {
const client = new Client({ connectionString: process.env.TEST_DATABASE_URL });
await client.connect();
await client.query('TRUNCATE users, orders RESTART IDENTITY CASCADE;');
await client.query(`
INSERT INTO users (id, email, role) VALUES
(1, 'qa@example.com', 'admin'),
(2, 'basic@example.com', 'member');
`);
await client.query(`
INSERT INTO orders (id, user_id, total, status) VALUES
(100, 1, 250.00, 'paid'),
(101, 2, 19.99, 'pending');
`);
await client.end();
}
You call seedDatabase() from your main globalSetup function, or export it separately and chain it. The TRUNCATE ... RESTART IDENTITY CASCADE line is the important part: it wipes the tables and resets auto-increment IDs so your tests can rely on id = 1 being the admin every single run. Deterministic IDs are what make assertions like expect(page.getByText('qa@example.com')).toBeVisible() stable.
One warning: seeding belongs in globalSetup only if your tests are read-only against that data. If tests mutate the seeded rows, you get ordering problems, because one test’s changes leak into the next. For mutation-heavy suites, prefer per-test fixtures with transactions instead, which I contrast in a moment.
globalTeardown: Cleanup After the Run
Whatever you stand up in globalSetup, you should tear down in globalTeardown. It runs once after the last test, even if some tests failed. Common teardown jobs:
- Revoke the auth token you minted so it cannot be reused.
- Drop the test database or clean up seeded rows in shared environments.
- Aggregate test artifacts, like merging coverage or copying trace files.
- Stop any temporary services you started outside
webServer.
// global-teardown.ts
import { FullConfig } from '@playwright/test';
import { Client } from 'pg';
async function globalTeardown(config: FullConfig) {
const client = new Client({ connectionString: process.env.TEST_DATABASE_URL });
await client.connect();
await client.query('DROP SCHEMA IF EXISTS public CASCADE; CREATE SCHEMA public;');
await client.end();
}
export default globalTeardown;
Be careful with destructive teardown in a shared environment. Dropping a schema is fine when the database is dedicated to tests, but it is a footgun against a staging database your team shares. In my setups, teardown is conditional: it only drops when an env flag like E2E_DESTRUCTIVE_TEARDOWN=1 is set, so a misconfigured local run cannot nuke shared data.
Passing Data From globalSetup to Your Tests
This is the question everyone asks after their first working setup: how do I hand a value from globalSetup to a test? The answer is process.env, because globalSetup runs in its own process and module-level variables will not reliably cross that boundary.
// inside global-setup.ts
process.env.SEEDED_ADMIN_ID = '1';
process.env.API_BASE = 'https://api.example.com/v2';
// tests/orders.spec.ts
import { test, expect } from '@playwright/test';
test('admin sees the seeded order', async ({ page }) => {
const adminId = process.env.SEEDED_ADMIN_ID;
expect(adminId).toBeDefined();
await page.goto(`/users/${adminId}/orders`);
await expect(page.getByText('$250.00')).toBeVisible();
});
Environment variables are strings only, so if you need to pass an object or an array, serialize it with JSON.stringify on the way out and JSON.parse on the way in. This is a simple pattern, but it is the difference between a flaky “undefined at runtime” bug and a suite that works on the first try in CI.
globalSetup vs Test Fixtures vs beforeAll
Most confusion in Playwright is people using the wrong scope for one-time work. Here is the decision rule I teach:
- Runs once per entire run and is not tied to a browser test: use
globalSetup. Examples: login once, seed the database, fetch a shared token. - Runs per worker or per file and needs Playwright fixtures like
page: usebeforeAllinside a describe block or a worker-scoped fixture. - Runs per test and needs isolation: use a fixture with
beforeEachsemantics, which is the default for Playwright fixtures.
Playwright’s own docs recommend fixtures over globalSetup for most data concerns, because fixtures give you automatic teardown and per-test isolation. A typed fixture looks like this:
// fixtures.ts
import { test as base } from '@playwright/test';
import { Client } from 'pg';
type DbFixture = { db: Client };
export const test = base.extend<DbFixture>({
db: async ({}, use) => {
const client = new Client({ connectionString: process.env.TEST_DATABASE_URL });
await client.connect();
await use(client);
await client.end();
},
});
I use globalSetup for true global concerns: authentication state and schema-level seeding. I use fixtures for per-test data and anything that must stay isolated. If a value needs to be shared by literally every test and created exactly once, that is globalSetup. If it needs to be clean per test, that is a fixture. Forgetting the difference is how suites end up with slow, order-dependent tests.
The pattern I land on most often is a hybrid: globalSetup handles login and schema seeding, while fixtures wrap each test in a database transaction that rolls back on teardown. That gives you the speed of one-time setup with the isolation of per-test cleanup. You get the best of both scopes instead of forcing one mechanism to do both jobs. The Playwright global setup docs and the Fixtures and Hooks (Day 6) article cover both sides of that line.
Putting It Together: One Run End to End
Here is the exact order Playwright follows on a full run, which clears up most “when does this actually fire?” questions:
- The
webServerentry (if configured) starts. globalSetupruns once: log in, save thestorageState, seed the database.- Test workers launch and each test loads the saved
storageState. - All tests finish, then
globalTeardownruns once to clean up. - The
webServerprocess stops.
You can watch this in your terminal. A healthy run shows the setup work up front, then the test lines, then the teardown at the very end:
$ npx playwright test
Running global setup from playwright.config.ts
Seeded 2 users and 2 orders
Saved storage state to playwright/.auth/user.json
3 passed (38s)
Running global teardown from playwright.config.ts
Dropped test schema
If you see the setup logs appear before every test file instead of once, your setup is running at the wrong scope, probably inside beforeAll. That is the single best signal to catch scope mistakes early.
Playwright Global Setup Pitfalls
These are the mistakes I see teams make, in the order of how often they bite:
- Using
pageor test fixtures insideglobalSetup. You cannot. Launch a browser manually withchromium.launch()like the example above. - Forgetting
require.resolve(). A bare relative string can resolve against the wrong directory and throw a “cannot find module” error only in CI. - Mismatched
storageStatepaths. Setup writes one path, config reads another. Tests run logged out and nothing fails loudly. - Putting per-test data in
globalSetup. If tests mutate seeded rows, you get order-dependent flakiness. Move that data to fixtures. - Hardcoding secrets. Credentials and database URLs belong in
process.env, not in committed files. - Ignoring
globalSetupfailures. If setup throws, the entire run fails before any test executes. Wrap it so the error is readable, or you will stare at an empty report. - Destructive teardown in a shared environment. Gate
DROPandTRUNCATEbehind an explicit flag. - Assuming the config and setup share state. They run in different processes. Only
process.envreliably crosses the boundary.
The India SDET Interview Angle
If you are preparing for SDET interviews in India, expect a version of this question: “How do you reuse a login session across your Playwright tests?” The answer that separates a mid-level candidate from a senior one is not “I log in in beforeEach.” It is “I capture a storageState in globalSetup so the login runs once, and I keep per-test data in fixtures so tests stay isolated.”
Interviewers at product companies in Bengaluru and Hyderabad are testing whether you understand scope: run-level, worker-level, and test-level. Being able to explain globalSetup versus beforeAll versus a fixture, with a concrete example of when each is correct, signals the kind of framework ownership that maps to the ₹15-40 LPA band for senior SDET roles.
The follow-up they almost always ask is how you keep tests isolated once data is shared across the suite, which is where the fixture-and-transaction answer from earlier comes back around. If you can hold both sides of that conversation, you have effectively demonstrated the framework design skill they are actually paying for. The Playwright CI GitHub Actions (Day 12) article shows how this same setup slots into a real pipeline.
Key Takeaways
globalSetupruns once per run before tests,globalTeardownonce after, and neither can use test fixtures directly.- The killer use case is login once and save a
storageState, plus one-time database seeding. - Register both with
require.resolve()inplaywright.config.ts, and keep thestorageStatepath shared between config and setup. - Pass values to tests through
process.env, serializing objects with JSON. - Use fixtures for anything that must stay isolated per test; use Playwright global setup only for true run-level concerns.
FAQ
What is the difference between globalSetup and beforeAll in Playwright?
globalSetup runs once for the entire test run in its own Node process. beforeAll runs once per worker file, inside the test runner, and can use fixtures like page. Use globalSetup for login and schema seeding, beforeAll for per-file browser setup.
Can I use the page fixture inside globalSetup?
No. Fixtures are not available in globalSetup because it runs outside the test worker. Launch a browser manually with chromium.launch() and build your own context.
Log in once in globalSetup, call context.storageState({ path }), and set the same path in your config’s use.storageState. Every test then starts authenticated.
Does globalSetup run in parallel with other workers?
No. It runs once in a dedicated process before any test worker starts. This is exactly why it is the right place for one-time setup that must finish before tests begin.
How do I pass dynamic data from globalSetup to tests?
Use process.env. Set it in globalSetup and read it in your tests or fixtures. Values are strings, so JSON-encode anything complex.
Can I skip globalSetup for a quick local run?
Yes. Wrap the expensive parts in a condition, or check an env flag inside globalSetup and skip login when something like E2E_SKIP_SETUP=1 is set. That lets you iterate on a single test without waiting for the database to reseed every time.
