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

The Problem

Three ways a growing Playwright suite falls apart.

Date2026-05-15
Read~7 min
Words~1,400
Revisions1

A Playwright project that grows past a hundred tests usually starts to fail in three distinct ways. The tests themselves get fragile, the team writing them drifts into incompatible conventions, the framework becomes accessible only to whoever built it… Each of these has different symptoms and a different fix, but they share a root cause that we'll try address and prevent with this series.

§ 1Selector rot, URL rot, schema rot

Most Playwright projects start with the example from the docs :

1 import { test, expect } from '@playwright/test';
2
3 test('user can submit an order', async ({ page }) => {
4 await page.goto('https://staging.myapp.com/login');
5 await page.fill('[data-testid="email-input"]', 'user@test.com');
6 await page.fill('[data-testid="password-input"]', 'Password123!');
7 await page.click('[data-testid="login-button"]');
8 await page.waitForURL('**/dashboard');
9
10 await page.click('[data-testid="nav-orders"]');
11 await page.click('[data-testid="new-order-button"]');
12 await page.fill('[data-testid="product-search"]', 'Widget Pro');
13 await page.click('[data-testid="product-result-0"]');
14 await page.click('[data-testid="submit-order"]');
15
16 await expect(page.locator('[data-testid="order-confirmation"]')).toBeVisible();
17 });

There is nothing wrong with this test. No really, that's not the prettiest but it does work. Sometimes it's all you expect from it : It runs, it passes, it verifies the right behavior. Multiplied across thirty test files, it stays green for as long as the application doesn't move. The problem is that applications move.

A data-testid (when you have the luxury to have test-ids) gets renamed during a UI refactor. The change is invisible to whoever made it, because they don't grep for testids when they rename a component. The CI turns red across however many spec files reference that testid. The fix is a find-and-replace pass, plus a manual sweep for any spec where the testid appears as part of a longer string.

A staging URL changes. It's hardcoded in every spec file plus a handful of setup files. Patching it is mechanical but slow, and at least one place gets missed on the first pass.

A required field gets added to a form. Every test that submits the form starts failing because none of them fill it. The fix is to add the field everywhere, except half the patches use the form field name and half use the database column name, and the next CI run is half-red for entirely silly reasons.

A login flow gets MFA, or SSO, or an email confirmation step. Every test breaks at once. Not because the tests are wrong, but because the assumption baked into all of them, that you can log in with two fields and a click, is no longer true.

Click any of the events below and watch the same 17 lines of test code give way. The damage in this single file is what every test in the suite is taking, in parallel, on the same day.

# interactive · 1 · fragile_test_simulator
ready
1 import { test, expect } from '@playwright/test';
2
3 test('user can submit an order', async ({ page }) => {
4 await page.goto('https://staging.myapp.com/login');
5 await page.fill('[data-testid="email-input"]', 'user@test.com');
6 await page.fill('[data-testid="password-input"]', 'Password123!');
7 await page.click('[data-testid="login-button"]');
8 await page.waitForURL('**/dashboard');
9
10 await page.click('[data-testid="nav-orders"]');
11 await page.click('[data-testid="new-order-button"]');
12 await page.fill('[data-testid="product-search"]', 'Widget Pro');
13 await page.click('[data-testid="product-result-0"]');
14 await page.click('[data-testid="submit-order"]');
15
16 await expect(page.locator('[data-testid="order-confirmation"]')).toBeVisible();
17 });
tests/e2e/order.spec.ts · typescript · 17 lines
no broken lines · click an event below to begin
EVENTS THAT HAPPEN ON REAL TEAMS
fig 1 · the same 17 lines of test code, after four refactors that happen every quarter

All-in-all, things starts to go wrong and whenever you open the codebase you start having the silly idea of "starting over", just to reassure yourself that you can do better. But let's face it : refactoring is a luxury most teams cannot afford…

None of these breakages are unusual. They are what happens when a frontend evolves at the pace the business needs it to. The procedural test absorbs the cost of every UI change as a sweep through every spec file that referenced the changed thing. That cost compounds as the suite grows.

At 30 tests it's annoying. At 300 it's the reason the team stops trusting your regression suite…

§ 2Convention drift inside a single stack

