The Construction
Building the four-layer architecture, layer by layer, with code that runs.
§ 1Project skeleton
Before any code, the folder layout. This is the thing that makes the rest possible :
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 :
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 :
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 :
Switching environments becomes one variable instead of a repo-wide find-and-replace :
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 :
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.
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 :
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 :
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 :
Then compose it into any page that has a data table :
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 :
{ contactPage } comes from
test("should submit contact form", async (contactPage) => {
await contactPage.fillContactForm(/* ... */);
}); export const test = base.extend(({
contactPage: async (({ page }, use) => {
await use(new ContactPage(page));
},
}); export class ContactPage extends BasePage {
// the page object the fixture just built
} new — the fixture does that for it
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 :
Each test run generates random but valid data. When you need a specific scenario, you override just what the test cares about :
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 :
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.
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 :
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.