Writing one automated test is easy. Keeping five hundred of them fast, readable, and reliable while the product changes every sprint is the real challenge. The difference between a framework that scales and one that collapses under its own weight is almost always architecture, not tooling.
Here are the principles I rely on when building UI automation frameworks in TypeScript with Playwright and Cypress.
1. Separate what you test from how you interact
The Page Object Model (POM) keeps selectors and interactions in one place so tests read like business scenarios. When the UI changes, you update one class—not fifty tests.
import { type Locator, type Page } from "@playwright/test";
export class LoginPage {
readonly email: Locator;
readonly password: Locator;
readonly submit: Locator;
readonly errorMessage: Locator;
constructor(private readonly page: Page) {
this.email = page.getByLabel("Email");
this.password = page.getByLabel("Password");
this.submit = page.getByRole("button", { name: "Sign in" });
this.errorMessage = page.getByRole("alert");
}
async goto() {
await this.page.goto("/login");
}
async signIn(email: string, password: string) {
await this.email.fill(email);
await this.password.fill(password);
await this.submit.click();
}
}2. Use fixtures for setup, not copy-paste
Playwright fixtures inject ready-to-use page objects (and authenticated sessions, API clients, or test data) into every test. This removes repetitive beforeEach blocks and keeps setup consistent.
import { test as base } from "@playwright/test";
import { LoginPage } from "./pages/LoginPage";
import { DashboardPage } from "./pages/DashboardPage";
type Pages = {
loginPage: LoginPage;
dashboardPage: DashboardPage;
};
export const test = base.extend<Pages>({
loginPage: async ({ page }, use) => {
await use(new LoginPage(page));
},
dashboardPage: async ({ page }, use) => {
await use(new DashboardPage(page));
},
});
export { expect } from "@playwright/test";In Cypress, the same idea is expressed with custom commands (cy.login()) and cy.session() to cache authentication between tests.
3. Make configuration environment-driven
A scalable framework runs the same tests against local, staging, and production-like environments without code changes. Centralize that in configuration and environment variables.
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./tests",
fullyParallel: true,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 4 : undefined,
reporter: [
["html", { open: "never" }],
["junit", { outputFile: "results/junit.xml" }],
],
use: {
baseURL: process.env.BASE_URL ?? "http://localhost:3000",
trace: "on-first-retry",
screenshot: "only-on-failure",
},
projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
{ name: "firefox", use: { ...devices["Desktop Firefox"] } },
{ name: "mobile", use: { ...devices["Pixel 7"] } },
],
});4. Design for parallel execution from day one
- Every test owns its data. Create users and records through APIs in setup instead of depending on shared accounts.
- No ordering assumptions. Any test must pass when run alone or in any order.
- Seed via API, verify via UI. API setup is faster and far less flaky than clicking through screens.
5. Tag tests by purpose
Tags like @smoke, @regression, and @critical let pipelines run the right subset at the right time: a fast smoke suite on every pull request, the full regression suite nightly. Run a tagged subset with npx playwright test --grep @smoke.
6. Treat flakiness as a bug
A flaky test erodes trust in the entire suite. Rely on auto-waiting assertions such as expect(locator).toBeVisible() instead of fixed sleeps, capture traces on retry, and quarantine flaky tests with a ticket instead of ignoring them.
7. Make results visible
HTML reports, JUnit output for CI dashboards, and traces for failed runs turn a red build into an actionable story. If people can't quickly see why a test failed, they will stop looking.
A recommended structure
automation/
├── pages/ # Page objects: locators + actions
├── fixtures.ts # Injected page objects, auth, API clients
├── tests/
│ ├── smoke/
│ └── regression/
├── data/ # Factories and synthetic test data
├── utils/ # API helpers, date/string helpers
└── playwright.config.tsA good framework makes the right thing easy: the simplest way to write a new test should also be the most maintainable one.