Skip to main content

Command Palette

Search for a command to run...

Everything I needed to understand before Playwright tests stopped looking like magic

Updated
12 min read
Everything I needed to understand before Playwright tests stopped looking like magic

The first time I opened a properly structured Playwright project, I didn't understand a single line. Not because the code was complicated, but because every line used a concept I'd never seen assembled that way before. TypeScript classes extending abstract bases, fixtures injecting page objects from nowhere, private readonly locators, generics with angle brackets, builders chaining methods. It looked like someone had taken five different tutorials and merged them into one file while being chased by a deadline.

I could run the tests. They passed. But I couldn't write a new one because I didn't actually understand what any of it was doing, which is a humbling place to be when your job title says "QA Architect."

If you've ever stared at a structured Playwright test and felt that mix of "this is clearly better" and "I have no idea how to modify it," this article is what I wish someone had handed me. We're going to start from a finished test and pull on every thread until the whole thing makes sense.

Start from what you see

Here's a real test from a project built with Histrion, a CLI that scaffolds this kind of architecture automatically. Don't worry about understanding it yet, just read it like you'd read someone else's code for the first time:

test('should submit successfully with valid data', async ({ contactPage }) => {
  await contactPage.navigate();
  await contactPage.fillContactForm(ContactBuilder.create().build());
  await contactPage.submitForm();
  await contactPage.expectSuccessMessage();
});

Five lines. No selectors, no page.click(), no hardcoded data. It reads almost like a spec document that a product manager could review.

But where does contactPage come from? What is ContactBuilder? What does await actually do here? Why is there no import { page } from '@playwright/test'?

Every one of these questions leads to a concept that, once you get it, makes the whole architecture click. So let's follow the thread.

"Where does contactPage come from?"

This is usually the first thing that trips people up. The test function receives { contactPage } as a parameter, but nobody passes it in. It just... appears. Like a well-trained butler that you never hired.

This is a fixture. In Playwright, a fixture is a factory function that creates something your test needs and injects it automatically. You declare what you want, and the framework provides it.

The fixture definition looks like this:

// src/fixtures/index.ts
import { test as base } from '@playwright/test';
import { ContactPage } from '../pages/contact.page';

type TestFixtures = {
  contactPage: ContactPage;
};

export const test = base.extend<TestFixtures>({
  contactPage: async ({ page }, use) => {
    await use(new ContactPage(page));
  },
});

The line contactPage: async ({ page }, use) => { await use(new ContactPage(page)) } says: "when a test asks for contactPage, create a new ContactPage instance and hand it over." The page that gets passed into ContactPage is Playwright's built-in browser page object, which is itself a fixture that Playwright provides for free.

Two things to notice here. First, tests never call new ContactPage(page) themselves, the fixture handles that. Second, fixtures are lazy: if a test doesn't ask for contactPage, the factory never runs. No waste.

From now on, every test in the project imports test from this fixtures file instead of directly from @playwright/test. That one change is what makes the injection work.

"What is ContactPage and why is it a class?"

The fixture creates a ContactPage. That's a page object: a class that represents one page of your application. It knows where the elements are (locators) and how to interact with them (methods), but it keeps those details hidden from the test.

// src/pages/contact.page.ts
import type { Page } from '@playwright/test';
import { BasePage } from '../core/base.page';

export class ContactPage extends BasePage {
  readonly path = '/contact';
  readonly pageTitle = /Contact/;

  private readonly firstNameInput = this.page.getByLabel('First name');
  private readonly lastNameInput = this.page.getByLabel('Last name');
  private readonly emailInput = this.page.getByLabel('Email');
  private readonly sendButton = this.page.getByRole('button', { name: 'Send' });

  async fillContactForm(data: ContactFormData): Promise<void> {
    await this.fill(this.firstNameInput, data.firstName, 'first name');
    await this.fill(this.lastNameInput, data.lastName, 'last name');
    await this.fill(this.emailInput, data.email, 'email');
  }

  async submitForm(): Promise<void> {
    await this.click(this.sendButton, 'Send button');
  }
}

There's a lot of vocabulary packed into this file, so let's unpack it piece by piece.

class and extends

A class is a blueprint for creating objects. ContactPage is the blueprint, and the fixture creates an instance of it (with new ContactPage(page)).

