TechByteByByte

Part 29: Advanced Playwright

Use advanced Playwright capabilities to model complex application workflows.

Advanced Playwright combines familiar parts—contexts, pages, events, networking, and helpers—for harder stories.

building blocks → coordinated scenario → meaningful check

Series: Playwright Zero to Expert | Demo app used throughout this series: saucedemo.com

This part collects a set of genuinely advanced patterns that build directly on fixtures (Part 15), configuration (Part 16), and authentication (Part 18) — the kind of techniques you’ll reach for once a suite is large, mature, and needs capabilities beyond what a straightforward, individual test can express on its own.


Advanced Fixtures — Auto-Fixtures

Recall Part 15: a fixture only runs when a test explicitly requests it as a parameter. Sometimes, though, you genuinely want a fixture’s setup to run for every test automatically, without every single test needing to remember to declare it — a good candidate for this is something like automatically attaching a console-error listener to catch unexpected JavaScript errors on every page, across the entire suite:

export const test = base.extend<{}, { forEachTest: void }>({
  forEachTest: [
    async ({ page }, use) => {
      const errors: string[] = [];
      page.on("pageerror", (error) => errors.push(error.message));

      await use();

      if (errors.length > 0) {
        throw new Error(`Unexpected console errors: ${errors.join(", ")}`);
      }
    },
    { auto: true },
  ], // auto: true means this runs for every test automatically
});

{ auto: true } is the key mechanism here — it inverts Part 15’s default behavior, making this fixture run unconditionally for every test in the suite, without any test needing to declare it as a parameter at all. This is genuinely powerful for exactly this kind of cross-cutting concern: catching unexpected JavaScript errors is something every test should arguably care about, regardless of what it’s specifically testing, and requiring every single test author to remember to opt into it manually would be both tedious and easy to forget.

Analogy: The Autopilot Safety System & Custom Dashboard Gauges

  • Auto-Fixtures (Autopilot Blind-Spot Monitor): When driving a high-end car, you don’t manually check a checklist to turn on the blind-spot monitors before every single turn you make (declaring a parameter in every test). The car has auto-engaged safety sensors ({ auto: true }) running silently in the background of every trip, alert-warning you if you drift out of your lane.
  • Custom Matchers (Custom Dashboard Warning Light): Standard cars have generic warnings: “Engine Check” (which could mean anything from a loose gas cap to a broken cylinder block, like a generic toHaveText fail). A customized racing car has a specific, bright warning dial on the wheel: “Oil Temperature Critical” (toHaveCartCount(3)). It tells you exactly what matters for your specific engine in plain language.

📊 Visual Flowchart: Advanced Automation Execution Sequence

Here is the execution order from project-level setup blocks through auto-fixtures down to custom assertions:

graph TD
    subgraph Phase1 ["Phase 1: Setup Dependencies (playwright.config)"]
        SetupProject["Setup Project Runs First"] --> SaveState["Save state: user.json"]
    end

subgraph Phase2 ["Phase 2: Fixture Activation (For every test)"]
        SaveState --> AutoFix["Auto-Fixture: pageerror listener initialized<br>(auto: true)"]
        AutoFix --> APIAuth["APIAuth: Bypasses UI form & injects JWT token"]
    end

subgraph Phase3 ["Phase 3: Test Execution and Assertion"]
        APIAuth --> Nav["page.goto('/inventory')"]
        Nav --> Action["Add item to cart"]
        Action --> CustomAssert["Custom Matcher: expect().toHaveCartCount(1)"]
    end

Custom Matchers

Recall Part 9’s built-in matchers like .toBeVisible(). Playwright lets you define your own, domain-specific ones, which can make test assertions read more clearly for concepts genuinely specific to your own application:

import { expect as baseExpect } from "@playwright/test";

export const expect = baseExpect.extend({
  async toHaveCartCount(locator, expected: number) {
    const text = await locator.textContent();
    const actual = text ? parseInt(text) : 0;
    const pass = actual === expected;

    return {
      pass,
      message: () =>
        `Expected cart badge to show ${expected}, but got ${actual}`,
    };
  },
});
// Instead of:
await expect(page.locator(".shopping_cart_badge")).toHaveText("3");

// You can now write:
await expect(page.locator(".shopping_cart_badge")).toHaveCartCount(3);

This is a genuinely worthwhile investment specifically once a particular kind of check gets used repeatedly across a suite — toHaveCartCount both reads more clearly (expressing intent, not implementation) and centralizes the actual comparison logic in one place, exactly the same underlying benefit Part 20’s Page Object Model provides for locators and actions, applied here to assertions instead.

Complex Authentication — Beyond Storage State

Part 18 covered saving and reusing storage state for a single, straightforward login. Real applications sometimes need more nuanced handling — a token that expires and needs periodic refreshing, or a genuinely multi-step login flow (username, then a separate password step, then a two-factor code) that’s awkward to fully replay through the UI for every fresh authentication.

