experiment bespoke · 3 interactives series/playwright-ts part 2 of 4
PLAYWRIGHT TS · PART 2

The Construction

Building the four-layer architecture, layer by layer, with code that runs.

Date2026-06-25
Read~14 min
Words~3,200
Revisions1

§ 1Project skeleton

Before any code, the folder layout. This is the thing that makes the rest possible :

1 playwright-project/
2 ├── playwright.config.ts
3 ├── biome.json
4 ├── tsconfig.json
5 ├── .env
6 ├── .env.example
7 ├── global-setup.ts
8 ├── src/
9 │ ├── core/ # Abstract base classes
10 │ │ ├── base.page.ts
11 │ │ ├── base.component.ts
12 │ │ └── base.api.ts
13 │ ├── components/ # Reusable UI components
14 │ │ ├── table.component.ts
15 │ │ ├── modal.component.ts
16 │ │ ├── form.component.ts
17 │ │ └── toast.component.ts
18 │ ├── pages/ # Page Objects
19 │ │ ├── login.page.ts
20 │ │ ├── dashboard.page.ts
21 │ │ └── contact.page.ts
22 │ ├── fixtures/ # Dependency injection
23 │ │ └── index.ts
24 │ ├── api/ # API clients for setup/teardown
25 │ │ └── user.api.ts
26 │ ├── data/ # Test data
27 │ │ ├── builders/
28 │ │ │ ├── base.builder.ts
29 │ │ │ └── contact.builder.ts
30 │ │ └── types/
31 │ │ └── index.ts
32 │ ├── config/ # Environment management
33 │ │ ├── env.config.ts
34 │ │ └── users.config.ts
35 │ ├── reporters/ # Custom reporter
36 │ │ └── html-report.ts
37 │ └── utils/ # Logger, matchers, visual helpers
38 │ ├── logger.ts
39 │ ├── custom-matchers.ts
40 │ └── visual.ts
41 ├── tests/
42 │ ├── e2e/
43 │ │ ├── login.spec.ts
44 │ │ └── contact.spec.ts
45 │ └── visual/
46 │ └── visual.spec.ts
47 └── docs/ # Local documentation (gitignored)

Tests live in tests/, framework code lives in src/ where it gets the same care you'd give any library code. The docs/ folder is gitignored and holds local onboarding guides, the kind of stuff your future self or your next hire is going to need but that doesn't belong in the public repo.

The folder layout is the convention. There's no helpers/ because helpers go in utils/. There's no framework/ because that's just core/ with a worse name. Locators don't end up in spec files because spec files don't import from anywhere that defines locators. The architecture is enforced by the import graph, not by tooling.

§ 2Naming conventions

The folder layout works because the files inside it follow a predictable naming pattern. The suffix tells you which layer the file belongs to :

File type Pattern Example
Page Object {name}.page.ts login.page.ts
Component {name}.component.ts table.component.ts
Test spec {name}.spec.ts login.spec.ts
Builder {name}.builder.ts user.builder.ts
API client {name}.api.ts auth.api.ts
Config {name}.config.ts env.config.ts

This isn't just visual cleanliness. The suffix is the contract : a file ending in .page.ts extends BasePage and lives in src/pages/. A file ending in .component.ts extends BaseComponent and lives in src/components/. If you ever find a .page.ts outside src/pages/, something has gone sideways.

Methods follow a similar pattern. Actions start with verbs : fillForm, clickSubmit, selectOption. Assertions start with expect : expectSuccessMessage, expectValidationErrors. Data access starts with get : getRowCount, getUsername. Wait helpers start with waitFor : waitForPageReady.

You see login.page.ts in the file tree, you know it contains LoginPage and it has the methods you'd expect. New file ? Pick the suffix that fits, and the architecture places itself.

§ 3Project configuration

A bit of plumbing before the layers. Three files set the tone.

TypeScript with strict mode and path aliases

The tsconfig.json enables path aliases so imports don't look like a directory traversal attack :

