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 setup between tests. This keeps the tests themselves lean - focusing purely on the assertions of each one.

Playwright has a bunch of its own built-in fixtures. They are used to build the page object almost every test uses, while the context and browser fixtures make working within those environments a whole lot easier.

We can create our own fixtures to perform our own common actions. We can even extend existing ones. For example 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 many types of user - each with their own way of authenticating. In this example we will log in to our creators application, which consists of an email address and password combination.

Login screen for creators application

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",
  });
};

However, there are a few problems with this approach. The biggest 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 makes use of Playwright's test object, alongside the page and associated browser and context objects that provides.

We have a dedicated fixtures.ts file that, by default, will re-export everything from @playwright/test. From there we export our own test object, which extends Playwright's own 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 }) => {
  // ...
});

It's time to add in the fixture. In this case we extend the context that spawns the page.

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);
  },
});

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 page ready for the test to open its own. Finally, the await use(context) tells Playwright it's ready to use.

All tests will pass through this context individually. This way we know that every test has logged in the same way and that no previous test run can affect another.

Reuse session for next time

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

Thankfully we can reuse the cookies from the first test in other tests 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();
}

By giving Playwright a place to store the context state in JSON format on disk we can then 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. By using fixtures we can take those actions out each individual test 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.