extends BasePage means ContactPage inherits everything from BasePage, a parent class that provides shared methods like fill(), click(), and navigate(). ContactPage doesn't have to define those itself, it gets them for free. That's what extends does: the child gets everything the parent has, plus whatever it adds on its own.

private readonly

The locators (firstNameInput, sendButton, etc.) are marked private readonly. Two keywords, two purposes.

private means the test cannot access the locator directly. You can't write contactPage.sendButton.click() in a test, you have to go through the public method submitForm(). This is the whole point of a page object: the test describes what it wants to do, the page object handles how.

readonly means the value is assigned once and can never change. Nobody can accidentally write this.sendButton = somethingElse later. It's a safety net against your future self at 11pm on a Friday.

async, await, and Promise<void>

Every interaction with the browser is asynchronous, meaning it takes time and the code needs to wait for it to finish. await this.fill(...) tells TypeScript "pause here until the field is actually filled before moving to the next line."

Without await, the code would fire all the interactions simultaneously and crash because the page isn't ready. Think of it as trying to fill a form while the page is still loading, which is exactly as productive as it sounds. In Playwright, forgetting an await is one of the most common bugs, and the worst part is that it sometimes works, just often enough to make you think your code is fine until it isn't.

void just means the function doesn't return a value. It does something (fills a field, clicks a button) but there's no result to capture.

Locator strategies: getByLabel, getByRole, getByTestId

Those this.page.getByLabel('First name') calls are locators: references to DOM elements on the page. Playwright offers several strategies to find elements, and the choice matters.

getByRole finds elements by their accessible role (button, link, heading...). It's the most resilient strategy because it mirrors how a screen reader navigates the page. If a developer changes the CSS class or the HTML structure but keeps the button accessible, the locator still works.

getByLabel finds an input by the text of its associated <label>. Good for form fields because the label text rarely changes.

getByTestId finds an element by a data-testid attribute. It's the fallback when accessible locators don't work, like for a generic <div> that has no semantic role. It requires developers to add the attribute in the source code, which is a small cost but makes tests independent of the visual layout.

The priority is: accessible locators first (getByRole, getByLabel), test IDs as a last resort. A locator is just a reference, by the way. It doesn't search the page when you create it. The actual search only happens when you act on it (.click(), .fill(), etc.).

"What's BasePage and why does it exist?"

ContactPage extends BasePage, which means there's a parent class that provides the shared methods. Here's a simplified version:

// src/core/base.page.ts
export abstract class BasePage {
  abstract readonly path: string;
  abstract readonly pageTitle: string | RegExp;

  constructor(protected readonly page: Page) {
    this.log = new Logger(this.constructor.name);
  }

  async navigate(): Promise<this> {
    await this.page.goto(this.path);
    return this;
  }

  protected async fill(locator: Locator, value: string, description: string): Promise<void> {
    this.log.action(`Fill "\({description}" with "\){value}"`);
    await locator.clear();
    await locator.fill(value);
  }

  protected async click(locator: Locator, description: string): Promise<void> {
    this.log.action(`Click: ${description}`);
    await locator.click();
  }
}

abstract

An abstract class is a class you can't instantiate directly. You can't write new BasePage(page) because it's incomplete on purpose, like a template with blanks you're required to fill in. It exists only to be extended by concrete classes like ContactPage or LoginPage.

abstract readonly path: string means "every class that extends me must define a path property." If ContactPage forgets to set path, TypeScript throws a compile error before you even run anything. It's the compiler being annoying in the way you actually want it to be annoying.

protected

protected is the middle ground between public and private. A protected method is invisible to tests (like private) but accessible to child classes (unlike private). That's why fill() and click() are protected in BasePage: ContactPage can call this.fill(...), but the test can't call contactPage.fill(...) directly.

constructor and super()

The constructor is the function that runs when you create an instance with new. BasePage's constructor takes a page parameter (the browser) and saves it as a property so that every method can use it.

When ContactPage extends BasePage, its own constructor needs to call super(page) first. That's TypeScript's way of saying "before you do your own setup, let the parent do its setup." super() calls BasePage's constructor, which stores the page reference and creates the logger.

"What's ContactBuilder.create().build()?"

Back in the original test, there's this line:

await contactPage.fillContactForm(ContactBuilder.create().build());