1 {
2 "compilerOptions": {
3 "target": "ES2022",
4 "module": "commonjs",
5 "moduleResolution": "node",
6 "strict": true,
7 "esModuleInterop": true,
8 "baseUrl": ".",
9 "paths": {
10 "@core/*": ["src/core/*"],
11 "@components/*": ["src/components/*"],
12 "@pages/*": ["src/pages/*"],
13 "@fixtures": ["src/fixtures/index.ts"],
14 "@data/*": ["src/data/*"],
15 "@config/*": ["src/config/*"],
16 "@utils/*": ["src/utils/*"]
17 }
18 },
19 "include": ["src/**/*.ts", "tests/**/*.ts", "*.ts"]
20 }

strict: true is non-negotiable. TypeScript's entire value proposition in a test framework is catching mistakes at compile time : missing fields, wrong types, broken imports. Turning off strict mode because the compiler is being loud is, well, you know how that ends...

Biome instead of ESLint + Prettier

One tool, one config, one pass. Biome handles linting and formatting together, which means no more debates about whether Prettier or ESLint should win when they disagree :

1 {
2 "$schema": "https://biomejs.dev/schemas/2.4.10/schema.json",
3 "formatter": {
4 "indentStyle": "space",
5 "indentWidth": 2,
6 "lineWidth": 90
7 },
8 "linter": {
9 "rules": {
10 "recommended": true,
11 "correctness": {
12 "noUnusedVariables": "error",
13 "noUnusedImports": "error"
14 }
15 }
16 }
17 }

Biome is also fast in a way that matters. Linting 38 files takes about 100ms. ESLint + Prettier on the same files takes around 2 seconds. The difference is irrelevant on your laptop, but it shows up in CI where every second of pipeline time multiplies across every PR every developer pushes every day. Over a year, the difference is measured in hours of saved CI compute.

Environment management

Hardcoded URLs are how "works on my machine" gets in the door. A single configuration class centralizes everything :

1 // src/config/env.config.ts
2 import * as dotenv from "dotenv";
3
4 type Environment = "local" | "dev" | "staging" | "production";
5
6 const environments: Record<Environment, EnvironmentConfig> = {
7 local: {
8 baseUrl: "http://localhost:3000",
9 apiUrl: "http://localhost:3000/api",
10 timeout: 15_000,
11 retries: 0,
12 workers: 4,
13 headless: false,
14 },
15 staging: {
16 baseUrl: "https://staging.myapp.com",
17 apiUrl: "https://staging.myapp.com/api",
18 timeout: 30_000,
19 retries: 2,
20 workers: 2,
21 headless: true,
22 },
23 };
24
25 class EnvironmentManager {
26 private readonly env: Environment;
27 private readonly config: EnvironmentConfig;
28
29 constructor() {
30 dotenv.config();
31 this.env = (process.env.TEST_ENV ?? "local") as Environment;
32 this.config = environments[this.env];
33 }
34
35 get baseUrl(): string { return process.env.BASE_URL ?? this.config.baseUrl; }
36 get timeout(): number { return this.config.timeout; }
37 get retries(): number { return this.config.retries; }
38 get workers(): number { return this.config.workers; }
39 get headless(): boolean { return this.config.headless; }
40 }
41
42 export const EnvConfig = new EnvironmentManager();

Switching environments becomes one variable instead of a repo-wide find-and-replace :

1 TEST_ENV=staging npx playwright test

The playwright.config.ts reads everything from EnvConfig so there are no hardcoded values anywhere in the project.

I won't paste the full file here, but the rule is simple : nothing in playwright.config.ts ever hardcodes a URL, a timeout, or a worker count. Everything comes from EnvConfig.

The day Marketing wants to point a demo at a new domain, you change one line in env.config.ts and the entire suite follows.

§ 4Core : the foundation

Now the layers. Bottom first.

The Core layer provides the abstract base classes that every Page Object and Component inherits from. You write these once, then barely touch them again, which is exactly what you want from foundational code.

BasePage

Every page object extends BasePage. It provides navigation, interaction helpers, and a built-in structured logger :

