Authenticating test users in Playwright
If you've worked with Playwright at scale, you know that test user management is super important and a common source of flaky tests, failures, or maintenance headaches. I recently spent some time working on a Playwright project with tightly coupled project dependencies and adapted it to log in users only when necessary. Here's a little bit of that journey.
The old way
The framework was built using well-established patterns to handle test user dependencies. It had:
-
Setup "tests" (in name only) that created
storageStateauth files before the real tests ran. - Project dependencies that called those setup tests at the start of each run.
- User fixtures that loaded auth files into the Playwright page context for real tests to use.
- Many test projects using a mixture of direct paths and tags to locate tests and necessary dependencies.
Here’s a simplified view of those framework components:
// the projects and their dependencies (playwright.config.ts)
export default defineConfig({
projects: [
{ name: 'setup-admin', testMatch: ['**/setup/admin.setup.ts'] },
{ name: 'setup-customer', testMatch: ['**/setup/customer.setup.ts'] },
// setup and test projects continue to grow in count over time
...
{
name: 'smoke-admin',
dependencies: ['setup-admin'],
testMatch: ['**/tests/admin/**.spec.ts'],
},
{
name: 'smoke',
dependencies: ['setup-admin', 'setup-customer'],
grep: /@smoke/,
},
],
});
// the user fixture (fixture.ts)
import { test as baseTest, expect } from '@playwright/test';
const createFixture = (authFile?: AuthFile) => {
...
let context: BrowserContext;
if (authFile && fs.existsSync(authFile)) {
context = await browser.newContext({
storageState: authFile,
});
} else {
context = await browser.newContext();
}
const page = await context.newPage();
await use(page);
...
}
const test = baseTest.extend<PageWithAuth>({
adminPage: createFixture(ADMIN.file)
...
}
export { test, expect };
// a login "test" (admin.setup.ts)
import { test as setup } from '@fixtures/fixture';
import { login } from '@utils/auth';
import { ADMIN } from '@constants/users';
setup('Create admin auth file', async ({ page }) => {
await login(ADMIN);
await page.getByRole('link', { name: /sign out/i }).waitFor();
await page.context().storageState({ path: 'auth/admin.json' });
});
// an actual test (admin/reports.spec.ts)
import { expect, test } from '@fixtures/fixture';
test(
'Admin can create a report across accounts',
{ tag: ['@smoke'] },
async ({ adminPage }) => {
await adminPage.goto('/reports');
await adminPage.getByRole('button', { name: /create a report/i }).click();
...
That probably sounds like a lot of Playwright frameworks you've seen. In fact it’s one of the patterns demonstrated in Playwright’s documentation. As the framework scaled to 20+ test users and was sharded across multiple runners in CI, some cracks in that approach started to show.
The cracks
This architecture created some pain points as it continued to scale:
Tight coupling and bugs. Setup projects and the test projects that depended on them were strictly coupled. If someone added a test using a new user fixture but forgot to add it to the project dependency, it would cause an immediate failure. If a test was moved into another project that had a different setup dependency, same result. This happened on multiple occasions, particularly for broad tag-based projects like the general “smoke” and “regression” suites.
Wasteful workarounds. To avoid missing dependencies, many engineers used an "all users" setup, logging in test users they didn't actually need. This problem was even more pronounced when using Playwright’s sharding where it was effectively performing
shards * userslogins. The alternative to "all users" here was meticulous orchestration of which runners received which tests (a maintenance nightmare of its own). Logging in every user drove up run time and increased the odds of hitting an all or nothing scenario.All or nothing test runs. A single auth setup failure would halt the entire project. Missing one test user in your environment? Someone changed a password? You'd get zero meaningful output from any test until you addressed that.
Maintenance overhead. The framework had numerous setup files, projects, and auth tests scattered throughout the codebase, making it difficult to understand what was actually happening or what was necessary for a project to run. This was a big barrier to entry for engineers who weren't regularly contributing to the test suite.
Noisy reporting. Login "tests" were surfaced in monitoring and reporting systems, creating extra noise that obscured real tests and legitimate functional failures. This also ran the risk of the flaky test management system allowing them to fail or skipping them outright, causing cascading issues with test projects.
In practice, this was too much to keep in mind. Test authors and code reviewers alike had to constantly verify seemingly unrelated pieces of the framework (like a project you didn't touch at all). It was time for a change.
Common approaches and what I wanted to avoid
Explicit project dependencies. This is the recommended approach in Playwright’s documentation: dedicated setup projects that generate storageState files, with test projects declaring dependencies on them. This was the starting point and it worked great for the team, until it didn't (as described above).
Using global setup for auth. Playwright’s
globalSetupcan be great for functionality you’re confident will always work. Because it isn't a test, you're going to have to write conditional or retry logic yourself if you need that. This wasn't a wheel I wanted to reinvent. There were many different test environments which didn't all contain the same data (users, accounts, etc.) necessary for the entire test suite, so a single global setup wouldn't work. Some individual test user wouldn’t exist in a given environment, then the entire suite would get skipped as a result.More mental overhead. The team didn’t have bugs with setup because people didn’t care about the tests, it was simply too much to keep in mind when trying to add new tests or alter projects. Reducing that complexity and having fewer moving pieces was a primary goal.
Making superficial changes. There was a large amount of setup projects and a way to run them all at the same time, so I wanted to make sure a change wasn't just recreating those pieces by a different name or mechanism.
With these considerations in mind, I landed on expanding the fixtures.
Letting the fixtures handle auth
What if each test could ensure its own user’s auth was valid without relying on dependency chains, beforeEach test hooks, or some other setup?
Here's what that looked like:
Ensuring valid auth files
I hooked a function into the user fixtures which create the playwright Page objects that were already in use to drive every test. Now, whenever a worker picks up a test using a given user fixture, it automatically checks if the necessary auth file is valid by running checks like:
- Does the file exist?
- Is it for the correct environment (not a different origin)?
- Is the token still valid, or does it expire soon?
If any check fails, the worker regenerates the auth file and other workers running tests involving other users are unaffected.
// fixture.ts
const createFixture = (authFile?: AuthFile) => {
++ if (authFile) {
++ const tempContext = await browser.newContext();
++ const tempPage = await tempContext.newPage();
++ await ensureValidAuthFile(tempPage, authFile);
++ await tempContext.close();
++ }
// continue to use() the page the same way afterwards
}
// utils/auth.ts
export async function ensureValidAuthFile(
page: Page,
authFile: AuthFile,
): Promise<void> {
if (isValidAuthFile(authFile)) return;
...
const user = getUser(authFile);
await login(user);
await page.getByRole('link', { name: /sign out/i }).waitFor();
await page.context().storageState({ path: authFile.path });
}
An important note
To do something similar, you'll likely want to handle workers racing to log in the same user. There are a couple different ways to do this, but I opted to create a temporary, per-user "lock" file in the auth/ directory and have the workers check for its presence before actually trying to log the user in. Don't be like me and get your test users blocked in Auth0 because they're trying to log in with 6 different tokens in 1 second.
Removed setup-only projects and dependencies
With fixtures handling their own auth validation, auth-only project dependencies were no longer needed. Who doesn't love a PR with a large red diff? (They're commented out for illustration here, not in the actual repo. I'm not a monster.)
// playwright.config.ts
export default defineConfig({
projects: [
// { name: 'setup-admin', testMatch: ['**/setup/admin.setup.ts'] },
// { name: 'setup-customer', testMatch: ['**/setup/customer.setup.ts'] }
{
name: 'smoke-admin',
// dependencies: ['setup-admin'],
testMatch: ['**/tests/admin/**.spec.ts'],
},
{
name: 'smoke',
// dependencies: ['setup-admin', 'setup-customer'],
grep: /@smoke/,
},
...
Deleted login "tests"
Since login is no longer a separate test but a fixture concern, the login "tests" could also go. This cleaned up reporting and made actual test metrics much more meaningful.
// completely removed all auth-only setup tests
// import { test as setup } from '@playwright/test';
// import { login } from '@utils/auth';
// import { ADMIN } from '@constants/users';
// setup('Create admin auth file', async ({ page }) => { ... });
No changes to existing tests!
Since auth hooked into the user fixtures that were already in use, nothing needed to change in the existing tests or the way they were written going forward! The page objects worked exactly as they had previously, but now they included authentication automatically.
Results
The transformation has been awesome, and it was a lot of fun to work on.
- Simpler architecture. The new approach eliminates auth-only dependencies from test projects, and the need for those setup projects entirely. Fixtures now handle authentication automatically. When a test needs a specific user, that user gets logged in on demand. Whether you run an existing project or a narrow grep-based subset, only the users actually required by those tests will be logged in.
- Better scaling. Previously each runner (shard or otherwise) logged in every single user because we could never be sure what tests they’d receive and which test users they’d need. Now they only log in the users they need with zero orchestration or intervention from us. Way more efficient.
-
Auth got even faster. Logging in a test user and creating the
storageStatetakes an average of 3 seconds because of an auth0 API app I'd stood up previously. Now, many projects went from logging in all the test users on each runner down to just the 1 or 2 users they needed. Scale that across 4-20 workers per job with hundreds of jobs per day and it adds up fast. - Cleaner reporting. Test dashboards, alerts, and reports now only show real test results, not setup noise.
- Less mental overhead. Previously, adding a test with a new user required multiple manual steps: create the user fixture, add a setup project, update the dependencies for every project that might run the test, and verify them all. Now, the author simply creates the fixture and uses it. There are far fewer variables for the author and reviewer to check.
- Fewer bugs in the framework. Missing or under-inclusive dependencies, stale project definitions, reorganizing test projects? The whole category of errors stemming from incorrect setups are simply no longer possible.
The takeaway
The best solution isn't always more orchestration. By moving authentication logic into the fixtures themselves, I traded tight dependency chains and manual setup coordination for automatic, on-demand behavior. Each fixture now handles exactly what it needs, when it needs it.
A simpler architecture, fewer bugs, and more time spent focusing on the business logic tests themselves.
There's never a silver bullet, but this worked really well for the project. If you're maintaining a growing Playwright test suite and similarly juggling an ever-growing list of setup projects or are unhappy with the constraints of using a global setup, consider giving fixtures a try!
Top comments (0)