Tech Notes Documentation

Insights, tutorials, and deep-dives into QA automation and software engineering.

QA Automation Last updated: 14 Agustus 2026

Building E2E Test Automation Architecture from Scratch with Playwright & POM


In modern software testing, having an automation framework that is robust, maintainable, and well-structured is the key to digital product success. Playwright has emerged as an industry leader for E2E testing.

1. Best Practice Directory Structure

For mid-to-enterprise-scale projects, clean separation of concerns is crucial. Here is a mature standard directory structure:

my-playwright-framework/
├── tests/
│   └── login.spec.ts           # 3. Test Script (Execution & Assertion)
├── pages/
│   └── LoginPage.ts            # 2. Page Class (Logic & Page Actions)
├── selectors/
│   └── loginSelectors.ts       # 1. Selector / Locator Repository
├── utils/
│   └── dataHelper.ts           # 4. Utilities / Helpers (Reusable Logic)
├── playwright.config.ts        # Global Playwright Configuration
└── package.json
2. The Anatomy of POM (Selector, Page, & Test)

An ideal Page Object Model divides components into clear layers for maximum maintainability:

A. Selector / Locator Layer

Stores all DOM elements or selectors in a centralized repository. If the UI changes, you only update it here.

// selectors/loginSelectors.ts
export const LoginSelectors = {
  usernameInput: "#username",
  passwordInput: "#password",
  loginButton: 'button[type="submit"]',
  errorMessage: ".alert-danger"
};
B. Page Class Layer (Implementing Getters)

This is where Getters play a vital role. Using a getter (`get`) to wrap locators encapsulates the element securely and makes code execution significantly cleaner.

// pages/LoginPage.ts
import { Page } from "@playwright/test";
import { LoginSelectors } from "../selectors/loginSelectors";

export class LoginPage {
  private page: Page;

  constructor(page: Page) {
    this.page = page;
  }

  // Getter Implementation (Encapsulation Best Practice)
  get usernameInput() { return this.page.locator(LoginSelectors.usernameInput); }
  get passwordInput() { return this.page.locator(LoginSelectors.passwordInput); }
  get loginButton() { return this.page.locator(LoginSelectors.loginButton); }
  get errorMessage() { return this.page.locator(LoginSelectors.errorMessage); }

  async navigate() {
    await this.page.goto("/login");
  }

  async login(user: string, pass: string) {
    await this.usernameInput.fill(user);
    await this.passwordInput.fill(pass);
    await this.loginButton.click();
  }
}
C. Test Script Layer (Execution & Assertion)

Thanks to the Getters defined above, we can perform assertions directly on page elements with a natural, elegant syntax.

// tests/login.spec.ts
import { test, expect } from "@playwright/test";
import { LoginPage } from "../pages/LoginPage";

test("Failed login displays error message", async ({ page }) => {
  const loginPage = new LoginPage(page);

  await loginPage.navigate();
  await loginPage.login("wrong_user", "wrong_password");

  // Assertion leveraging Getters (Super Clean!)
  await expect(loginPage.errorMessage).toBeVisible();
  await expect(loginPage.errorMessage).toHaveText("Invalid credentials");
});
3. Why Using Getters is the Ultimate Best Practice?

In modern JavaScript/TypeScript, a Getter is not just syntactic sugar; it is an architectural strategy to enforce Lazy Evaluation and Encapsulation. Let's look at the deep comparison:

❌ Without Getter (Standard Property Declaration) ✅ With Getter (Best Practice)
this.btn = page.locator('.btn');
Drawbacks:
  • Stale Element Risk: The element is evaluated and "locked in" the moment the class is instantiated. If the UI re-renders (DOM changes) before the element is clicked, the script crashes (*StaleElementReferenceException*).
  • Memory Waste: All locators inside the class are instantly evaluated by Playwright when the class is called, even if those elements are never used in that specific test scenario.
  • Syntax calling lacks the flexibility inherent to getters.
get btn() { return page.locator('.btn'); }
Benefits:
  • Lazy Evaluation (Dynamic Lookup): The element is NOT searched when the class is instantiated. The element is searched in real-time in the DOM exactly when the line of code is executed. This 100% eliminates *Stale Element* issues.
  • Memory Efficient: If a test case does not interact with a specific button, Playwright will never waste memory or time processing that button's locator.
  • Elegant Syntax: Allows for natural Chaining and Assertions that read like plain English (e.g., expect(page.btn).toBeVisible()).
4. Decoupling Logic with Helpers (DRY Principle)

Often in test automation, we need to generate random emails, format dates, or perform complex calculations. Never put this logic inside a Page Class! A Page Class should be 100% focused on UI interactions.

Extract independent functions into the /utils or /helpers directory. Why? So they can be reused across any test file without rewriting code, strictly adhering to the Don't Repeat Yourself (DRY) principle.

// utils/dataHelper.ts
export function generateRandomEmail(): string {
  const timestamp = new Date().getTime();
  return `qa_tester_${timestamp}@example.com`;
}
Conclusion

Building automation is not just about making scripts "work". By isolating Selectors, using elegant Getters (Lazy Evaluation) for dynamic, resilient performance against DOM changes, and delegating data generation to Helpers, you construct an enterprise-grade architecture that is highly scalable, minimizes flaky tests, and is effortlessly maintainable by the team in the long run.