1 // src/core/base.page.ts
2 import { type Locator, type Page, expect } from "@playwright/test";
3 import { Logger } from "../utils/logger";
4
5 export abstract class BasePage {
6 protected readonly log: Logger;
7
8 abstract readonly path: string;
9 abstract readonly pageTitle: string | RegExp;
10
11 constructor(protected readonly page: Page) {
12 this.log = new Logger(this.constructor.name);
13 }
14
15 async navigate(): Promise<this> {
16 this.log.step(`Navigating to ${this.path}`);
17 await this.page.goto(this.path, { waitUntil: "domcontentloaded" });
18 await this.waitForPageReady();
19 return this;
20 }
21
22 async waitForPageReady(): Promise<void> {
23 await this.page.waitForLoadState("networkidle");
24 }
25
26 async expectToBeVisible(): Promise<void> {
27 await expect(this.page).toHaveTitle(this.pageTitle);
28 }
29
30 protected async click(locator: Locator, description: string): Promise<void> {
31 this.log.action(`Click: ${description}`);
32 await locator.click();
33 }
34
35 protected async fill(locator: Locator, value: string, description: string): Promise<void> {
36 this.log.action(`Fill "${description}" with "${value}"`);
37 await locator.clear();
38 await locator.fill(value);
39 }
40
41 protected async selectOption(locator: Locator, value: string, description: string): Promise<void> {
42 this.log.action(`Select "${value}" in "${description}"`);
43 await locator.selectOption(value);
44 }
45 }

The protected keyword is the entire point. Subclasses can call this.fill() and this.click(), but tests cannot. Tests have to go through the page object's public methods, they never touch these helpers directly.

The test says what it wants, the page object decides how. That separation is non-negotiable : the moment a test reaches into the helpers, you've opened a hole that other tests will eventually pour through.

Every interaction is automatically logged with timestamps and context. The first time you see this output, you'll wonder how you ever debugged without it :

14:32:01 ■ ContactPage     │ ▸ Navigating to /contact
14:32:01 ■ ContactPage     │   fill "first name" with "Jean"
14:32:01 ■ ContactPage     │   fill "last name" with "Dupont"
14:32:02 ■ ContactPage     │   click Send button

When a test fails in CI and you're reading the trace, this log is the difference between a 5-minute diagnosis and an hour of detective work.

The logger has five levels, each with its own icon and color : step for high-level operations, action for granular interactions, success for verified states, warn for soft failures, error for hard failures. Every Page Object and every Component gets its own logger instance, named after the class. So a failure trace tells you which class did what, in order, with timestamps.

The same output that scrolls past during local development also feeds the custom HTML reporter (covered in Part 4) : the this.log.step() calls you write in your Page Objects show up in two places, the terminal and the post-run report. You write the trace once. It pays back forever.

# interactive · 1 · logger_terminal
ready
click a run to stream the structured log output
$ pnpm test
fig 1 · the structured logger in action · click a run to see what every test produces

BaseComponent

Components are UI fragments that appear across multiple pages : tables, modals, forms, toasts. They extend BaseComponent, which scopes all interactions to a root locator so that a TableComponent only sees rows inside its own table, not every table on the page :

1 // src/core/base.component.ts
2 import { type Locator, type Page, expect } from "@playwright/test";
3 import { Logger } from "../utils/logger";
4
5 export abstract class BaseComponent {
6 protected readonly log: Logger;
7
8 constructor(
9 protected readonly page: Page,
10 protected readonly root: Locator,
11 ) {
12 this.log = new Logger(this.constructor.name);
13 }
14
15 protected locator(selector: string): Locator {
16 return this.root.locator(selector);
17 }
18
19 async expectToBeVisible(): Promise<void> {
20 await expect(this.root).toBeVisible();
21 }
22 }

That root locator is the boundary. When a TableComponent calls this.locator("tbody tr"), it only finds rows inside its own root element. On a dashboard with three tables side by side, each TableComponent instance only sees its own data. No accidental cross-talk, no flaky assertions because Playwright found a row in the wrong table.

That's Core done. Two abstract classes, less than 100 lines, and we never have to think about navigation, logging, or locator scoping again.

