(Updated: )

How to Speed up your Playwright Tests with shared "storageState"

Share on social

Speed up your PWT tests with storageState
Table of contents

What are the two things making your end-to-end test investment a failure?

Firstly, it's test flakiness. If you've invested days (if not months) in creating your test suite and it didn't turn out to be like this one trustworthy friend you have in your life for decades, you failed. You failed because eventually, you'll discover that every moment waiting for retrying tests became a burden. You'll maintain questionable test code, delay deployments, and only wait for the false sense of security from "sometimes passing" tests. 

It depends on how long your tests run, but I think you'll agree that all this waiting for headless browsers to test your site should better be worth it. And this brings us to the second end-to-end testing failure — tests that take forever. If you're on the edge of your seat to release this critical production hotfix, every minute counts. And if then your test execution time passed the 30-minute mark months ago, you'll question if all this effort makes sense. 

But how can you make your Playwright tests faster?

For starters, you should definitely look into parallelizing your tests. Spinning up multiple workers, distributing the work and running your tests in parallel will drastically reduce your test execution time in your CI/CD pipeline. But what if you could look at the problem from another angle and find ways to make your tests do less?

Let's look into how you could restructure your test suite to implement a setup step that will enable you to share browser session data across tests. If you're trying to test a login-walled website, you'll then submit your email and password once and reuse the resulting browser session across the entire test suite. Then, your tests don't have to log in repeatedly.

Follow along on YouTube...

... or find a good old tutorial below. Sounds good? Let's go!

The time-wasting problem — end-to-end testing of features behind a login wall

If you're trying to test anything behind a login wall you'll face the problem that, parallelized or not, every new Playwright test will come with a fresh browser state and its own context. And this is a feature because it helps to avoid test collisions and keep things encapsulated. But it also means that every test must log into your app to test your gated product features.

Here's a test to check if Checkly's monitoring dashboard is loading properly after login.

product.spec.ts
import { expect, test } from "@playwright/test"

test("home dashboard loads", async ({ page }) => {
  // login
  await test.step("login", async () => {
	await page.goto("https://app.checklyhq.com")
	await page.getByPlaceholder("yours@example.com").fill(process.env.USER)
	await page.getByPlaceholder("your password").fill(process.env.PW)
	await page.getByLabel("Log In").click()
	await expect(page.getByLabel("Home")).toBeVisible()
  })

  // actual test case
  await expect(page.getByTestId("home-dashboard-table")).toBeVisible()
  // more test instructions
  // ...
})

If you look at this test in encapsulation it's fine and does the trick. But if you test an entire product hiding behind a login, all your tests will perform the same operation — login. This approach is anything but DRY.

Let's consider 50+ tests covering all your product features. If all your tests need to log in, this is a lot of wasted and repeated effort.

For example, it takes roughly five seconds to log into the Checkly web app.

Playwright UI mode showing that 5s delay quickly adds up in a complex test suite.

Considering a reasonably sized test suite with 50 tests: without running tests in parallel, these repeated login steps increase the overall test execution time by more than four minutes (50 tests multiplied by 5 seconds). And even when you do parallelize your tests and run them with multiple workers, you'll just be fighting symptoms. Your test suite might finish quicker, but that argument stands: your browser test sessions will waste time performing the same actions over and over again.

But how can you log in once and then reuse the browser session state across browsers?

The answer to this question is twofold. First, you need to establish a setup step running before your tests. And second, you must persist the browser session state (cookies and localStorage) to disk so that it is reusable in your tests. 

Let's add these two magic ingredients!

How to define a Playwright project setup step

There are multiple ways to run code before your tests. Let's discuss the obvious (but not working) ones first.

You might have used beforeEach before. beforeEach runs code before every test, but unfortunately, it isn't helping us here because we're looking for one setup execution right before running all tests.

Then, you could consider using beforeAll, but it unfortunately only runs before the tests in a single spec file (and you might have tests in multiple files relying on the same session state) and also doesn't have access to the page or context fixtures. So, neither of these two options fits.

To solve the setup problem, Playwright introduced project dependencies in v1.31. Project dependencies allow you to chain Playwright tests and are a perfect fit for setup steps. Here's an example.

playwright.config.ts
import { defineConfig, devices } from "@playwright/test"

export default defineConfig({
  // more config stuff
  // ...
  projects: [
    // define a setup project
    {
      name: "setup",
      use: { ...devices["Desktop Chrome"] },
      testMatch: /.*\.setup\.ts/,
    },
    {
      name: "behind-login",
      use: {
        ...devices["Desktop Chrome"],
      },
      // declare that the `setup` project is a dependency
      dependencies: ["setup"],
    },
  ],
})

The behind-login project defines a setup project dependency that's defined above. The setup project will run tests with the setup.ts suffix. Whenever you now run npx playwright test, Playwright will try to run the projects and is smart enough to understand that these two projects need to run in order. First setup, then behind-login. Great stuff! 

By chaining projects with dependencies, you can then define setup instructions and run them before your actual test files. 

But how can you persist login state? Let's find out.

How to persist session state using "storageState"