A builder is a pattern for constructing test data objects with sensible defaults. Instead of writing { firstName: 'Test', lastName: 'User', email: 'test@example.com' } in every test (we've all done it, no judgment), you call ContactBuilder.create().build() and get a fully valid object with random, realistic data generated by Faker.js (a library that produces fake but plausible names, emails, addresses, and anything else you'd find in a form).

// src/data/builders/contact.builder.ts
export class ContactBuilder extends Builder<ContactFormData> {
  private constructor() {
    super({
      firstName: faker.person.firstName(),
      lastName: faker.person.lastName(),
      email: faker.internet.email(),
      subject: faker.helpers.arrayElement(SUBJECTS),
      message: faker.lorem.sentence({ min: 5, max: 20 }),
    });
  }

  static create(): ContactBuilder {
    return new ContactBuilder();
  }

  withEmail(email: string): this {
    this.data.email = email;
    return this;
  }
}

Generics: Builder<ContactFormData>

The <ContactFormData> part is a generic. Think of it as a variable for types. Builder<T> is a base class that works with any data shape. When you write Builder<ContactFormData>, you're telling TypeScript "this particular builder builds objects that match the ContactFormData interface." If you try to add a field that doesn't exist on ContactFormData, the compiler catches it.

Same class, different type parameter: Builder<Product> would build product objects, Builder<User> would build user objects. The logic stays the same, only the shape changes.

The ...spread operator

Inside the base builder, you'll see { ...defaults } and { ...this.data }. The spread operator (...) copies all properties from one object into another. { ...defaults } creates a fresh copy of the defaults so that modifying one builder's data doesn't affect another. Fields listed after the spread override the copied values, which is how .withEmail('custom@test.com') replaces just the email while keeping everything else.

interface and type

The builder works with ContactFormData, which is an interface: a definition of what fields an object must have and what types they must be. No logic, just the contract.

export interface ContactFormData {
  firstName: string;
  lastName: string;
  email: string;
  subject: ContactSubject;
  message: string;
}

export type ContactSubject = "customer-service" | "webmaster" | "return" | "payments";

A type union like ContactSubject restricts the possible values. You can't pass "blabla" as a subject because TypeScript knows it's not in the list. The builder uses faker.helpers.arrayElement(SUBJECTS) to randomly pick one valid value from the array, so every generated contact has a realistic, type-safe subject.

"What about import and export?"

You might have noticed that every class starts with export and every file starts with import statements. These are TypeScript's module system.

export makes a class, function, or type available to other files. Without it, the code stays locked inside its own file. import pulls something from another file into the current one.

// This file exports the class
export class ContactPage extends BasePage { ... }

// Another file imports it
import { ContactPage } from '../pages/contact.page';

The path in the import ('../pages/contact.page') is relative to the current file. The .. means "go up one directory." In a structured project, you'll often see path aliases like @pages/contact.page instead, which are configured in tsconfig.json to avoid those ../../../ chains that make you question your career choices.

The full picture

Now you can re-read that original test and understand every piece:

test('should submit successfully with valid data', async ({ contactPage }) => {
  await contactPage.navigate();
  await contactPage.fillContactForm(ContactBuilder.create().build());
  await contactPage.submitForm();
  await contactPage.expectSuccessMessage();
});

async ({ contactPage }) : the fixture creates a ContactPage instance and injects it.

await contactPage.navigate() : calls BasePage's navigate(), which does page.goto(this.path). await waits for the page to load.

ContactBuilder.create().build() : creates a fresh set of random, valid form data using Faker.js. Every run uses different values.

contactPage.fillContactForm(...) : the page object fills the form fields using its private locators. The test never sees a selector.

contactPage.expectSuccessMessage() : an assertion wrapped in the page object. If the success alert doesn't appear, the test fails.

Five lines that took an entire article to explain, which either means the architecture is too complex or that I'm getting paid by the word. I'd argue it's neither. All the complexity is hidden behind clear interfaces. Once you understand the machinery, writing a new test is almost boring: declare what you need, call methods that read like English, assert the result.

And boring tests are exactly what you want, because it means you're spending your time testing behavior instead of fighting infrastructure. The person who built this framework did the suffering so you don't have to. The least you can do is understand what they built.

5 views