Playwright fixtures for authentication
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.
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.