top of page

Playwright Hooks in Action: Saving Login State Using storageState and Reusing BrowserContext

Updated: Feb 25

In real-world automation, logging in before every test is inefficient and slows down execution. As tests grow, repeated login steps also make scripts harder to maintain and harder to scale.

Playwright provides two important mechanisms to solve this problem:

Hooks – to control setup and cleanup

storageState – to save and reuse authentication


In this post, we will use Playwright hooks together with storageState to perform a login once and reuse the authenticated session across multiple tests.

This is a foundational pattern used in real-world Playwright automation frameworks.


What are Hooks in Playwright?


Hooks are lifecycle functions that run automatically at specific stages of test execution.

They allow us to define setup and cleanup logic separately from the test itself.

Playwright provides four hooks:

Hook

Purpose

beforeAll

Runs once before all tests

beforeEach

Runs before every test

afterEach

Runs after every test

afterAll

Runs once after all tests

Hooks are ideal for tasks like:

• login setup

• test data preparation

• browser setup

• cleanup


Instead of repeating setup steps inside every test, we can place them inside hooks.


Overall Plan for This Post

We will implement authentication reuse in a structured way using hooks and storageState.


Our plan is:


Step 0: Move test data out of the script for cleaner design

Step 1: Use beforeAll hook to login once and save authentication state

Step 2: Reuse saved authentication in tests to skip login

Step 3: Use afterAll hook to clean up browser context


This approach improves performance, readability, and scalability.


Starting Point: Our Existing Purchase Workflow Test

We will use the purchase product workflow created in the previous post, where we implemented a complete end-to-end purchase flow using:


• Playwright built-in locators

• Assertions

• Real user interactions


Before introducing hooks, let’s first clean up the test by separating test data from test logic.

Step 0: Move Test Data into Variables

In our current test, credentials and URLs are hardcoded:

await page.getByRole('textbox', { name: /username/i })  .fill('anuradha.learn@gmail.com');await page.getByRole('textbox', { name: /password/i })  .fill('Play@1234#$');

This makes maintenance harder. If credentials change, multiple places must be updated.

Instead, we move data to variables at the top:

const baseURL = "https://qa-cart.com/";
const username = "anuradha.learn@gmail.com";
const password = "Play@1234#$";
const storageStatePath = "state.json";

This improves readability and maintainability.

Now we are ready to introduce hooks.


Step 1: Login and Save Authentication State using beforeAll Hook

First, we declare a BrowserContext variable:

let webContext: BrowserContext;

Note the explicit type declaration.

In TypeScript, we must tell the compiler that webContext is a BrowserContext. Otherwise, TypeScript assigns the type any, which is not allowed in strict mode.


Understanding the browser Fixture


Now look at the beforeAll hook:

