Sponsored Link
Looking for a unified way to  viewmonitor, and  debug Playwright Test Automation runs?
Try the Playwright Dashboard by today. Use the coupon code PWSOL10 for a 12 month 10% discount.

Handling Multiple Login States Between Different Tests in Playwright

This week I was inspired to write about how to handle different login states in a Playwright project, from a conversation with a Joel a fellow tester and this question that was asked in the Discord Channel.

I followed the doc https://playwright.dev/docs/auth for storing 'logged in' state in a JSON file. The next tests work like a charm until I reach the one that tests the logout feature. After the logout test scenario, the following tests no longer start as an already logged user. Why is that? Pls help me understand and fix it. Cheers!

Caveat: There are many different ways to solve this problem, I will cover the way I chose to solve the problem which was dependent on the web application I was testing. If you have a simpler way or more robust way to solve this problem please do reach out to me, I would love to hear all about it!

Caveat2: The assertions in the tests I show below are weaksauce . I invested minimal time here, in order to highlight sessionStorage and test setup.

Exploring the website I will test against

But before we begin, we must learn about our the website we will be writing automation for. We will use https://practicesoftwaretesting.com/ for our system under test (more details about the site are listed on the GitHub project page.

In my exploratory test session to learn about the software, I've identified that the Admin auth-token is generated upon login and can be used to have an authenticated state within different tabs of the same browser. If I open a new incognito window the session isn't active and I am not logged in, I can  create a 2nd authenticated session with different auth-token with the same user name and password. Both of those sessions can be active at the same time. As soon I use the logout functionality I found that the auth-token that was logged out against is now invalidated, while another auth-token can still be active.  This tells me that when I am going to write tests to validate the logout functionality, my tests that rely on an active session will no longer work.

This tells me the application we are testing has good security practices. When logging out of the system, the auth-token is invalidated. So as we create logout tests we should probably refrain from using our default auth-token.

Creating A Setup Project Within Playwright

The repository I am using to commit code to can be found below.

GitHub - playwrightsolutions/playwright-practicesoftwaretesting.com: Example using Playwright against site https://practicesoftwaretesting.com
Example using Playwright against site https://practicesoftwaretesting.com - GitHub - playwrightsolutions/playwright-practicesoftwaretesting.com: Example using Playwright against site https://practi...

The first step we need to do is establish a new setup project. In the below config file we have 2 projects, the first being setup which looks for *.setup.ts files and runs them, with a second project being called ui-tests, which is dependent on the setup project to run successfully to proceed.  More info on the defineConfig can be found within the Test configuration section of the playwright docs.

// playwright.config.ts

import { defineConfig } from "@playwright/test";
import type { APIRequestOptions } from "./lib/fixtures/apiRequest";
import { TestOptions } from "./lib/pages";

require("dotenv").config();

export default defineConfig<APIRequestOptions & TestOptions>({
  projects: [
    { name: "setup", testMatch: /.*\.setup\.ts/, fullyParallel: true },
    {
      name: "ui-tests",
      dependencies: ["setup"],
    },
  ],
  testDir: "./tests",
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: [["html"], ["list"]],
  use: {
    testIdAttribute: "data-test",
    baseURL: process.env.UI_URL,
    apiURL: process.env.API_URL,
    apiBaseURL: process.env.API_URL,
    trace: "on",
  },
});

Let's Add Our Setup Script

As you can see below the auth.setup.ts relies on a few building blocks that I discuss below this file (.env & LoginPage). Because this setup file was setup as a project, we have access to test blocks which we rename to setup in order to indicate these are setup steps not true tests.

The first half of the auth.setup.ts file sets variables for different emails, passwords, and filenames. These are then used in each setup block. Breaking down the actual setup steps, which includes

  • creating a LoginPage class so we can utilize the page object
  • going to the login page
  • using login async function, passing in a specified email and password
  • validating the user has been signed in
  • saving the storageState to the specified file

This is repeated in the 3 setup blocks for different users (admin, customer01, and customer02).

Also one other thing to note is I set fullyParallel: true in the playwright.config.ts which will run each setup step at the same time when running with multiple workers. This will help speed up the setup steps.

// tests/auth.setup.ts

// Save your storage state to a file in the .auth directory via setup test