§ 5Pages and components : the UI layer

Concrete page objects

With the base class in place, concrete page objects are focused and clean. Every page declares its path and pageTitle, keeps locators private, and exposes actions and assertions as public methods :

1 // src/pages/contact.page.ts
2 import type { Page } from "@playwright/test";
3 import { BasePage } from "../core/base.page";
4 import type { ContactFormData } from "../data/types";
5
6 export class ContactPage extends BasePage {
7 readonly path = "/contact";
8 readonly pageTitle = /Contact/;
9
10 //Selectors
11
12 private readonly firstNameInput = this.page.getByLabel("First name");
13 private readonly lastNameInput = this.page.getByLabel("Last name");
14 private readonly emailInput = this.page.getByLabel("Email");
15 private readonly subjectSelect = this.page.getByTestId("subject");
16 private readonly messageTextarea = this.page.getByLabel("Message");
17 private readonly sendButton = this.page.getByRole("button", { name: "Send" });
18 private readonly successAlert = this.page.getByRole("alert");
19
20 //Actions
21
22 async fillContactForm(data: ContactFormData): Promise<void> {
23 this.log.step(`Filling contact form for ${data.firstName} ${data.lastName}`);
24 await this.fill(this.firstNameInput, data.firstName, "first name");
25 await this.fill(this.lastNameInput, data.lastName, "last name");
26 await this.fill(this.emailInput, data.email, "email");
27 await this.selectOption(this.subjectSelect, data.subject, "subject");
28 await this.fill(this.messageTextarea, data.message, "message");
29 }
30
31 async submitForm(): Promise<void> {
32 this.log.step("Submitting contact form");
33 await this.click(this.sendButton, "Send button");
34 }
35
36 //Assertions
37
38 async expectSuccessMessage(): Promise<void> {
39 await expect(this.successAlert).toBeVisible();
40 }
41 }

