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:

  • LoginPage is a BasePage
  • WebTest is a BaseTest
  • AdminDashboard is a DashboardPage

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:

PatternPurposeLesson
Page Object ModelEncapsulate page interactions8.8
Screenplay PatternActor-based test organization8.9
Factory PatternCreate test data objects8.23
Builder PatternConstruct complex test data8.23
Strategy PatternSwap algorithms at runtime8.28

Exercise: Build a Page Object Hierarchy

Create a three-level class hierarchy for an e-commerce application:

  1. BasePage — common methods: navigate, getTitle, waitForLoad
  2. ProductListPage extends BasePage — methods: searchProduct, filterByCategory, sortBy
  3. ProductDetailPage extends BasePage — methods: addToCart, selectSize, getPrice
  4. CartPage 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