import { LoginPage } from "@pages";
import { test as setup, expect } from "@playwright/test";

let adminEmail = process.env.ADMIN_USERNAME;
let adminPassword = process.env.ADMIN_PASSWORD;
const adminAuthFile = ".auth/admin.json";

let customer01Email = process.env.CUSTOMER_01_USERNAME;
let customer01Password = process.env.CUSTOMER_01_PASSWORD;
const customer01AuthFile = ".auth/customer01.json";

let customer02Email = process.env.CUSTOMER_02_USERNAME;
let customer02Password = process.env.CUSTOMER_02_PASSWORD;
const customer02AuthFile = ".auth/customer02.json";

setup("Create Admin Auth", async ({ page, context }) => {
  const loginPage = new LoginPage(page);
  await loginPage.goto();

  await loginPage.login(adminEmail, adminPassword);
  expect(await loginPage.navAdminMenu.innerText()).toContain("John Doe");

  await context.storageState({ path: adminAuthFile });
});

setup("Create Customer 01 Auth", async ({ page, context }) => {
  const loginPage = new LoginPage(page);
  await loginPage.goto();

  await loginPage.login(customer01Email, customer01Password);
  expect(await loginPage.navUserMenu.innerText()).toContain("Jane Doe");

  await context.storageState({ path: customer01AuthFile });
});

setup("Create Customer 02 Auth", async ({ page, context }) => {
  const loginPage = new LoginPage(page);
  await loginPage.goto();

  await loginPage.login(customer02Email, customer02Password);
  expect(await loginPage.navUserMenu.innerText()).toContain("Jack Howe");

  await context.storageState({ path: customer02AuthFile });
});

The first thing to note is I am using the package dotenv in order to manage my environment variables which are stored in the .env file.

// .env

# URLS
UI_URL=https://practicesoftwaretesting.com
API_URL=https://api.practicesoftwaretesting.com

# Logins
[email protected]
CUSTOMER_01_PASSWORD=welcome01
[email protected]
CUSTOMER_02_PASSWORD=welcome01
[email protected]
ADMIN_PASSWORD=welcome01

I am also using a Page Object for the Login Page This is imported from the @pages path I have setup in my tsconfig.json file. I haven't covered this in any of my guides but plan to soon, for now just know it's a nice shortcut that I can use without having to use a full path in my imports. Within the page file we have some locators, and 2 methods. One for going to the login page and the other for login requiring an email and password to be passed in as variables.

Doing this allows  me to simplify my tests and setup file.

// lib/pages/loginPage.ts

import { Page } from "@playwright/test";

export class LoginPage {
  readonly username = this.page.getByTestId("email");
  readonly password = this.page.getByTestId("password");
  readonly submit = this.page.getByTestId("login-submit");
  readonly navUserMenu = this.page.getByTestId("nav-user-menu");
  readonly navAdminMenu = this.page.getByTestId("nav-admin-menu");
  readonly navSignOut = this.page.getByTestId("nav-sign-out");
  readonly navSignIn = this.page.getByTestId("nav-sign-in");

  async goto() {
    await this.page.goto("/#/auth/login");
  }

  async login(email: string, password: string) {
    await this.goto();
    await this.username.fill(email);
    await this.password.fill(password);
    await this.submit.click();
  }

  constructor(private readonly page: Page) {}
}

State Files

Because the auth.setup.ts file is it's own Playwright project, I can actually run the setup steps on their own, when I do, a new directory is created .auth/ with the newly created storageState files inside. Now i went ahead and added .auth/ to my .gitignore file as I don't want to commit these files to my repository. I will share the example below, as this is a test site, but you should never share these files online for production sites.

// .auth/customer01.json

{
  "cookies": [],
  "origins": [
    {
      "origin": "https://practicesoftwaretesting.com",
      "localStorage": [
        {
          "name": "auth-token",
          "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJodHRwczovL2FwaS5wcmFjdGljZXNvZnR3YXJldGVzdGluZy5jb20vdXNlcnMvbG9naW4iLCJpYXQiOjE2ODk1NzQ4MTIsImV4cCI6MTY4OTU3NTExMiwibmJmIjoxNjg5NTc0ODEyLCJqdGkiOiJOb0d0bzFOVzRNN2ROVUJsIiwic3ViIjoiMiIsInBydiI6IjIzYmQ1Yzg5NDlmNjAwYWRiMzllNzAxYzQwMDg3MmRiN2E1OTc2ZjciLCJyb2xlIjoidXNlciJ9.6vLMZtL80xgVcsib8u7os5zalj4_U8KnQY6eELohFr0"
        }
      ]
    }
  ]
}


