What do cooking and test automation have in common?
Cooking tasty recipes comes with its own hacks and secret ingredients, just like automation scripts with twists and tweaks from testers.
You may wonder: what do cooking and test automation have in common? We can build beautiful comparisons of coding and cooking: automation testers as chefs, automation scripts as cooking recipes, automation frameworks as cooking pots and pans, and a cookbook as simple as this article! This article is dedicated to all testers who are on the lookout for new recipes for authentication using Playwright.
This article is meant for automation testers who are already familiar with or are currently using Playwright for their work. For demonstration purposes, I will use the Book Cart practice store, which I came across in a Ministry of Testing article written by Sarah Deery.
I have handpicked four authentication recipes in this cookbook that range from basic to moderate levels of complexity. These recipes use a mix of UI and API authentication methods, with real-world use cases explained for each one.
Prerequisites: Ingredients common to all the recipes
- Playwright must be installed and running on your system.
- You must know basic Typescript.
- You must be familiar with common authentication scenarios for both API and graphical user interfaces.
- You must be familiar with the information in Playwright Installation and Playwright Authentication,
What do these recipes bring to your testing plate?
Regardless of the test automation tool you use, it’s always a good practice to reuse your login state for your tests whenever possible. In other words, each time you run a test for a particular user, you reuse their login state. It’s like purchasing a single yearly subscription for Netflix instead of purchasing a monthly subscription 12 times a year.
All of the Playwright authentication methods described below allow you to log in once and reuse that login state effectively across your tests. This will save execution time, avoid brittleness, and omit needless repetition: work smart, not hard.
Recipe 1: Authenticate once via the UI and reuse the login storage state
When you examine the Book Cart site, it has a good front end that we can use for meaningful tests like logging in, adding to the shopping cart, adding wish lists, and placing orders. It is equipped with a Swagger document that we can use for API tests too.
You CAN use this recipe when all your tests can run in parallel using the same login account without affecting each other. For example, you can use this recipe to add books to the ‘wishlist’ as a user. In such an instance, it makes more sense to log in just once and reuse the login state across all the tests.
There are some situations where you cannot use this recipe. For example, on our practice website Bookcart, imagine that you have a test that validates the order history page and another test that validates the page elements when the order list is empty (see screenshots below). You clearly have to use different accounts for the tests, which means you CANNOT use this recipe in this situation. However, you can use Recipe 2 for that use case.
Step 1: Create a setup.spec file
Create a tests/setup.spec.ts
file that has the login steps and captures the storage state of the user as specified in the path below.
Make sure to add auth.json to .gitignore
so you do not push the file to your repository.
import { test as setup, expect } from "@playwright/test";
setup("authenticate", async ({ page }) => {
await page.goto("https://bookcart.azurewebsites.net/");
await page.getByRole("button", { name: " Login " }).click();
await page.getByLabel("Username").fill("username");
await page.getByLabel("Password").fill("password");
await page
.locator("mat-card-actions")
.getByRole("button", { name: "Login" })
.click();
// Wait until the page receives the cookies.
// Sometimes login flow sets cookies in the process of several redirects.
// Wait for the final URL to ensure that the cookies are actually set.
await page.waitForURL("https://bookcart.azurewebsites.net/");
// End of authentication steps.
await page.context().storageState({ path: "playwright/.auth.json" });
});
Step 2: Add setup as a project
In playwright.config.ts
, add the above setup as a project and make it a dependency for all the projects. The setup.ts file will be run before any tests are run.
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./tests",
projects: [
// Setup project
{ name: "setup", testMatch: /.*\.setup\.ts/ },
{
name: "chromium",
use: {
...devices["Desktop Chrome"],
// Use prepared auth state.
storageState: "./playwright/.auth.json",
},
dependencies: ["setup"],
},
],
});
Result of recipe 1
Playwright will run the setup.ts
file first before any tests and capture the storage state. Any tests that run after this will use the storage state from the auth.json file
.
Running 2 tests using 1 worker
✓ 1 [setup] > tests/auth.setup.ts:3:6 > authenticate (2.7s)
✓ 2 [chromium] > tests/first_test.spec.ts:3:5 > first test (1.6s)
2 passed (5.0s)
Recipe 2: Authenticate via the UI using different accounts and use unique storage states across each parallel worker
For the case where you must log in as two or more different users, recipe 2 comes to the rescue. You can validate those scenarios using a unique user account per parallel worker.
Have a look at the Playwright website for information on how this recipe is implemented. I used this template to implement my experiment.
Step 1: Create fixture file
- Create a fixtures file that overrides the storage state fixture, which means there is no need to declare any dependency in the config file, unlike what you did in recipe 1.
- The test object is extended from
baseTest
and adds the storage state that is unique to each parallel worker. - The
workerStorageState
is a fixture setup that is scoped to each parallel worker. - The storage states are stored in unique files,
auth0.json
andauth1.json
, in the playwright folder. Remember to add these auth files to.gitignore!
- The acquireAccount function assigns the login credentials from environment variables based on worker ids.
For example, I have logged in as swathika
and snithik
, once per user, using a fixture file. The code then saves their storage states, which can then be used across workers. This way, we will authenticate once per worker, each with a unique account : Example, worker 1 as swathika and the other as snithik
.
See the fixture file below:
import { test as baseTest, expect } from "@playwright/test";
export * from "@playwright/test";
import fs from "fs";
let account: { username: any; password: any };
// Define a function to return account credentials based on the ID
async function acquireAccount(id: number) {
// Define two accounts for demonstration
if (id === 0) {
account = {
username: process.env.username0,
password: process.env.password0,
};
} else if (id === 1) {
account = {
username: process.env.username1,
password: process.env.password1,
};
}
return account;
}
export const test = baseTest.extend<{}, { workerStorageState: string }>({
// Use the same storage state for all tests in this worker.
storageState: ({ workerStorageState }, use) => use(workerStorageState),
// Authenticate once per worker with a worker-scoped fixture.
workerStorageState: [
async ({ browser }, use) => {
// Use parallelIndex as a unique identifier for each worker.
const id = test.info().parallelIndex;
const fileName = `playwright/.auth${id}.json`;
if (fs.existsSync(fileName)) {
// Reuse existing authentication state if any.
await use(fileName);
return;
}
// Important: make sure we authenticate in a clean environment by unsetting storage state.
const page = await browser.newPage({ storageState: undefined });
//you can have a list of pre created accounts for testing.
account = await acquireAccount(id);
// Perform authentication steps. Replace these actions with your own.
await page.goto("https://bookcart.azurewebsites.net/");
await page.getByRole("button", { name: " Login " }).click();
await page.getByLabel("Username").fill(account.username);
await page.getByLabel("Password").fill(account.password);
await page
.locator("mat-card-actions")
.getByRole("button", { name: "Login" }).click();
// Wait for the final URL to ensure that the cookies are actually set.
await page.waitForURL("https://bookcart.azurewebsites.net/");
// End of authentication steps.
await page.context().storageState({ path: `playwright/.auth${id}.json` });
await page.close();
await use(fileName);},
{ scope: "worker" },
],
});
Step 2: create test files under the tests folder
Now, create a couple of tests that validate login with the unique storage states that were created in auth0.json
and auth1.json
.
For example, this is a simple test to validate my homepage URL after logging in as swathika
.
import { test, expect } from "../playwright/fixtures";
test("first test", async ({ page }) => {
await page.goto("https://bookcart.azurewebsites.net/");
await page.waitForURL("https://bookcart.azurewebsites.net/");
//continue with any user journey like adding books to cart or wishlist and make assertions
//If the test runs under worker 1, it will be performed as 'swathika' as the auth0.json will be saved with storage state for 'swathika'
});
Result of recipe 2
Once the tests are executed, the storage state files auth0.json
and auth1.json
are created. Each will be used by its own parallel worker.
Below, you can see that the two tests have passed and each worker process has used a unique storage state from auth0.json
and auth1.json
.
Recipe 3: Authenticate via the API for multiple accounts and use the resulting storage states
We all know that it's quicker to use API endpoints than the UI in tests. This is especially true for testing with multiple user accounts.
Role-based testing is critical when it comes to validating the actions that each type of user category is authorized to perform. This recipe is a perfect choice when you have to log in as multiple users and perform actions with different authorisation levels across tests.
For demonstration, imagine you have two users, one as an admin and the other as a reader / shopper. (The Book Cart website doesn’t support various roles other than reader: we are just assuming this.) With this assumption, let's see how to authenticate as an admin and also a reader to use their storage states across tests.
I have used the Playwright website as the reference. Let’s see how to implement this.
Step 1: create a setup.spec file
Create a tests/setup.spec.ts
file that has the API login steps as admin and reader and also captures the storage state of the users in their respective json files: admin.json
and reader.json
.
Make sure to add the admin.json
and reader.json
files to .gitignore
so you do not accidentally push them to your repository.
import { test as setup, expect } from "@playwright/test";
const adminFile = "playwright/.auth/admin.json";
setup("authenticate as admin", async ({ request }) => {
// authenticate
await request.post("https://bookcart.azurewebsites.net/api/login", {
form: {
user: "BookcartAdmin",
password: process.env.password1,
},
});
await request.storageState({ path: adminFile });
});
const readerFile = "playwright/.auth/reader.json";
setup("authenticate as reader", async ({ request }) =>
// authenticate
await request.post("https://bookcart.azurewebsites.net/api/login", {
form: {
user: "BookcartReader",
password: process.env.password2,
},
});
await request.storageState({ path: readerFile });
});
Step 2: Create tests that use the storage states of the admin and reader
We can use the storage state to a group of tests or individually for each test depending on what your test demands. Let us create two individual tests, one for ‘admin’ and another for ‘reader’.
I have now created an adminTest.spec.ts
test file, where I use the storage state for admin, and a readerTest.spec.ts
for reader. This is similar to the above recipes except that I will be using the storage state directly within the test instead of setting it up globally in the config file.
In both the test files, I have directly navigated to the My Orders page of the Book Cart website and validated if specific text is displayed. This ensures that the storage state has allowed us to authenticate as the admin or as the reader.
Below is the adminTest.spec.ts
using the storage state from admin.json.
import { test } from "@playwright/test";
test.use({ storageState: "./playwright/.auth/admin.json" });
test("admin test", async ({ page }) => {
await page.goto("https://bookcart.azurewebsites.net/myorders");
await page.waitForURL("https://bookcart.azurewebsites.net/myorders");
//validate if the storage state has worked and login is successful
//my orders page
await page.getByPlaceholder(" Start shopping ").isVisible();
//validate tests for admin
//......
});
And the readerTest.spec.ts
file, using the storage state from reader.json:
import { test } from "@playwright/test";
test.use({ storageState: "./playwright/.auth/reader.json" });
test("reader test", async ({ page }) => {
await page.goto("https://bookcart.azurewebsites.net/myorders");
await page.waitForURL("https://bookcart.azurewebsites.net/myorders");
//validate if the storage state has worked and login is successful
//my orders page
await page.getByPlaceholder(" Start shopping ").isVisible();
//validate tests for reader
//......
});
Result of recipe 3
After running the tests, we will have two storage state files, one for the admin and the other for the reader. The tests will use the storage state files appropriate for them.
Below is the evidence of the storage state captured in one of the json files.
Recipe 4: Authenticate via the API for multiple accounts and use them within a single test
In this recipe, we adopt the same steps we followed in recipe 3. But this time, instead of using the storage state of ‘admin’ and ‘reader’ in different tests, we use them within the same test. You can use this recipe when you want to test how multiple authenticated roles interact with each other.
Step 1: Create a setup.spec file
Follow the same steps as Recipe 3: create a setup.ts
file that stores the API authentication for admin and reader.
Step 2: Create a test file
Create a test file to use the authenticated states of both the admin and the reader:
import { test } from "@playwright/test";
test("admin and user", async ({ browser }) => {
// adminContext and all pages inside, including adminPage, are signed in as "admin".
const adminContext = await browser.newContext({
storageState: "playwright/.auth/admin.json",
});
const adminPage = await adminContext.newPage();
// readerContext and all pages inside, including readerPage, are signed in as "reader".
const readerContext = await browser.newContext({
storageState: "playwright/.auth/reader.json",
});
const readerPage = await readerContext.newPage();
// ... interact with both adminPage and userPage ...
await adminPage.goto("https://bookcart.azurewebsites.net/myorders");
await adminPage.waitForURL("https://bookcart.azurewebsites.net/myorders");
await readerPage.goto("https://bookcart.azurewebsites.net/myorders");
await readerPage.waitForURL("https://bookcart.azurewebsites.net/myorders");
// write further tests…..
await adminContext.close();
await readerContext.close();
});
Result of recipe 4
After running the tests, just as in recipe 3, we will get two storage state files: one for the admin and another for the reader. Each test will use the appropriate storage state.
The only difference is that in recipe 4 we use the storage state of both users within the same test, whereas in recipe 3 we use the storage states across different tests.
To wrap up
We have now seen a few authentication techniques that we can implement using Playwright. In my experience, every application is different, and the test automation strategy also differs based on the application.
For example, if your application doesn’t involve various roles and permissions, you can choose an authentication method accordingly. On the other hand, if your application supports roles with different authorisation levels, your authentication methods and techniques of using them in your tests will drastically differ.
I hope this article has given you a clear picture of how to reuse authentication states. This will save you execution time on pipelines, and this is important, especially when scalability of the automation framework is a factor!
I have pushed the recipes to my GitHub portfolio and these recipes can be found on their respective branches to avoid confusion. Please feel free to clone my practice repository and have a playthrough. The Playwright official website also provides a wealth of knowledge and sample code that you can adopt and tweak to suit your needs.
Happy cooking!