// A fixture that authenticates via a direct API call, bypassing the UI login form entirely
export const test = base.extend<{ apiAuthenticatedPage: Page }>({
  apiAuthenticatedPage: async ({ page, request }, use) => {
    const response = await request.post("/api/login", {
      data: { username: "standard_user", password: "secret_sauce" },
    });
    const { token } = await response.json();

    await page.addInitScript((token) => {
      window.localStorage.setItem("authToken", token);
    }, token);

    await use(page);
  },
});

This directly combines Part 17’s API testing capability with Part 18’s authentication concepts — logging in via a fast, direct API call rather than the UI at all, then injecting the resulting token directly into the browser’s storage before any test code actually navigates anywhere, using page.addInitScript() to run that injection before the page’s own JavaScript first runs. This is meaningfully faster than even a UI-based storage-state approach, since it skips rendering the login page entirely.

Advanced Parallelization — Dependency Between Projects

Recall Part 16’s projects array. Projects can be configured to depend on each other, letting one project’s setup run once, before other projects that need it:

projects: [
  {
    name: 'setup',
    testMatch: /global-setup\.spec\.ts/, // a dedicated "test" that performs setup, not a real assertion-based test
  },
  {
    name: 'chromium',
    use: { ...devices['Desktop Chrome'], storageState: 'playwright/.auth/user.json' },
    dependencies: ['setup'], // ensures 'setup' project runs first, and completes, before this one begins
  },
],

This is a more explicit, composable evolution of Part 18’s globalSetup function — instead of one single global setup script, you can have a dedicated setup project (itself potentially containing multiple, organized setup steps) that other projects explicitly declare a dependency on, which scales more cleanly once a framework needs several distinct kinds of setup for different groups of tests.


How It Works in a Real Test Run

Advanced features are valuable when they remove repeated framework code while keeping test intent visible. Auto-fixtures can collect evidence, custom matchers can express domain outcomes, and dependency projects can prepare reusable state.

Every abstraction needs an ownership boundary, useful error message, deterministic teardown, and a small example proving how it behaves on failure. Cleverness that hides control flow makes the suite harder to debug.

Interview Questions

Q: What does { auto: true } do for a fixture, and what’s a genuinely good use case for it?

Ans: It makes a fixture run automatically for every test in the suite, without any individual test needing to explicitly declare it as a parameter. A good use case is a cross-cutting concern that arguably every test should benefit from, regardless of what it’s specifically testing — like automatically catching and failing on unexpected JavaScript console errors on every page, which would be tedious and error-prone to require every test author to manually opt into.

Q: Why would a team invest in writing a custom matcher like toHaveCartCount instead of just using the built-in toHaveText?

Ans: A custom matcher can express intent more clearly — toHaveCartCount(3) directly communicates what’s actually being verified, rather than requiring the reader to understand that a raw text comparison happens to represent a cart count. It also centralizes the actual comparison logic (like parsing the text into a number) in one place, so if that logic ever needs to change, it only needs to be updated once, rather than wherever a similar raw comparison happens to be duplicated across the suite.

Q: Why might authenticating via a direct API call, and injecting the resulting token via addInitScript, be preferable to Part 18’s UI-based storage state approach for some scenarios?

Ans: It’s meaningfully faster, since it entirely skips rendering and interacting with the login page — even Part 18’s storage-state reuse still requires an initial real UI login to generate that saved state at least once. For scenarios needing very frequent fresh authentication (like tokens that expire quickly and need frequent regeneration), authenticating directly via the API can be a more efficient, more direct approach.

Q: What problem does declaring dependencies between projects (dependencies: ['setup']) solve, compared to a single globalSetup function?

Ans: It provides a more explicit, composable way to organize setup, especially once a framework needs several genuinely distinct kinds of setup serving different groups of tests, rather than cramming all setup logic into one single global function. Different projects can depend on different, appropriately scoped setup projects, making the overall structure clearer and easier to maintain as a framework’s setup needs grow more complex.


Exercises — Part 29

Understand: Explain, in your own words, why an auto-fixture is a meaningfully different mechanism than simply calling a setup function manually at the top of every test file, even though both could technically achieve similar results.

Simple Practice: Write a custom matcher for a concept specific to a page you’ve tested earlier in this series (for instance, toBeOutOfStock for a product) and use it in place of an equivalent built-in matcher in at least one existing test.

Real-World Scenario: Design an auto-fixture that automatically fails any test where a page’s response includes an unexpected 4xx or 5xx status code for any network request during the test (using Part 19’s route interception concepts to observe, not modify, responses), without any individual test needing to explicitly check for this itself.

Challenge: Research Playwright’s project dependency documentation further, and design a three-project setup (a dedicated setup project, followed by two feature-area projects — like cart-tests and checkout-tests — that both depend on it) for a hypothetical, larger SauceDemo test framework.


Next: Part 30 — Advanced TypeScript for Playwright

— generics, typed fixtures, utility types, and building genuinely type-safe, reusable framework abstractions.

Author
TechByteByByte Editorial Team
Reviewed by
TechByteByByte Admin
Published
Last reviewed