Stop writing fragile Tests: a structured Playwright setup in TypeScript
If you've been using Playwright for a while, you've probably reached that moment. The one where your test suite has 40+ spec files, half of them start with the same login flow, selectors are copy-pasted across files, and someone on your team just broke 15 tests by changing a single button's data-testid. You didn't write bad tests. but you kinda skipped the architecture.

Stop Writing Fragile Tests: A Structured Playwright Setup in TypeScript
Here's a pattern I see constantly. A team starts with Playwright, follows the docs, and writes tests like this:
import { test, expect } from '@playwright/test';
test('user can submit an order', async ({ page }) => {
await page.goto('https://staging.myapp.com/login');
await page.fill('[data-testid="email-input"]', 'user@test.com');
await page.fill('[data-testid="password-input"]', 'Password123!');
await page.click('[data-testid="login-button"]');
await page.waitForURL('**/dashboard');
await page.click('[data-testid="nav-orders"]');
await page.click('[data-testid="new-order-button"]');
await page.fill('[data-testid="product-search"]', 'Widget Pro');
await page.click('[data-testid="product-result-0"]');
await page.click('[data-testid="submit-order"]');
await expect(page.locator('[data-testid="order-confirmation"]')).toBeVisible();
});
This works right up until it doesn't, and the problems creep in gradually. That login sequence gets duplicated into 30 test files. The staging URL is hardcoded, so running against a local dev environment means find-and-replace across the entire repo. A UI redesign renames nav-orders to sidebar-orders-link and suddenly you're patching selectors in dozens of places. And nobody really knows which tests cover which feature because everything lives in flat, procedural spec files.
The fix isn't more discipline about keeping things tidy, it's structure.
Four layers of abstraction
The architecture that holds up over time has four real layers, where each one hides the complexity of the layer below it and exposes only what the layer above needs.
┌─────────────────────────────────────────────────┐
│ Tests (tests/e2e/*.spec.ts) │
│ Scenarios and assertions. Never touches │
│ Playwright directly. Reads like a spec. │
├─────────────────────────────────────────────────┤
│ Fixtures (src/fixtures/) │
│ Dependency injection layer. Creates Page │
│ Objects and injects them into tests. │
├─────────────────────────────────────────────────┤
│ Pages & Components (src/pages/ + src/ │
│ components/) │
│ Pages = one per page of your app. Components │
│ = reusable UI fragments (tables, modals, │
│ navbars). Pages compose Components. │
├─────────────────────────────────────────────────┤
│ Core (src/core/) │
│ Abstract base classes: BasePage, BaseComponent │
│ Shared methods: navigate, fill, click, log │
└─────────────────────────────────────────────────┘
Dependencies always flow downward: tests use fixtures, fixtures use pages, pages use core, and never the other way around. If you ever find a core class importing from a page object, something has gone very wrong.
Alongside these four layers, you have support modules that any layer can reach into: config for environments and credentials, data for builders and types, utils for the logger and custom matchers, and reporters. These aren't layers in the dependency hierarchy, they're shared tooling.
playwright-project/
├── playwright.config.ts
├── biome.json
├── tsconfig.json
├── .env
├── .env.example
├── global-setup.ts
├── src/
│ ├── core/ # Abstract base classes
│ │ ├── base.page.ts
│ │ ├── base.component.ts
│ │ └── base.api.ts
│ ├── components/ # Reusable UI components
│ │ ├── table.component.ts
│ │ ├── modal.component.ts
│ │ ├── form.component.ts
│ │ └── toast.component.ts
│ ├── pages/ # Page Objects
│ │ ├── login.page.ts
│ │ ├── dashboard.page.ts
│ │ └── contact.page.ts
│ ├── fixtures/ # Dependency injection
│ │ └── index.ts
│ ├── api/ # API clients for setup/teardown
│ │ └── user.api.ts
│ ├── data/ # Test data
│ │ ├── builders/
│ │ │ ├── base.builder.ts
│ │ │ └── contact.builder.ts
│ │ └── types/
│ │ └── index.ts
│ ├── config/ # Environment management
│ │ ├── env.config.ts
│ │ └── users.config.ts
│ ├── reporters/ # Custom reporter
│ │ └── html-report.ts
│ └── utils/ # Logger, matchers, visual helpers
│ ├── logger.ts
│ ├── custom-matchers.ts
│ └── visual.ts
├── tests/
│ ├── e2e/
│ │ ├── login.spec.ts
│ │ └── contact.spec.ts
│ └── visual/
│ └── visual.spec.ts
└── docs/ # Local documentation (gitignored)
Tests are organized by domain in tests/, and framework code lives in src/ where it gets the same care you'd give any library code. The docs/ folder contains local-only onboarding guides, gitignored so they don't clutter the repo.
Project configuration
TypeScript
Your tsconfig.json should enable path aliases so you don't end up with import chains that look like a directory traversal attack:
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"moduleResolution": "node",
"strict": true,
"esModuleInterop": true,
"baseUrl": ".",
"paths": {
"@core/*": ["src/core/*"],
"@components/*": ["src/components/*"],
"@pages/*": ["src/pages/*"],
"@fixtures": ["src/fixtures/index.ts"],
"@data/*": ["src/data/*"],
"@config/*": ["src/config/*"],
"@utils/*": ["src/utils/*"]
}
},
"include": ["src/**/*.ts", "tests/**/*.ts", "*.ts"]
}
The strict: true flag is non-negotiable. TypeScript's entire value proposition in a test framework is catching mistakes at compile time: missing fields, wrong types, bad imports. Turning off strict mode to make the compiler stop complaining is like turning off your smoke detector because it's annoying.
Biome instead of ESLint + Prettier
One tool instead of two, and zero config friction. Biome handles linting and formatting in a single pass, which means no more Friday afternoon debates about whether Prettier or ESLint should win when they disagree:
{
"$schema": "https://biomejs.dev/schemas/2.4.10/schema.json",
"formatter": {
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 90
},
"linter": {
"rules": {
"recommended": true,
"correctness": {
"noUnusedVariables": "error",
"noUnusedImports": "error"
}
}
}
}
Environment management
Hardcoded URLs and credentials are the number one source of "works on my machine" issues in test suites. The fix is a single configuration class that centralizes everything:
// src/config/env.config.ts
import * as dotenv from "dotenv";
type Environment = "local" | "dev" | "staging" | "production";
const environments: Record<Environment, EnvironmentConfig> = {
local: {
baseUrl: "http://localhost:3000",
apiUrl: "http://localhost:3000/api",
timeout: 15_000,
retries: 0,
workers: 4,
headless: false,
},
staging: {
baseUrl: "https://staging.myapp.com",
apiUrl: "https://staging.myapp.com/api",
timeout: 30_000,
retries: 2,
workers: 2,
headless: true,
},
};
class EnvironmentManager {
private readonly env: Environment;
private readonly config: EnvironmentConfig;
constructor() {
dotenv.config();
this.env = (process.env.TEST_ENV ?? "local") as Environment;
this.config = environments[this.env];
}
get baseUrl(): string { return process.env.BASE_URL ?? this.config.baseUrl; }
get timeout(): number { return this.config.timeout; }
get retries(): number { return this.config.retries; }
get workers(): number { return this.config.workers; }
get headless(): boolean { return this.config.headless; }
}
export const EnvConfig = new EnvironmentManager();
Switching environments becomes a single variable instead of a repo-wide find-and-replace:
TEST_ENV=staging npx playwright test
Playwright configuration
The playwright.config.ts reads everything from EnvConfig so there are no hardcoded values anywhere:
import { defineConfig, devices } from "@playwright/test";
import { EnvConfig } from "./src/config/env.config";
export default defineConfig({
testDir: "./tests",
fullyParallel: true,
workers: EnvConfig.workers,
retries: EnvConfig.retries,
timeout: EnvConfig.timeout,
globalSetup: require.resolve("./global-setup"),
reporter: [
["list"],
["html", { open: "never", outputFolder: "reports/playwright-html" }],
["./src/reporters/html-report.ts", { outputFile: "reports/custom-report.html" }],
],
use: {
baseURL: EnvConfig.baseUrl,
headless: EnvConfig.headless,
trace: "on-first-retry",
screenshot: "only-on-failure",
actionTimeout: 10_000,
navigationTimeout: 30_000,
},
projects: [
{
name: "e2e-admin",
testDir: "./tests/e2e",
use: {
...devices["Desktop Chrome"],
storageState: "auth/admin.json",
},
},
{
name: "visual",
testDir: "./tests/visual",
use: {
...devices["Desktop Chrome"],
storageState: "auth/admin.json",
},
},
],
});
The globalSetup runs authentication once before all tests and saves browser state to disk. Individual tests reuse that cached state, which means no logging in at the start of every single test. Your 200-test suite authenticates once, not 200 times.
Core: the foundation
The Core layer provides abstract base classes that every Page Object and Component inherits from. You write these once and then barely touch them again, which is exactly what you want from foundational code.
BasePage
Every page object extends BasePage, which provides navigation, interaction helpers, and a built-in structured logger:
// src/core/base.page.ts
import { type Locator, type Page, expect } from "@playwright/test";
import { Logger } from "../utils/logger";
export abstract class BasePage {
protected readonly log: Logger;
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> {
this.log.step(`Navigating to ${this.path}`);
await this.page.goto(this.path, { waitUntil: "domcontentloaded" });
await this.waitForPageReady();
return this;
}
async waitForPageReady(): Promise<void> {
await this.page.waitForLoadState("networkidle");
}
async expectToBeVisible(): Promise<void> {
await expect(this.page).toHaveTitle(this.pageTitle);
}
protected async click(locator: Locator, description: string): Promise<void> {
this.log.action(`Click: ${description}`);
await locator.click();
}
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 selectOption(locator: Locator, value: string, description: string): Promise<void> {
this.log.action(`Select "\({value}" in "\){description}"`);
await locator.selectOption(value);
}
}
The protected keyword matters here. 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. That's the whole point of the abstraction: the test says what it wants, the page object decides how.
Every interaction is automatically logged with timestamps and context:
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 at 2am and you're trying to figure out what happened from your phone, this log is the difference between a 5-minute diagnosis and an hour of detective work.
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 table, not every table on the page:
// src/core/base.component.ts
import { type Locator, type Page, expect } from "@playwright/test";
import { Logger } from "../utils/logger";
export abstract class BaseComponent {
protected readonly log: Logger;
constructor(
protected readonly page: Page,
protected readonly root: Locator,
) {
this.log = new Logger(this.constructor.name);
}
protected locator(selector: string): Locator {
return this.root.locator(selector);
}
async expectToBeVisible(): Promise<void> {
await expect(this.root).toBeVisible();
}
}
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, each TableComponent instance only sees its own data. No accidental cross-talk, no flaky assertions because Playwright found a row in the wrong table.
Pages 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:
// src/pages/contact.page.ts
import type { Page } from "@playwright/test";
import { BasePage } from "../core/base.page";
import type { ContactFormData } from "../data/types";
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 subjectSelect = this.page.getByTestId("subject");
private readonly messageTextarea = this.page.getByLabel("Message");
private readonly sendButton = this.page.getByRole("button", { name: "Send" });
private readonly successAlert = this.page.getByRole("alert");
async fillContactForm(data: ContactFormData): Promise<void> {
this.log.step(`Filling contact form for \({data.firstName} \){data.lastName}`);
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");
await this.selectOption(this.subjectSelect, data.subject, "subject");
await this.fill(this.messageTextarea, data.message, "message");
}
async submitForm(): Promise<void> {
this.log.step("Submitting contact form");
await this.click(this.sendButton, "Send button");
}
async expectSuccessMessage(): Promise<void> {
await expect(this.successAlert).toBeVisible();
}
}
A few patterns to notice 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). Action methods accept typed objects (ContactFormData) instead of loose strings. 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 a good example because most enterprise apps have tables on every other page:
// src/components/table.component.ts
import { type Locator, expect } from "@playwright/test";
import { BaseComponent } from "../core/base.component";
export class TableComponent extends BaseComponent {
private readonly rows: Locator = this.locator("tbody tr");
private readonly headers: Locator = this.locator("thead th");
async getRowCount(): Promise<number> {
return this.rows.count();
}
async clickRowAction(rowIndex: number, actionTestId: string): Promise<void> {
this.log.action(`Click action "\({actionTestId}" on row \){rowIndex}`);
await this.rows.nth(rowIndex).locator(`[data-testid="${actionTestId}"]`).click();
}
async sortByColumn(headerText: string): Promise<void> {
this.log.action(`Sort by column: ${headerText}`);
await this.headers.filter({ hasText: headerText }).click();
}
async expectRowCount(count: number): Promise<void> {
await expect(this.rows).toHaveCount(count);
}
}
Then compose it into any page that has a data table:
// src/pages/dashboard.page.ts
export class DashboardPage extends BasePage {
readonly path = "/dashboard";
readonly pageTitle = /Dashboard/;
readonly dataTable: TableComponent;
readonly confirmModal: ModalComponent;
readonly toast: ToastComponent;
constructor(page: Page) {
super(page);
this.dataTable = new TableComponent(page, page.getByTestId("data-table"));
this.confirmModal = new ModalComponent(page, page.getByTestId("confirm-modal"));
this.toast = new ToastComponent(page, page.getByTestId("toast"));
}
async deleteRow(index: number): Promise<void> {
this.log.step(`Deleting row ${index}`);
await this.dataTable.clickRowAction(index, "action-delete");
await this.confirmModal.confirm();
await this.toast.expectSuccess();
}
}
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 nightmare 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.
Fixtures: dependency injection
Playwright's fixture system creates Page Objects and injects them into tests automatically. Tests never call new, they just declare what they need and the framework provides it:
// src/fixtures/index.ts
import { test as base } from "@playwright/test";
import { ContactPage } from "../pages/contact.page";
import { DashboardPage } from "../pages/dashboard.page";
type TestFixtures = {
contactPage: ContactPage;
dashboardPage: DashboardPage;
};
export const test = base.extend<TestFixtures>({
contactPage: async ({ page }, use) => {
await use(new ContactPage(page));
},
dashboardPage: async ({ page }, use) => {
await use(new DashboardPage(page));
},
});
export { expect } from "../utils/custom-matchers";
Two things to notice. First, test and expect are 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 injection and the custom matchers work everywhere. Second, fixtures are lazy: if a test only requests contactPage, the dashboardPage factory never runs, so there's no unnecessary overhead.
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.
Data 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:
// src/data/builders/contact.builder.ts
import { faker } from "@faker-js/faker";
import type { ContactFormData } from "../types";
import { Builder } from "./base.builder";
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(["customer-service", "webmaster", "return"]),
message: faker.lorem.words({ min: 5, max: 20 }),
});
}
static create(): ContactBuilder {
return new ContactBuilder();
}
withEmail(email: string): this {
this.data.email = email;
return this;
}
withEmptyFields(): this {
this.data.firstName = "";
this.data.lastName = "";
this.data.email = "";
this.data.message = "";
return this;
}
}
Each test run generates random but valid data. When you need a specific scenario, you override just what the test cares about:
ContactBuilder.create().build(); // fully random, valid
ContactBuilder.create().withEmail("invalid").build(); // one bad field
ContactBuilder.create().withEmptyFields().build(); // all empty
New required field on the form next month? Update the builder's defaults in one place. Every test keeps working without being touched.
The 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:
// tests/e2e/contact.spec.ts
import { test } from "../../src/fixtures";
import { ContactBuilder } from "../../src/data/builders/contact.builder";
test.describe("Contact form @smoke", () => {
test.beforeEach(async ({ contactPage }) => {
await contactPage.navigate();
});
test("should submit successfully with valid data", async ({ contactPage }) => {
await contactPage.fillContactForm(ContactBuilder.create().build());
await contactPage.submitForm();
await contactPage.expectSuccessMessage();
});
test("should show validation errors for invalid data", async ({ contactPage }) => {
await contactPage.fillContactForm(
ContactBuilder.create().withEmptyFields().withEmail("not-an-email").build(),
);
await contactPage.submitForm();
await contactPage.expectValidationErrors();
});
});
Compare that to the procedural test we started with. No page.click(), no data-testid, no hardcoded strings. And if you want to see just how much the maintenance cost changes, here's the same form submission test in both styles side by side.
Before (procedural):
test('submit contact form', async ({ page }) => {
await page.goto('https://staging.myapp.com/contact');
await page.locator('[data-test="first-name"]').clear();
await page.locator('[data-test="first-name"]').fill('John');
await page.locator('[data-test="last-name"]').clear();
await page.locator('[data-test="last-name"]').fill('Doe');
await page.locator('[data-test="email"]').clear();
await page.locator('[data-test="email"]').fill('john@test.com');
await page.locator('[data-test="subject"]').selectOption('customer-service');
await page.locator('[data-test="message"]').clear();
await page.locator('[data-test="message"]').fill('Hello');
await page.locator('[data-test="contact-submit"]').click();
await expect(page.getByRole('alert')).toBeVisible();
});
After (structured):
test('submit contact form', async ({ contactPage }) => {
await contactPage.fillContactForm(ContactBuilder.create().build());
await contactPage.submitForm();
await contactPage.expectSuccessMessage();
});
Same behavior, random data instead of hardcoded values, and a fraction of the maintenance cost. When the form changes, you update one Page Object and one builder, not 40 spec files.
What to take away from all of this
Structure in a test framework isn't overhead, it's investment. Every hour you spend building proper page objects, configuring environments, and wiring fixtures pays you back tenfold the first time a UI refactor lands and your tests need zero changes.
The architecture comes down to four layers: Core hides Playwright, Pages and Components hide selectors, Fixtures hide construction, and Tests hide nothing because they're the clean surface where you describe behavior.
If you take one thing from this article, make it these five principles:
Separate framework from tests. Your page objects, fixtures, and helpers are application code. Treat them that way: put them in src/, type them strictly, review them in PRs.
Encapsulate selectors. A selector should appear in exactly one place in your entire codebase. If you're grepping for data-testid in your spec files, something went wrong.
Use composition for shared UI. When a table, modal, or navbar appears on multiple pages, extract it into a Component. Pages compose components, they don't duplicate them.
Make fixtures your dependency injection layer. Don't instantiate page objects in tests. Declare what you need, let Playwright's fixture system provide it, and keep your test code focused on behavior instead of setup.
Design for the rename. The true test of your architecture is what happens when someone renames a button, changes a URL pattern, or adds a field to a form. If the blast radius is limited to a single page object, you've won.
Update: I've packaged this entire architecture into a CLI called Histrion. Run npx histrion create, answer 5 questions, and get a fully configured project in about 30 seconds. Read the launch announcement here.
This article is part of TestBot Chronicles, where I write about test automation, engineering practices, and the craft of quality.






