Playwright fixtures for authentication

  • Testing
  • Playwright
  • Auth
  • JavaScript

Writing tests can be hard. Browser-based tests even more so. They can be slow, brittle and hard to maintain. Playwright makes tests a lot easier to reason with, but developers can still be put off by the time it takes to get up and running. For applications requiring authentication it adds another layer of complication to the process.

At Vidsy, we're in the process of migrating all of our browser based tests over to Playwright. Right now I'm working to make the process of authoring these tests as frictionless as possible for the rest of the team.

Every single application we have requires some form of authentication to access, which means a lot of times tests would need to go through the authentication flow. Thankfully Playwright has a trick up its sleeve to make repeat actions easier to reason with - fixtures.

What is a fixture?

Fixtures help extract and share parts of the setup for tests. This allows the test file itself to focus purely on the steps of that test without having to worry about which browser to spin up, needing to create a new page or anything else that every test needs.

Playwright has a bunch of its own fixtures built in. The page object almost every test uses comes from a fixture, while context and browser fixtures are there to help build out the tests you'd like.

We can add our own fixtures in the mix to perform common actions for our own use-cases, or even extend existing ones. We can use test.extend to extend the test object itself and provide features that way.

Logging in

Vidsy has lots of different applications serving lots of different types of user and methods of authentication. In this example I'll use our creators application, which has a login consisting of an email address and password combination.

Login screen for creators application

Logging in is a case of entering the user's email address and password and clicking sign in. Pretty straightforward.

const logInAsCreator = (page: Page) => {
  await page.getByRole("textbox", { name: "Email" }).fill(process.env.EMAIL);

  await page
    .getByRole("textbox", { name: "Password" })
    .fill(process.env.PASSWORD);

  await page.getByRole("button", { name: "Sign in" }).click();

  await page.locator("#navHome").waitFor({
    state: "visible",
  });
};

There's a few problems with this approach however - the biggest of which is that we would need to repeat the process for every test that logs in. That's where fixtures come in.

Log in fixture

Every test we have makes use of Playwright's test object, alongside the page and associated browser and context that provides.

We have a dedicated fixtures.ts file that by default re-exports everything from @playwright/test. In there we export our own test object, which extends from the one provided by Playwright and add in all of our lovely fixtures.

Every test file can then import and use that test object like normal.

import { expect, test } from "./fixtures";

test("home", async ({ page }) => {
  // ...
});

With that set up it's time to add in the fixture. In this case we can extend the context from which the page spawns.

export const test = baseTest.extend<IOptions>({
  // ...
  context: async ({ baseURL, context }, use) => {
    // Perform log in
    const page = await context.newPage();
    await page.goto("/");
    await logInAsCreator(page);

    await page.close();

    // context is now ready to use
    await use(context);
  },
});

In this context we create a new page and log in using the same approach we did before. The process of logging in saves a cookie, which is stored within the context. We then close this log in page ready for the test to open its own. Finally, the await use(context) tells Playwright it's ready to use.

When Playwright runs the test, it will go through this context set up each time. That way we know each test will have a logged in user each time and they can be trimmed down to just what they're testing.

Reuse session for next time

Performing the same log in procedure each time is slow and not particularly useful. We've tested our login flow separately already, we don't need to keep doing it for each test.

Thankfully we can reuse the cookies from the first test in all other tests after that by storing the state.

const fileName = "path/to/storage.json";

if (fs.existsSync(fileName)) {
  // Reuse existing authenticated session
  authedContext = await browser.newContext({
    baseURL,
    storageState: fileName,
  });
} else {
  // Log in as user and store session
  authedContext = await browser.newContext({
    baseURL,
    storageState: undefined,
  });
  const page = await authedContext.newPage();
  await page.goto("/");
  await logInAsCreator(page);
  await page.context().storageState({ path: fileName });
  await page.close();
}

Fixtures run in isolation, but by giving Playwright a place to store the context state in JSON format on disk we can then pick it up and reuse it for subsequent tests.

The storageState method takes a path name to save it against. We can then check for the presence of that file and set up a new context using that state with the storageState option.

One crucial thing to remember is that we will need to refresh these stored states once they expire. We use a global setup to wipe all stored states at the beginning of any test run we perform to keep things clean.

Summary

Repeated actions slow down tests. We can optimise them away without too much fuss and make both Playwright and our developers happy.

Using a fixture to handle authentication means that every subsequent test after the first can shave a couple of seconds off of its run time. This soon starts adding up when you have multiple tests running in multiple browsers and builds a great foundation to build a test suite out in the future.