DEV Community

kadir
kadir

Posted on

Auth Once with storageState (Playwright + TypeScript, Ch.15)

Welcome to Part 4 — Integration: making the API and UI layers work together.
This is what separates a toy suite from a real one, and we start with the highest-value
example — logging in.

The problem

Logging in through the form on every UI test is slow (load the page, type the
email, type the password, click, wait for the redirect) and repetitive. Do it on a
hundred tests and you've wasted real minutes per run.

The idea: save the session

When you log in, the browser stores proof that you're logged in — in cookies and
in localStorage (a small key/value store the page keeps in the browser). Together
these are your session.

Playwright can save that session to a file and load it back into other tests.
A test that loads it opens the app already logged in — no form needed. That saved
file is called a storage state.

Code for this chapter is tagged ch-15 in the repo:
https://github.com/aktibaba/playwright-qa-course — see src/setup/auth.setup.ts,
playwright.config.ts, and src/tests/ui/authenticated.spec.ts.

A setup project that logs in once

Playwright lets you run a setup project — a special test file that runs before
your other tests and prepares things they need. Ours logs in and saves the session.

Here's the integration twist: instead of driving the slow login form, we log in
through the API (one fast request), then write the session into the browser's
localStorage and save it:

// src/setup/auth.setup.ts
import { test as setup, expect } from "@playwright/test";
import { env } from "@utils/env";
import { SEED_USERS } from "../fixtures/data.fixture";

const authFile = ".auth/playwright.json";

setup("authenticate", async ({ page, request }) => {
  const { email, password } = SEED_USERS.playwright;

  // 1. Log in via the API (no clicking) and get the token.
  const res = await request.post(`${env.apiURL}/users/login`, {
    data: { user: { email, password } },
  });
  expect(res.ok()).toBeTruthy();
  const { user } = await res.json();

  // 2. Write the exact session shape Inkwell reads on load, into localStorage.
  const session = { headers: { Authorization: `Token ${user.token}` }, isAuth: true, loggedUser: user };
  await page.goto("/");
  await page.evaluate((v) => localStorage.setItem("loggedUser", JSON.stringify(v)), session);

  // 3. Save cookies + localStorage to a file.
  await page.context().storageState({ path: authFile });
});
Enter fullscreen mode Exit fullscreen mode

A couple of new things:

  • page.evaluate(fn, arg) runs fn inside the browser page (where localStorage lives) and passes arg into it. That's how we set the value the app expects.
  • We discovered the exact localStorage shape (loggedUser) by reading Inkwell's source — Inkwell restores the session from localStorage.getItem("loggedUser") on load. Knowing one small detail of the app under test is the essence of integration testing.

Wire it up with project dependencies

We tell Playwright "run setup before ui" using dependencies:

// playwright.config.ts
projects: [
  { name: "api", testDir: "./src/tests/api", use: { baseURL: env.apiURL } },
  {
    name: "setup",
    testDir: "./src/setup",
    testMatch: /auth\.setup\.ts/,
    use: { baseURL: env.webURL },
  },
  {
    name: "ui",
    testDir: "./src/tests/ui",
    dependencies: ["api", "setup"], // setup runs first → the auth file exists
    use: { baseURL: env.webURL, ...devices["Desktop Chrome"] },
  },
],
Enter fullscreen mode Exit fullscreen mode

Opt a test into the session

You choose per file whether to start logged in. Our anonymous tests (home,
locators, login) stay logged out; only this file loads the saved session, with
test.use:

// src/tests/ui/authenticated.spec.ts
import { test, expect } from "@playwright/test";

test.use({ storageState: ".auth/playwright.json" });   // load the saved session

test("starts already logged in", async ({ page }) => {
  await page.goto("/");
  await expect(page.getByRole("link", { name: "New Article" })).toBeVisible();
  await expect(page.getByRole("navigation").getByText("playwright")).toBeVisible();
  await expect(page.getByRole("link", { name: "Sign up" })).toBeHidden();
});
Enter fullscreen mode Exit fullscreen mode

No LoginPage, no form, no redirect — the test opens the app and the user is already
there. Multiply that saving across a hundred tests.

The .auth/ folder is git-ignored — it holds a live token and is regenerated by the
setup project on every run, so it never goes into version control.

When to use which login

  • storageState (this chapter): the default for most authenticated tests — fast, shared, set up once.
  • Logging in through the form (LoginPage): keep it for the few tests whose subject is the login flow — you still want to prove the form itself works (the Chapter 4 test stays exactly as it was).

Next up

We used the API to set up auth. Next we generalise that to all test data.
Chapter 16 — Seed via API, verify in UI: create an article through the API in
milliseconds, then check it renders in the browser — the pattern that makes UI suites
fast and reliable. Tag: ch-16.

Following along? Star the repo
and tell me how many seconds storageState shaved off your suite.

Top comments (0)