Now that we have defined our setup project, let's create a new session.setup.ts file and include the login code we initially defined in our test case.

session.setup.ts
import { expect, test as setup } from "@playwright/test"

setup("authenticate", async ({ page }) => {
  // login
  await page.goto("https://app.checklyhq.com")
  await page.getByPlaceholder("yours@example.com").fill(process.env.USER)
  await page.getByPlaceholder("your password").fill(process.env.PW)
  await page.getByLabel("Log In").click()
  await expect(page.getByLabel("Home")).toBeVisible()
})

Note: To make things easier to read and more recognizable, we also renamed the test method to setup. This approach makes it clear that you're editing at a *.setup.ts file that will run before the tests.

However, including the login actions in our setup does not solve the problem yet because there is no connection between our setup and the behind-login project. Remember, every test comes with its own browser state. This is where the storageState method comes in handy.

When you call storageState, Playwright will make all the current cookie and localStorage entries accessible to you. If you will, storageState returns an entire browser storage session dump. 

You can either access all the data directly in JavaScript/TypeScript or write it directly to disk by defining a path option.

// return storage and session data
await page.context().storageState()
// write storage and session data to disk
await page.context().storageState({ path: ".auth/user.json" })

If you inspect the returned storage state, you'll find a nicely structured object that gives you cookie and localStorage entries.

{
  "cookies": [
    {
      "name": "_csrf",
      "value": "...",
      "domain": "auth.checklyhq.com",
      "path": "/usernamepassword/login",
      "expires": 1723844704.104802,
      "httpOnly": true,
      "secure": true,
      "sameSite": "Lax"
    }
    // ...
  ],
  "origins": [
    {
      "origin": "https://app.checklyhq.com",
      "localStorage": [
        {
          "name": "ajs_user_id",
          "value": "..."
        },
        // ...
      ]
    }
  ]
}

Let's extend our setup test case to log in and (!) write the browser state to an .auth directory.

session.setup.ts
import { expect, test as setup } from "@playwright/test"

// define file path for storage and session data
const authFile = ".auth/session.json"

setup("authenticate", async ({ page }) => {
  await page.goto("https://app.checklyhq.com")
  await page.getByPlaceholder("yours@example.com").fill(process.env.USER)
  await page.getByPlaceholder("your password").fill(process.env.PW)
  await page.getByLabel("Log In").click()
  await expect(page.getByLabel("Home")).toBeVisible()
  // write storage and session data to disk
  await page.context().storageState({ path: authFile })
})

When we run npx playwright test, session.setup.ts is executed first. This creates a beautiful .auth/session.json file that holds all the information we need to prepare our following test cases. How can we apply the session information to the tests, then?

Apply storage state to projects with a nifty config one-liner

You could now think that you must perform some glue work to tie it all together but luckily setting Playwright-generated storage files to a project is a nifty one-liner in your project configuration.

playwright.config.ts
import { defineConfig, devices } from "@playwright/test"

export default defineConfig({
  // more config stuff
  // ...
  projects: [
    {
      name: "setup",
      use: { ...devices["Desktop Chrome"] },
      testMatch: /.*\.setup\.ts/,
    },
    {
      name: "behind-login",
      use: {
        ...devices["Desktop Chrome"],
        // set storage state for tests
        storageState: ".auth/user.json",
      },
      dependencies: ["setup"],
    },
  ],
})

With this one-line change, all tests included in the behind-login project can drop the login logic and go straight into the product because they're getting a headstart with the correct cookies and localStorage entries. They can go straight to the testing business.

product.spec.ts
import { expect, test } from "@playwright/test"

test("home dashboard loads", async ({ page }) => {
  // there's no login needed because storage state
  // was already set via project dependencies
  await page.goto("https://app.checklyhq.com")
  await expect(page.getByTestId("home-dashboard-table")).toBeVisible()
})

If you don't want to define the storage state per project in your playwright.config.ts, you could also define it per test using test.use in your spec.ts files.

product.spec.ts
import { expect, test } from "@playwright/test"

test.use({ storageState: ".auth/user.json" })

test("home dashboard loads", async ({ page }) => {
  await page.goto("https://app.checklyhq.com")
  await expect(page.getByTestId("home-dashboard-table")).toBeVisible()
})

Either way, your tests don't need to worry about login logic anymore because they now come with the required cookie and localStorage values. You removed all the repetitive work and only log in once. Win-win!

Conclusion

When I combined project dependencies with storageState for the first time I was amazed by how well the Playwright methods work together. The recommended setup.ts convention quickly internalizes for the team and, I don't know about you, but I'm a fan of cutting off wasted minutes of test execution time and performing less work in my end-to-end tests!

It might not seem like a big deal when you save fifteen seconds per test, but trust me, these improvements add up. Soon fifteen seconds per test run will save you hours of waiting and real dollars you're paying for running your tests in your CI/CD pipeline.

Do you have other tips on how to speed up your Playwright tests? If so, I'd love to hear them in the Checkly community Slack where we discuss all things Playwright and Synthetic Monitoring. I'll see you there.

Share on social