Tech Notes Documentation
Insights, tutorials, and deep-dives into QA automation and software engineering.
Building E2E Test Automation Architecture from Scratch with Playwright & POM
Last updated: 14 Agustus 2026Understanding In-Depth SDLC & STLC Synchronization in Quality Engineering
Last updated: 12 Agustus 2026End-to-End QA Testing Process: From Risk Analysis and Test Planning to Execution
Last updated: 10 Agustus 2026Why PRD is Crucial: Escaping the Infinite Loop of Back-and-Forth Development
Last updated: 08 Agustus 2026Integrating Playwright into CI/CD Pipelines with GitHub Actions & Docker
Last updated: 05 Agustus 2026Leveraging Claude Code & MCP to Accelerate QA Workflows
Last updated: 03 Agustus 2026Building 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) |
|---|---|
Drawbacks:
|
Benefits:
|
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.