As you can see the storageState file isn't big, but it does include an entry in the localStorage with a key/value pair of auth-token with the value listed above. This is what the application needs to validate we are authenticated.

Example of the .auth directory

Now I have 3 active sessions that can be used in my tests, an admin user, customer01 and customer02. Let's see how we can use them now.

NOTE: I decided not to use set storageState within my playwright.config.ts file. This is an option that can be used but I want more control over my tests and state.

Using storageState in a Test

So I am cheating a bit in the example below, I am not utilizing Page Objects so there will be a bit more text on the screen. In the below example I have 3 different Describe blocks, 2 that show the customer journey and the other showing the admin journey when trying to access the account page.

The main thing to observe is the test.use({ storageState: "${pathToFile}" }); line of code. This is the magic that tells the browser to load the storageState into the browser context. One thing to note is this can be set on the highest level of the test or within a describe block. It cannot be set within a before or test blocks. This is one constraint we have when deciding on how we want to design our tests.

Note in the first 2 tests the customers are logged in successfully, while on the 3rd test the admin doesn't have access to the accounts page and by default gets redirected to the login screen. This is how the app operates.

// tests/account/account.spec.ts

import { test, expect } from "@playwright/test";

test.describe("Customer 01 my account specs", () => {
  test.use({ storageState: ".auth/customer01.json" });

  test("validate customer 01 my account page", async ({ page }) => {
    await page.goto("/#/account");

    expect(await page.getByTestId("nav-user-menu").innerText()).toContain(
      "Jane Doe"
    );
    expect(await page.getByTestId("page-title").innerText()).toContain(
      "My account"
    );
    expect(await page.getByTestId("nav-favorites").innerText()).toContain(
      "Favorites"
    );
  });
});

test.describe("Customer 02 my account specs", () => {
  test.use({ storageState: ".auth/customer02.json" });

  test("validate customer 02 my account page", async ({ page }) => {
    await page.goto("/#/account");

    expect(await page.getByTestId("nav-user-menu").innerText()).toContain(
      "Jack Howe"
    );
    expect(await page.getByTestId("page-title").innerText()).toContain(
      "My account"
    );
    expect(await page.getByTestId("nav-favorites").innerText()).toContain(
      "Favorites"
    );
  });
});

test.describe("Admin my account specs", () => {
  test.use({ storageState: ".auth/admin.json" });

  test("Validate admin my account page fails to load", async ({ page }) => {
    await page.goto("/#/account");

    expect(page.url()).toContain("/#/auth/login");
    expect(await page.getByTestId("nav-admin-menu").innerText()).toContain(
      "John Doe"
    );
  });
});

With this functionality you can build out your automation with a lot of control over each describe block within your tests. Some ways this can be used are validating Standard Users, Super Users, Read Only Users, Users with no authentication, etc.

We must however remember what "Uncle Ben" taught us

"With great power comes great responsibility"

Keeping Our Storage In a Good State

There are different instances in which your storageState can get in a ruined state. This is typically due to good security as a part of the application you are testing. Some examples include

  • Logging out of an application (this will more than likely invalidate your session token)
  • Logging in to the application in a new window (some applications only allow 1 authenticated session per user, the original token can become invalidated while the new token is available for use).
  • Tests that run longer than the token expiration (some applications will expire any tokens after 15 minutes) If your suite runs longer than this you will get 4xx authentication errors.

I won't cover every single scenario but I will cover the logout scenario with a solution. Below is my logout.spec.ts file.  

NOTE: the application I am testing isn't susceptible to the 2nd bullet point, I am able to login to the app multiple times (creating multiple auth tokens for each user).

Logout Test Example

In this scenario, I am calling a createAuth datafactory which will create a Temp Admin Auth file and a Temp Customer Auth file, using the default credentials.

I do this in the beforeEach block, the function which can be seen below createTemplateAdminAuth() returns a string of the file name which was created. So I store that value and pass it into the test on const adminContext = await browser.newContext({ storageState: tempAdminAuth }); step.

