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. A flat structure hinders scalability. Here is a mature, centralized standard directory structure:

my-playwright-framework/
├── .github/
│   └── workflows/
│       └── playwright.yml      # CI/CD Pipeline Configuration
├── resources/                  # Centralized Test Resources
│   ├── pages/                  # 2. Page Class (Logic & Page Actions)
│   ├── selectors/              # 1. Selector / Locator Repository
│   └── test-data/              # Static Test Data (JSON, CSV)
├── tests/
│   └── login.spec.ts           # 3. Test Script (Execution & Assertion)
├── utils/
│   └── dataHelper.ts           # 4. Utilities / Helpers (Reusable Logic)
├── Dockerfile                  # Containerization Config
├── 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.

// resources/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.

// resources/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 "../resources/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.
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.
4. Playwright Fixtures: The "Native" Way (Advanced)

While the example above uses new LoginPage(page), the true best practice in Playwright is using Fixtures (Dependency Injection). This avoids repetitive manual class instantiation across multiple test files.

// utils/testFixtures.ts
import { test as base } from '@playwright/test';
import { LoginPage } from '../resources/pages/LoginPage';

type MyFixtures = {
  loginPage: LoginPage;
};

export const test = base.extend<MyFixtures>({
  loginPage: async ({ page }, use) => {
    await use(new LoginPage(page));
  },
});

// In your test file, simply call the fixture:
// test('my test', async ({ loginPage }) => { ... })
5. 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.

6. CI/CD Integration & Real-time Reporting

Automation isn't complete if it only runs locally. A mature architecture should be ready to execute in a CI/CD pipeline (like GitHub Actions) within a Docker environment to ensure OS consistency. Furthermore, test results (pass/fail/flaky) should be routed to a messaging bot (Slack or MS Teams) using Playwright Reporters so the entire team gets real-time feedback.

Conclusion

Building automation is not just about making scripts "work". By centralizing resources in the resources/ directory, using elegant Getters (Lazy Evaluation) for dynamic performance, implementing Fixtures for dependency injection, and delegating logic to Helpers, you construct an enterprise-grade architecture that is highly scalable, CI/CD-ready, and effortlessly maintainable by the QA team in the long run.