A few patterns worth noticing here. Locators use getByRole and getByLabel before falling back to getByTestId because accessible locators are more resilient to UI refactors (a CSS class change won't break them, and the team probably tests the accessible names anyway).

Action methods accept typed objects (ContactFormData) instead of loose strings, so adding a field to the form means updating the type and the compiler flags every test that needs to be updated. And this.fill() and this.click() come from BasePage, which handles the clear() call and the logging automatically.

When the constructor has no extra work to do (no Component instances to create), you don't write it. TypeScript generates one automatically that calls super(page).

Reusable components

When a UI element appears on multiple pages, extract it into a Component instead of duplicating the logic. A table is the obvious example because most enterprise apps have tables on every other page :

1 // src/components/table.component.ts
2 import { type Locator, expect } from "@playwright/test";
3 import { BaseComponent } from "../core/base.component";
4
5 export class TableComponent extends BaseComponent {
6 private readonly rows: Locator = this.locator("tbody tr");
7 private readonly headers: Locator = this.locator("thead th");
8
9 async getRowCount(): Promise<number> {
10 return this.rows.count();
11 }
12
13 async clickRowAction(rowIndex: number, actionTestId: string): Promise<void> {
14 this.log.action(`Click action "${actionTestId}" on row ${rowIndex}`);
15 await this.rows.nth(rowIndex).locator(`[data-testid="${actionTestId}"]`).click();
16 }
17
18 async sortByColumn(headerText: string): Promise<void> {
19 this.log.action(`Sort by column: ${headerText}`);
20 await this.headers.filter({ hasText: headerText }).click();
21 }
22
23 async expectRowCount(count: number): Promise<void> {
24 await expect(this.rows).toHaveCount(count);
25 }
26 }

Then compose it into any page that has a data table :

1 // src/pages/dashboard.page.ts
2 export class DashboardPage extends BasePage {
3 readonly path = "/dashboard";
4 readonly pageTitle = /Dashboard/;
5
6 readonly dataTable: TableComponent;
7 readonly confirmModal: ModalComponent;
8 readonly toast: ToastComponent;
9
10 constructor(page: Page) {
11 super(page);
12 this.dataTable = new TableComponent(page, page.getByTestId("data-table"));
13 this.confirmModal = new ModalComponent(page, page.getByTestId("confirm-modal"));
14 this.toast = new ToastComponent(page, page.getByTestId("toast"));
15 }
16
17 async deleteRow(index: number): Promise<void> {
18 this.log.step(`Deleting row ${index}`);
19 await this.dataTable.clickRowAction(index, "action-delete");
20 await this.confirmModal.confirm();
21 await this.toast.expectSuccess();
22 }
23 }

This is composition in practice. DashboardPage doesn't know how a table sorts or how a modal confirms, it delegates those details to the components. If the modal's confirm button changes from a <button> to an <a> tag, you update ModalComponent once and every page that uses it keeps working.

Compare that to having the same modal logic copy-pasted across five page objects, which is a maintenance situation you've probably already lived through...

Note that the constructor is needed here because we're creating Component instances. When a Page only has locators (like ContactPage), the constructor stays implicit.

§ 6Fixtures : dependency injection

Playwright's fixture system creates Page Objects and injects them into tests automatically. Tests never call new, they declare what they need and the framework provides it :

1 // src/fixtures/index.ts
2 import { test as base } from "@playwright/test";
3 import { ContactPage } from "../pages/contact.page";
4 import { DashboardPage } from "../pages/dashboard.page";
5
6 type TestFixtures = {
7 contactPage: ContactPage;
8 dashboardPage: DashboardPage;
9 };
10
11 export const test = base.extend<TestFixtures>({
12 contactPage: async ({ page }, use) => {
13 await use(new ContactPage(page));
14 },
15 dashboardPage: async ({ page }, use) => {
16 await use(new DashboardPage(page));
17 },
18 });
19
20 export { expect } from "../utils/custom-matchers";
# interactive · 2 · fixture_injection
ready
click trace to see where { contactPage } comes from
// tests/e2e/contact.spec.ts
1
test("should submit contact form", async (contactPage) => {
  await contactPage.fillContactForm(/* ... */);
});
// src/fixtures/index.ts
2
export const test = base.extend(({
  contactPage: async (({ page }, use) => {
    await use(new ContactPage(page));
  },
});
// src/pages/contact.page.ts
3
export class ContactPage extends BasePage {
  // the page object the fixture just built
}
the test never calls new — the fixture does that for it
fig 2 · fixture injection traced step by step · click trace to replay

Two things to notice. First, test and expect are both re-exported from this single file, which means every test in the project imports from src/fixtures instead of from @playwright/test. That one convention is what makes the dependency injection and the custom matchers work everywhere.

Second, fixtures are lazy : if a test only requests contactPage, the dashboardPage factory never runs. There's no overhead for fixtures the test doesn't actually use.

Adding a new page always follows the same three steps : create the Page Object, add the type to TestFixtures, add the factory function. That's it. The day a junior on the team needs to add coverage for a new page, they don't ask anyone how the framework works, they copy a fixture from the file, follow the pattern, and ship.

§ 7Data builders : no more hardcoded values

Hardcoded test data is fragile in a way that doesn't become obvious until it's too late. Add a required field to your form and every test that constructs a data object by hand breaks. The builder pattern, combined with Faker.js (a library that generates realistic fake data like names, emails, and addresses), gives you sensible defaults with targeted overrides :

1 // src/data/builders/contact.builder.ts
2 import { faker } from "@faker-js/faker";
3 import type { ContactFormData } from "../types";
4 import { Builder } from "./base.builder";
5
6 export class ContactBuilder extends Builder<ContactFormData> {
7 private constructor() {
8 super({
9 firstName: faker.person.firstName(),
10 lastName: faker.person.lastName(),
11 email: faker.internet.email(),
12 subject: faker.helpers.arrayElement(["customer-service", "webmaster", "return"]),
13 message: faker.lorem.words({ min: 5, max: 20 }),
14 });
15 }
16
17 static create(): ContactBuilder {
18 return new ContactBuilder();
19 }
20
21 withEmail(email: string): this {
22 this.data.email = email;
23 return this;
24 }
25
26 withEmptyFields(): this {
27 this.data.firstName = "";
28 this.data.lastName = "";
29 this.data.email = "";
30 this.data.message = "";
31 return this;
32 }
33 }

Each test run generates random but valid data. When you need a specific scenario, you override just what the test cares about :

1 ContactBuilder.create().build(); // fully random
2 ContactBuilder.create().withEmail("invalid").build(); // one bad field
3 ContactBuilder.create().withEmptyFields().build(); // all empty
# interactive · 3 · builder_method_chain
ready
▸ build your test data
▸ the data — what .build() returns
fig 3 · build test data by clicking methods · order matters

New required field on the form next month ? Update the builder's defaults in one place. Every test keeps working without being touched.

§ 8The test layer : boring on purpose

With all the layers in place, tests become deliberately boring. No selectors, no waits, no setup boilerplate, just specifications of behavior that a QA analyst could read without knowing TypeScript :

1 // tests/e2e/contact.spec.ts
2 import { test } from "../../src/fixtures";
3 import { ContactBuilder } from "../../src/data/builders/contact.builder";
4
5 test.describe("Contact form @smoke", () => {
6 test.beforeEach(async ({ contactPage }) => {
7 await contactPage.navigate();
8 });
9
10 test("should submit successfully with valid data", async ({ contactPage }) => {
11 await contactPage.fillContactForm(ContactBuilder.create().build());
12 await contactPage.submitForm();
13 await contactPage.expectSuccessMessage();
14 });
15
16 test("should show validation errors for invalid data", async ({ contactPage }) => {
17 await contactPage.fillContactForm(
18 ContactBuilder.create().withEmptyFields().withEmail("not-an-email").build(),
19 );
20 await contactPage.submitForm();
21 await contactPage.expectValidationErrors();
22 });
23 });

That's it. The whole test layer. No page.click(), no data-testid, no hardcoded strings, no setup. Behavior in, assertion out. A new QA hire could read this file on day one and understand what it does without knowing a single thing about the framework underneath.

▸ STRUCTURED (3 lines)
1 await contactPage.fillContactForm(ContactBuilder.create().build());
2 await contactPage.submitForm();
3 await contactPage.expectSuccessMessage();
▸ PROCEDURAL (13 lines)
1 await page.goto('https://staging.myapp.com/contact');
2 await page.locator('[data-test="first-name"]').clear();
3 await page.locator('[data-test="first-name"]').fill('John');
4 await page.locator('[data-test="last-name"]').clear();
5 await page.locator('[data-test="last-name"]').fill('Doe');
6 await page.locator('[data-test="email"]').clear();
7 await page.locator('[data-test="email"]').fill('john@test.com');
8 await page.locator('[data-test="subject"]').selectOption('customer-service');
9 await page.locator('[data-test="message"]').clear();
10 await page.locator('[data-test="message"]').fill('Hello');
11 await page.locator('[data-test="contact-submit"]').click();
12 await expect(page.getByRole('alert')).toBeVisible();

Same behavior. 13 lines doing what the structured version does in 3.

Running focused subsets

The @smoke tag in the describe block (test.describe("Contact form @smoke", ...)) isn't decorative. It hooks into Playwright's grep filter, which means you can run subsets of your suite by tag :

1 npm run test:smoke # only tests tagged @smoke
2 npm run test:regression # only tests tagged @regression
3 npm run test:visual # only the visual regression suite

The convention worth landing on early : @smoke for the handful of paths that have to work on every PR (login, create, save), @regression for full coverage that runs nightly. A 300-test suite where smoke is 15 tests means PRs get feedback in two minutes, and the long tail runs while the team sleeps.

§ 9Where this leaves us

We have the four layers built. Core hides Playwright. Pages and Components hide selectors. Fixtures hide construction. Tests describe behavior in three lines and barely change when the UI moves underneath them.

Was it worth it ? That depends on how much the UI is actually going to move and how big the suite is going to get. Part 3 answers that with numbers, not opinions. We measure the blast radius of common changes against both architectures, then watch how the difference grows as the suite scales from 10 tests to 500.