Even when the tests are technically resilient, the codebase that produces them tends to drift. This isn't about people bringing incompatible stacks to the project (the hiring filter usually handles that), it's about people bringing different opinions about how to write tests in the same stack, and nothing in the project forcing those opinions to converge.

One person writes flat spec files with inline locators. Another extracts locators into helper functions at the top of each spec. A third writes proto-Page-Objects as plain classes in a helpers/ folder. A fourth adds a pages/ folder a month later, not realizing helpers/ already exists for similar reasons, and starts moving things in there. Each of these is a defensible choice in isolation but none of them combine.

The same login flow ends up implemented three different ways across the repo. None of them share locators. When the login button's testid changes, the dev who fixes one version doesn't know the other two exist. The regression suite goes half-red, gets half-fixed, and the team learns to live with a 75% pass rate on things that are not really broken, but hey…

This drift doesn't get caught in review because tests rarely get the review rigor that production code does. A PR that adds a new test in a fourth structure gets approved in two minutes by someone who saw the CI go green and assumed the rest of the codebase looked similar. By the time the divergence becomes visible, there are four parallel folders and no shared abstractions between them.

The root issue is that the project never declared a convention. Nobody wrote pages/ is where Page Objects live and they extend BasePage and locators are private. Without a declared convention, people fall back on whatever they did last. The result isn't bad code, it's incoherent code, which is harder to fix because there's no single thing to fix.

§ 3The framework as private property

The third failure shows up later, usually when the original author of the test setup stops being available to it. They take a vacation, switch projects, leave the company. The framework is still there, but the team's relationship with it completely changes.

Custom fixtures that nobody else wired keep working but nobody dares modify them. The custom HTML reporter generates output that the team has informally agreed not to look at because last time someone opened it the page hung. New tests get written in a parallel style next to the existing framework, drawing on the Playwright getting-started docs instead of the project's actual setup, because the docs are documented and the framework isn't.

This isn't a code quality problem, the framework was good. It just had no public interface. No documented entry points, no convention statement, no examples of how to extend it. Without those, the only person who could maintain it was the person who built it, and once that person isn't around, the team works around the framework instead of through it. Eventually you end up with two test suites in the same repo, written years apart, with no shared abstractions and no plan to consolidate. And that angers me for some reason.

§ 4What all three have in common

The selector rot is a symptom of selectors not being encapsulated. The convention drift is a symptom of no declared architecture. The private-property problem is a symptom of no public interface.

Different symptoms, same disease : there is no shared model of what a test is, what a Page Object is, what a fixture does, and who is allowed to know what about what.

The architecture that survives all three has four layers and one rule :

The rule is that each layer can only depend on layers below it. Hover any layer to see what lives there, or toggle violation mode to test what happens when the rule gets broken.

# interactive · 2 · living_architecture
hover any layer to explore
hover any layer to explore · toggle violation mode to test the rule
L4 · TESTS
tests/e2e/*.spec.ts
— scenarios and assertions
L3 · FIXTURES
src/fixtures/
— dependency injection
L2 · PAGES & COMPONENTS
src/pages/ + src/components/
— UI abstraction
L1 · CORE
src/core/
— abstract base classes
The rule. Every layer imports only from the one below it — never upward. Break that direction (violation mode) and the four layers collapse back into procedural soup.
L4 · TESTS tests/e2e/*.spec.ts

example · contact.spec.ts
! Click a break point on the left. Each one forces a lower layer to import from a higher layer — the single thing this architecture forbids. You'll see the exact illegal import, and what it costs.
×

illegal ·

fig 2 · the architecture · hover a layer to explore · toggle violation mode to test the rule

The four layers and the dependency rule are what stop the three failures from compounding. Selectors are encapsulated because they have nowhere else to go. Conventions hold because the layers are the convention. The framework has a public interface because the layers and the rule are the interface. None of this is enforced by tooling, it's enforced by where you can and can't import from.

§ 5Where this leaves us

Three failure modes, one architectural pattern, one rule about dependency direction. The rule sounds simple until you build the thing that enforces it. That's the next three parts.

Part 2 walks through the build, layer by layer, with code that runs. Part 3 measures what the architecture actually buys you against the three failures from this article. Part 4 packages the whole thing into a CLI so you never have to type it out again.