test.beforeAll(async ({ browser }) => {

The browser parameter is a built-in Playwright fixture.

It represents the active browser instance, which Playwright automatically launches based on configuration.


We do not need to manually launch the browser.


Using this browser fixture, we can create a new BrowserContext:

const myContext = await browser.newContext();

This creates a fresh browser profile.


Why create a new BrowserContext?

When you log in manually, the browser stores authentication information such as:

• Cookies

• Local storage


These allow the website to recognise you as logged in.

For example, after logging into qa-cart.com, refreshing the page does not ask you to log in again.

This happens because the browser sends stored cookies back to the server.

Playwright follows the same model.

Authentication is stored at the BrowserContext level.

Architecture:

Browser   ← provided by browser fixture 
↓Context   ← stores cookies and localStorage  
 ↓Page 
  ↓Test

This means if we save the context, we save the login session.


Our Goal: Login Once and Reuse Authentication


Instead of logging in before every test, we will:

• Create a fresh context

Perform login once

• Save authentication using storageState

• Reuse authentication in future tests

This works because storageState saves:

• Cookies (used for authentication)

• Local storage (application state)


When restored, the application recognizes the user as already logged in.


Why does this improve automation?

Without storageState:

Test 1 → Login → Execute testTest 2 → Login → Execute testTest 3 → Login → Execute test

With storageState:

Login once   ↓Test 1 → ExecuteTest 2 → ExecuteTest 3 → Execute

This makes tests:

• Faster

• Cleaner

• More scalable

Implementing Login using beforeAll Hook



Playwright saves authentication inside state.json.


This file contains cookies and local storage required to restore login.


You can also observe local storage by using google inspect tool ->application tab



storageState saves cookies and local storage from a BrowserContext and allows them to be reused later to restore login state.


When we execute:

await myContext.storageState({ path: storageStatePath });

Playwright creates a file (state.json) containing:

• Cookies (used for authentication)

• Local storage (used for application state)

These cookies act as proof to the server that the user is already authenticated.


Later, when we create a new BrowserContext using this saved state, Playwright restores those cookies and local storage, and the application recognizes the user as already logged in.


This allows us to skip the login step completely.


Step 2: Reuse the Saved Authentication State in Test


Now that we have saved authentication using the beforeAll hook, we can reuse it inside our test.


Instead of logging in again, we create a new page using the authenticated context:

test('Purchase product as logged-in user', async () => {  
  const page = await webContext.newPage();   
 await page.goto(baseURL);
});

Notice that we are using:

webContext.newPage();

This webContext was created earlier using:

webContext = await browser.newContext({storageState: storageStatePath});

Because this context already contains cookies

and local storage, Playwright restores the authenticated session automatically.


As a result:

• The login page does not appear

• The test starts as a logged-in user

• We can directly continue with business workflow


This is the key benefit of using storageState.


Step 3: Cleanup using afterAll Hook


After all tests are complete, we close the browser context:

test.afterAll(async () =>
 {    
await webContext?.close();
}
);

This ensures proper cleanup of browser resources.

The afterAll hook runs only once after all tests in the file complete.

This prevents memory leaks and ensures clean test execution.


Let’s observe how hooks organise our test lifecycle:

beforeAll   → Login once and save authentication  
 ↓Test        → Reuse saved authentication
  ↓afterAll    → Cleanup browser context

The beforeAll hook prepares the environment.

The test executes the workflow.

The afterAll hook cleans up resources.


Final Workflow with Clear Steps


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

let webContext: BrowserContext;

// Test data
const baseURL = "https://qa-cart.com/";
const username = "anuradha.learn@gmail.com";
const password = "Play@1234#$";
const maxPrice = 25;
const storageStatePath = "state.json";

test.beforeAll(async ({ browser }) => {
  const myContext = await browser.newContext();
  const page = await myContext.newPage();

  await test.step("Authenticate once and save storageState", async () => {
    await page.goto(baseURL);

    await expect(page.getByRole("heading", { name: /login/i })).toBeVisible();

    await page.getByRole("textbox", { name: /username/i }).fill(username);
    await page.getByRole("textbox", { name: /password/i }).fill(password);
    await page.getByRole("button", { name: /log in/i }).click();

    await expect(page.getByRole("link", { name: /log out/i }).first()).toBeVisible();

    await myContext.storageState({ path: storageStatePath });
  });

  webContext = await browser.newContext({ storageState: storageStatePath });
  await myContext.close();
});

test.afterAll(async () => {
  await webContext?.close();
});

test("E2E: Search → Filter → Add to Cart → Checkout → Verify Order → Logout", async () => {
  const page = await webContext.newPage();

  // We'll capture these during the flow
  let productName = "";
  let orderId = "";

  await test.step("Open DemoShop", async () => {
    await page.goto(baseURL);
    await page.getByRole("link", { name: "DemoShop" }).click();
    await expect(page.getByRole("heading", { name: "DemoShop" })).toBeVisible();
  });

  await test.step('Search products with keyword "organic"', async () => {
    await page.getByRole("searchbox", { name: /search/i }).fill("organic");
    await page.getByRole("button", { name: /search/i }).click();

    await expect(page.getByRole("heading", { name: /search results.*organic/i })).toBeVisible();
  });

  await test.step(`Apply max price filter (${maxPrice}) and confirm filter applied`, async () => {
    const maxPriceInput = page.getByRole("textbox", { name: /maximum price/i });

    await maxPriceInput.clear();
    await maxPriceInput.fill(String(maxPrice));
    await maxPriceInput.press("Enter");

    await expect(page.getByRole("button", { name: /remove price up to/i })).toBeVisible();
    await expect(page.getByRole("alert")).toContainText(/\d+ results/i);
  });

  await test.step(`Validate all listed products have price <= ${maxPrice}`, async () => {
    const productGrid = page.locator("ul.products");
    await expect(productGrid).toBeVisible();

    const products = productGrid.locator(":scope > li.product");
    const countItems = await products.count();
    expect(countItems).toBeGreaterThan(0);

    for (let i = 0; i < countItems; i++) {
      const product = products.nth(i);

      const priceText = await product.locator("span.price bdi").first().innerText();
      const price = parseFloat(priceText.replace("$", "").trim());

      expect(price).toBeLessThanOrEqual(maxPrice);
    }
  });

  await test.step("Add first product to cart and capture selected product name", async () => {
    const productGrid = page.locator("ul.products");
    const products = productGrid.locator(":scope > li.product");
    const firstProduct = products.first();

    productName = (await firstProduct.locator(".woocommerce-loop-product__title").innerText()).trim();
    expect(productName).toBeTruthy();

    const addBtn = firstProduct.locator("a.add_to_cart_button");
    await addBtn.click();

    // Wait for state changes
    await expect(addBtn).not.toHaveClass(/loading/, { timeout: 15000 });
    await expect(addBtn).toHaveClass(/added/, { timeout: 15000 });

    const cartLink = page.getByRole("link", { name: /^View Shopping Cart/i });
    await expect(cartLink).toBeVisible();
  });

  await test.step("Open cart and verify selected product is present", async () => {
    await page.goto("https://qa-cart.com/mycart/");
    await expect(page).toHaveURL(/mycart/);

    const cartRows = page.locator("table.cart tr.cart_item");
    await expect(cartRows.first()).toBeVisible();

    const countRows = await cartRows.count();
    expect(countRows).toBeGreaterThan(0);

    let productFound = false;
    for (let i = 0; i < countRows; i++) {
      const cartProductName = (await cartRows.nth(i).locator("td.product-name").innerText()).trim();
      if (cartProductName.includes(productName)) {
        productFound = true;
        break;
      }
    }
    expect(productFound).toBeTruthy();
  });

  await test.step("Proceed to checkout and place the order", async () => {
    const checkoutLink = page.getByRole("link", { name: /proceed to checkout/i });
    await checkoutLink.scrollIntoViewIfNeeded();
    await expect(checkoutLink).toBeVisible();
    await checkoutLink.click();

    await expect(page).toHaveURL(/checkout/i, { timeout: 15000 });
    await expect(page.getByRole("heading", { name: /checkout/i })).toBeVisible({ timeout: 15000 });
    await expect(page.locator("form.checkout")).toBeVisible({ timeout: 15000 });

    const placeOrder = page.locator("#place_order");
    await expect(placeOrder).toBeVisible({ timeout: 15000 });
    await placeOrder.scrollIntoViewIfNeeded();
    await placeOrder.click();

    await expect(page.getByText(/your order has been received/i)).toBeVisible({ timeout: 15000 });

    orderId = (await page.locator("ul.order_details>li.order strong").innerText()).trim();
    expect(orderId).toBeTruthy();
  });

  await test.step("Go to My Account → Orders and verify the order exists", async () => {
    await page.getByRole("link", { name: /my account/i }).click();
    await expect(page.getByRole("heading", { name: /my account/i })).toBeVisible();

    await page.getByRole("link", { name: /^orders$/i }).first().click();
    await expect(page).toHaveURL(/orders/i);

    const ordersTable = page.locator("table.woocommerce-orders-table");
    await expect(ordersTable).toBeVisible();

    // Find and open matching order
    const orderRowLink = ordersTable.getByRole("link", { name: `View order number ${orderId}` });
    await expect(orderRowLink).toBeVisible();
    await orderRowLink.click();

    await expect(page).toHaveURL(new RegExp(`view-order.*${orderId}`), { timeout: 15000 });

    await expect(
      page.getByRole("heading", { name: new RegExp(`Order\\s*#${orderId}`, "i") })
    ).toBeVisible();

    await expect(page.getByRole("heading", { name: `Order #${orderId}` })).toBeVisible();
  });

  await test.step("Logout", async () => {
    await page.getByRole("link", { name: /my account/i }).click();
    await expect(page.getByRole("heading", { name: /my account/i })).toBeVisible();

    const logoutLink = page.getByRole("link", { name: /log out/i }).first();
    await expect(logoutLink).toBeVisible();
    await logoutLink.click();

    // Optional: verify we are back on login page
    await expect(page.getByRole("heading", { name: /login/i })).toBeVisible();
  });

  await page.close();
});

Never Miss a Post. Subscribe Now!

Thanks for submitting!

©anuradha agarwal

    bottom of page