How to Manage Authentication & State in Your End-to-End Tests with Playwright
Three approaches to working with state data in your Playwright end-to-end tests

Playwright is an open source tool by Microsoft for browser automation and end-to-end testing. Playwright can drive Chromium (Chrome), WebKit (Safari) and Firefox in both headed and headless modes (with and without windows on the screen.) And, it comes with some pretty cool and powerful development and debugging tools that are quick and easy to use.
End-to-end (E2E) tests are not-really-unit tests in which real browsers are used to interact with a website or web app to check if things are working as expected. They’re commonly used to ensure that often-used UX/UI workflows like login pages or other app interactions are working correctly, often when it becomes too cumbersome to manually test everything in a SaaS product.
This article is Part 3 in a series of articles aimed at getting you started with everything you need to write end-to-end (E2E) tests using Playwright. In this article, we’ll look at how to work with authentication and other browser state across your E2E tests. I’ll assume that you’ve read Part 1 of the series.
Articles in this series
- How to Write End-to-End Tests for Chrome, Safari & Firefox Using Playwright
- Playwright Comes with Amazing Tools for Writing and Debugging Your End-to-End Tests
- How to Manage Authentication & State in Your End-to-End Tests with Playwright
- Hidden Goodies in Playwright That Will Super-Charge Your End-to-End Testing Experience
The examples in this article are based on E2E tests from KPI Manager, a SaaS startup I’ve co-founded. Our product, for recruitment agencies, aims to eliminate sales and productivity problems in a positive, proactive way through gamification techniques. As we moved past our MVP and further developed the product, it became increasingly difficult to manually test all the workflows. Thus, we turned to writing end-to-end tests with Playwright for some of the more obscure screens and interactions — places which were more involved to test or less immediately visible.
For the most part, it’s likely that when we’re doing end-to-end testing, we’re testing screens of an app where a user is logged in. Playwright offers a number of ways to approach this state data. It breaks down into roughly the following three options:
- Log in before each test
- Log in before each test, but with a more abstracted setup
- Log in once globally and reuse the state across tests
Now, let’s look at at all three approaches.
A New User For Each Test
The simplest way to approach application state is the slowest, but also my favorite because it’s idempotent. Each test gets its own freshly initialized state. The way we do this is that, before each test runs, we run some browser automation code which performs the actions necessary to log a user in. The reason this is the slowest approach is because, if we have 10 tests, it means that we’re logging in 10 times, once for each test. The benefit of this is that each test has a fresh login session. Rather than simply logging in, if we also create a new user and then log in for each test, we are able to have our tests be completely isolated from each other.
In a Playwright E2E test, it looks like this:
import { test, expect } from '@playwright/test'
import { nanoid } from 'nanoid'const URL_BASE = process.env.CI
? `https://{deployId}.<omitted>.dev`
: 'http://localhost:3000'test.describe('User settings screen', () => {
// create a new user account and logged in
// session before running each test
test.beforeEach(async ({ page }) => {
await page.goto(URL_BASE + '/onboarding')
await page.waitForSelector('button[type="submit"]') await page.fill('#userName', 'Name ' + nanoid())
await page.fill('#userEmail', nanoid() + '@a.test')
await page.fill('#campaignName', nanoid())
await page.fill('#campaignStartDate', '2100-01-01')
await page.fill('#campaignEndDate', '2100-02-01')
await page.fill('#campaignMetric_0_name', nanoid())
await page.fill('#campaignReward_0', nanoid()) await page.click('button[type="submit"]') await page.waitForNavigation()
}) test('should let a user change their bio', async ({
page,
}) => {
await page.goto(URL_BASE + '/settings') // We're now logged in. We can do whatever we need
// here as a logged in user to achieve our test goals
// and assertions
})
})If we have multiple test files which rely on this login code, it becomes a bit cumbersome to repeat the beforeEach() code in each file. We can solve this with Playwright's Page Object Model. The Page Object Model lets us create abstractions over web app pages to reduce code duplication cross multiple tests. It lets us refactor the above code.
First, we create a User Sign-up Page Object Model:
// onboarding-page-model.ts
import { Page } from '@playwright/test'
import { nanoid } from 'nanoid'
import { URL_BASE } from './config'export default class OnboardingPage {
readonly page: Page constructor(page: Page) {
this.page = page
}
async load() {
await this.page.goto(URL_BASE + '/onboarding')
await this.page.waitForSelector('button[type="submit"]')
} async signUpAndLogIn() {
const { page } = this await this.load() const campaignName = 'E2E Test Campaign ' + nanoid() await page.fill('#userName', 'Name ' + nanoid())
await page.fill('#userEmail', nanoid() + '@a.test')
await page.fill('#campaignName', campaignName)
await page.fill('#campaignStartDate', '2100-01-01')
await page.fill('#campaignEndDate', '2100-02-01')
await page.fill('#campaignMetric_0_name', nanoid())
await page.fill('#campaignReward_0', nanoid()) await page.click('button[type="submit"]') await page.waitForNavigation()
}
}Then, our simplified test file:
import { test, expect } from '@playwright/test'
import OnboardingPage from './onboarding-page-model'
import { URL_BASE } from './config'test.describe('User settings screen', () => {
test('should let a user change their bio', async ({
page,
}) => {
// create a new user account and logged in
// session before running each test
const onboarding = new OnboardingPage(page)
await onboarding.signUpAndLogIn() // We're now logged in. We can do whatever we need
// here as a logged in user to achieve our test goals
// and assertions await page.goto(URL_BASE + '/settings') // expect() assertions
})
})Of course, this refactor only starts to pay dividends once we have multiple test files. We’ve covered two of the three options Playwright gives us:
- Log in before each test
- Log in before each test, but with a more abstracted setup
Next we’ll look at the third option: Log in once globally and reuse the state across tests.
Reusing State
To set our authentication and/or app state globally, we must define a global setup script in the Playwright config file. The global setup script will run first before any of our tests. In the script we automate whatever interactions are necessary to get the browser into the desired state (like being logged in), and then capture that state data using the page.context().storageState() method which will grab all of the cookies and DOM Storage (local and session storage) data present in the page.
In code, it looks like this:
// global-setup.ts
import { chromium, FullConfig } from '@playwright/test'
import OnboardingPage from './onboarding-page-model'async function globalSetup(config: FullConfig) {
const browser = await chromium.launch()
const page = await browser.newPage() const onboarding = new OnboardingPage(page)
await onboarding.signUpAndLogIn() await page
.context()
.storageState({ path: '/tmp/storage-state.json' })
await browser.close()
}export default globalSetupWe then need to configure Playwright to use our global setup script. We do this by setting the globalSetup configuration parameter with the path to our global setup script:
// playwright.config.ts
import { PlaywrightTestConfig } from '@playwright/test'const config: PlaywrightTestConfig = {
globalSetup: require.resolve('./tests/e2e/global-setup'),
use: {
storageState: '/tmp/storage-state.json',
},
}
export default configFinally, with this scaffolding out of the way, our test becomes very short:
import { test, expect } from '@playwright/test'test.describe('User settings screen', () => {
test('should let a user change their bio', async ({
page,
}) => {
// We're logged in. We can do whatever we need
// here as a logged in user to achieve our test goals
// and assertions
})
})Keep in mind that each test will now be using the same user and logged in session. Depending on your app and test needs, this may be just fine.
One thing to note is that, you can also set the storageState configuration option within your test using the test.use() method. This can be useful if you have different state data for different contexts:
import { test, expect } from '@playwright/test'test.describe('User settings screen', () => {
test.use({ storageState: 'special-storage-data.json' }) test('should let a user change their bio', async ({
page,
}) => {
// ...
})
})Conclusion
We’ve looked at three approaches to working with our app state:
- Logging in before each test.
- Logging in before each test, but with the login logic abstracted into a Page Object Model
- Using a global setup script to login once, then sharing the state data across each test
This article barely scratches the surface of what’s possible with Playwright. In the final article in this series we’ll look at some additional goodies or features from Playwright which haven’t already been covered or are not as well known. Or, check out Part 2 to learn about the powerful debugging and test generation tools Playwright has for simplifying end-to-end testing.
You might also like:
If this article was useful, please consider giving it a like 👏.
Subscribe and get future articles from me directly to your email. Or, become a member and support me and other authors like me here on Medium. 🙇♂️