For the test I am not able to just use the existing page/context fixture as there is no storageState set, but Playwright gives us a way to set storageState when creating a browser.newContext().

I then take that adminContext and use that to create a Page Object const adminPage = await adminContext.newPage(); and const loginPage = new LoginPage(adminPage);.  Now that I am using the LoginPage I have access to the goto function which I use to visit an authenticated web page. From here I navigate the menu toSign Out. This will invalidate the temporary storageSession we created on the fly, and keep our modified session segmented from the rest of our tests.

// tests/auth/logout.spec.ts

import {
  createTempAdminAuth,
  createTempCustomerAuth,
} from "@datafactory/createAuth";
import { LoginPage } from "@pages";
import { test, expect } from "@playwright/test";

test.describe("Logout Specs", () => {
  let adminEmail = process.env.ADMIN_USERNAME;
  let adminPassword = process.env.ADMIN_PASSWORD;
  let tempAdminAuth: string;

  let customerEmail = process.env.CUSTOMER_01_USERNAME;
  let customerPassword = process.env.CUSTOMER_01_PASSWORD;
  let tempCustomerAuth: string;

  test.beforeEach(async ({ page }) => {
    tempAdminAuth = await createTempAdminAuth(page, adminEmail, adminPassword);
    tempCustomerAuth = await createTempCustomerAuth(
      page,
      customerEmail,
      customerPassword
    );
  });

  test("Logout from active admin session", async ({ browser }) => {
    const adminContext = await browser.newContext({
      storageState: tempAdminAuth,
    });
    const adminPage = await adminContext.newPage();
    const loginPage = new LoginPage(adminPage);
    await loginPage.goto();
    await loginPage.navAdminMenu.click();
    await loginPage.navSignOut.click();

    await expect(loginPage.navSignIn).toBeVisible();
  });

  test("Logout from active customer session", async ({ browser }) => {
    const customerContext = await browser.newContext({
      storageState: tempCustomerAuth,
    });
    const customerPage = await customerContext.newPage();
    const loginPage = new LoginPage(customerPage);

    await loginPage.goto();
    await loginPage.navUserMenu.click();
    await loginPage.navSignOut.click();

    await expect(loginPage.navSignIn).toBeVisible();
  });
});

In this file you can see how we create the temporary sessionStorage on the fly. I have hard coded values for the file name but you could easily refactor this to be generic and create as many temporary sessions as needed so there aren't any collisions.

// lib/datafactory/createAuth

// Save your storage state to a file in the .auth directory via setup test

import { expect } from "@playwright/test";
import { LoginPage } from "@pages";

const tempAdminAuthFile = ".auth/tempAdminAuth.json";
const tempUserAuthFile = ".auth/tempUserAuth.json";

export async function createTempAdminAuth(page, email, password) {
  const loginPage = new LoginPage(page);
  await loginPage.goto();
  await loginPage.login(email, password);
  expect(await loginPage.navAdminMenu.innerText()).not.toContain("Sign In");

  await page.context().storageState({ path: tempAdminAuthFile });
  return tempAdminAuthFile;
}

export async function createTempCustomerAuth(page, email, password) {
  const loginPage = new LoginPage(page);
  await loginPage.goto();
  await loginPage.login(email, password);
  expect(await loginPage.navUserMenu.innerText()).not.toContain("Sign In");

  await page.context().storageState({ path: tempUserAuthFile });
  return tempUserAuthFile;
}

To help visualize some of the files that were discussed in this article, I've tried to make some notes to help connect the dots.

Official Playwright Examples

The official Playwright Docs are below, and handle even more scenarios than what I am showing here, with some really great easy to follow examples. Check them out!

Authentication | Playwright
Playwright executes tests in isolated environments called browser contexts. This isolation model improves reproducibility and prevents cascading test failures. Tests can load existing authenticated state. This eliminates the need to authenticate in every test and speeds up test execution.

The pull request which includes the changes discussed above along with some other changes to this repo is available for viewing and inspection.


Thanks for reading! If you found this helpful, reach out and let me know on LinkedIn or consider buying me a cup of coffee. If you want more content delivered to you in your inbox subscribe below, and be sure to leave a ❤️ to show some love.