Why OOP Matters for Test Automation
Object-Oriented Programming is not just an academic concept — it is the foundation of maintainable test automation. Without OOP, test suites grow into unstructured scripts that are impossible to maintain at scale.
Understanding OOP helps you write test code that is organized, reusable, and easy to modify when the application changes.
The Four OOP Principles
1. Encapsulation
Encapsulation means bundling data and methods together in a class, hiding internal details and exposing only what is necessary.
Without encapsulation:
// Selectors scattered across tests — nightmare to maintain
test('user can login', async ({ page }) => {
await page.fill('#email-input-v2', 'admin@test.com');
await page.fill('input[name="pwd"]', 'secret');
await page.click('.btn-submit-login');
});
test('user sees dashboard after login', async ({ page }) => {
await page.fill('#email-input-v2', 'admin@test.com'); // duplicated
await page.fill('input[name="pwd"]', 'secret'); // duplicated
await page.click('.btn-submit-login'); // duplicated
});
With encapsulation (Page Object):
class LoginPage {
// Private selectors — hidden from tests
#emailInput = '#email-input-v2';
#passwordInput = 'input[name="pwd"]';
#submitButton = '.btn-submit-login';
constructor(page) {
this.page = page;
}
// Public method — clean interface for tests
async login(email, password) {
await this.page.fill(this.#emailInput, email);
await this.page.fill(this.#passwordInput, password);
await this.page.click(this.#submitButton);
}
}
// Tests are clean and maintainable
test('user can login', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.login('admin@test.com', 'secret');
});
When selectors change, you update one class instead of dozens of tests.
2. Inheritance
Inheritance lets one class inherit properties and methods from another. In testing, this is used for base classes and page object hierarchies.
// Base page with common functionality
class BasePage {
constructor(page) {
this.page = page;
}
async navigate(path) {
await this.page.goto(`https://app.example.com${path}`);
}
async getTitle() {
return await this.page.title();
}
async waitForPageLoad() {
await this.page.waitForLoadState('networkidle');
}
}
// Login page inherits from BasePage
class LoginPage extends BasePage {
async login(email, password) {
await this.navigate('/login');
await this.page.fill('#email', email);
await this.page.fill('#password', password);
await this.page.click('#submit');
}
}
// Dashboard page also inherits from BasePage
class DashboardPage extends BasePage {
async getWelcomeMessage() {
return await this.page.textContent('.welcome');
}
async navigateToSettings() {
await this.page.click('#settings-link');
}
}
Both LoginPage and DashboardPage get navigate(), getTitle(), and waitForPageLoad() for free through inheritance.
3. Polymorphism
Polymorphism means different classes can be used through the same interface. In testing, this enables flexible test helpers.
// Base notification checker
class NotificationChecker {
async verify(page, message) {
throw new Error('Subclass must implement verify()');
}
}
// Toast notification
class ToastChecker extends NotificationChecker {
async verify(page, message) {
await expect(page.locator('.toast')).toHaveText(message);
}
}
// Banner notification
class BannerChecker extends NotificationChecker {
async verify(page, message) {
await expect(page.locator('.banner-alert')).toHaveText(message);
}
}
// Test code works with any notification type
async function verifyNotification(checker, page, message) {
await checker.verify(page, message);
}
4. Abstraction
Abstraction means hiding complex implementation behind a simple interface. Tests should read like a description of user behavior, not like implementation code.
// High abstraction — reads like a user story
test('customer can place an order', async ({ page }) => {
const shop = new ShopWorkflow(page);
await shop.loginAsCustomer();
await shop.addProductToCart('Wireless Mouse');
await shop.proceedToCheckout();
await shop.enterShippingAddress(testAddress);
await shop.payWithCard(testCard);
await shop.verifyOrderConfirmation();
});
The ShopWorkflow class hides all the complexity of selectors, waits, and page navigation. The test reads like a specification.
Class Hierarchy for Test Automation
A typical test automation project has this class hierarchy:
BaseTest
├── WebTest (browser setup/teardown)
│ ├── LoginTest
│ ├── DashboardTest
│ └── CheckoutTest
└── ApiTest (HTTP client setup)
├── UserApiTest
└── OrderApiTest
BasePage
├── LoginPage
├── DashboardPage
├── ProductPage
└── CheckoutPage
├── ShippingPage
└── PaymentPage
Base Test Class Pattern
class BaseTest {
constructor() {
this.testData = {};
}
async setup() {
// Override in subclasses
}
async teardown() {
// Cleanup test data
for (const [type, ids] of Object.entries(this.testData)) {
for (const id of ids) {
await this.cleanup(type, id);
}
}
}
trackCreated(type, id) {
if (!this.testData[type]) this.testData[type] = [];
this.testData[type].push(id);
}
}
class WebTest extends BaseTest {
async setup() {
await super.setup();
this.browser = await chromium.launch();
this.page = await this.browser.newPage();
}
async teardown() {
await this.browser.close();
await super.teardown();
}
}
Composition vs Inheritance
Inheritance is powerful but can create rigid hierarchies. Composition offers more flexibility.
When to Use Inheritance
Use inheritance for clear “is-a” relationships:
LoginPageis aBasePageWebTestis aBaseTestAdminDashboardis aDashboardPage
When to Use Composition
Use composition when you need to combine unrelated capabilities:
// Composition — mixing capabilities
class TestHelper {
constructor(page) {
this.api = new ApiHelper();
this.db = new DatabaseHelper();
this.ui = new UIHelper(page);
this.email = new EmailHelper();
}
async createUserAndLogin(userData) {
// Uses API to create user (faster than UI)
const user = await this.api.createUser(userData);
// Uses UI to login
await this.ui.login(user.email, user.password);
return user;
}
async verifyEmailReceived(to, subject) {
// Uses email service to check
return await this.email.waitForEmail(to, subject);
}
}
The Rule of Thumb
- Inheritance depth should not exceed 3 levels. Deep hierarchies are hard to understand and modify.
- Favor composition when combining different domains (API + UI + DB).
- Use inheritance within a single domain (page object hierarchy).
Interfaces and Contracts
Even in JavaScript (which lacks formal interfaces), you can define contracts:
// Define expected interface via documentation and convention
class PageObject {
/** Navigate to this page */
async goto() { throw new Error('Not implemented'); }
/** Verify page is loaded correctly */
async verifyLoaded() { throw new Error('Not implemented'); }
/** Get the page URL pattern */
get urlPattern() { throw new Error('Not implemented'); }
}
// All page objects follow this contract
class LoginPage extends PageObject {
async goto() {
await this.page.goto('/login');
}
async verifyLoaded() {
await expect(this.page.locator('#login-form')).toBeVisible();
}
get urlPattern() {
return /\/login$/;
}
}
Design Patterns Preview
OOP enables powerful design patterns in test automation:
| Pattern | Purpose | Lesson |
|---|---|---|
| Page Object Model | Encapsulate page interactions | 8.8 |
| Screenplay Pattern | Actor-based test organization | 8.9 |
| Factory Pattern | Create test data objects | 8.23 |
| Builder Pattern | Construct complex test data | 8.23 |
| Strategy Pattern | Swap algorithms at runtime | 8.28 |
Exercise: Build a Page Object Hierarchy
Create a three-level class hierarchy for an e-commerce application:
BasePage— common methods: navigate, getTitle, waitForLoadProductListPage extends BasePage— methods: searchProduct, filterByCategory, sortByProductDetailPage extends BasePage— methods: addToCart, selectSize, getPriceCartPage extends BasePage— methods: updateQuantity, removeItem, proceedToCheckout
For each class, identify which methods and properties should be public vs private.
Key Takeaways
- Encapsulation hides selectors and details inside page objects
- Inheritance reduces duplication through base classes and page hierarchies
- Polymorphism enables flexible and interchangeable test components
- Abstraction makes tests read like user stories
- Prefer composition over deep inheritance hierarchies
- Keep inheritance depth to 3 levels maximum