Skip to content
SDETRole
SDETRole

Lesson 5 of 9 · Programming for Testers

OOP for Test Automation

Classes, encapsulation, inheritance, polymorphism and abstraction explained through page objects, API clients and drivers, with TypeScript and Java examples and when to prefer composition.

intermediate 8 min read Updated

You will learn to

  • Explain the four OOP pillars using examples from automation frameworks
  • Write classes with constructors, private state and methods in TypeScript and Java
  • Use interfaces and polymorphism to swap implementations such as API clients or browsers
  • Choose composition over inheritance to avoid fragile base classes

Prerequisites

  • TypeScript for Testers
  • Data Structures You Will Use

Every automation framework of any size is built from objects: page objects, API clients, test data builders, configuration readers, driver factories. And “explain the OOP concepts with examples from your framework” is one of the most predictable SDET interview questions, especially for Java roles. This lesson teaches object-oriented programming through those exact examples, using TypeScript first and Java alongside.

Classes and objects

A class is a blueprint that groups data (fields or properties) and behaviour (methods). An object is an instance created from it.

class ApiClient {
  constructor(private readonly baseUrl: string, private readonly token: string) {}

  async get(path: string): Promise<Response> {
    return fetch(`${this.baseUrl}${path}`, {
      headers: { Authorization: `Bearer ${this.token}` },
    });
  }
}

const client = new ApiClient('https://api.example.test', process.env.API_TOKEN ?? '');
const res = await client.get('/users/42');

The constructor parameters marked private readonly are TypeScript parameter properties: they declare and assign fields in one step. Each new ApiClient(...) creates a separate object with its own baseUrl and token.

The Java equivalent uses explicit fields and the built-in java.net.http.HttpClient:

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;

public class ApiClient {
    private final String baseUrl;
    private final String token;
    private final HttpClient http = HttpClient.newHttpClient();

    public ApiClient(String baseUrl, String token) {
        this.baseUrl = baseUrl;
        this.token = token;
    }

    public HttpResponse<String> get(String path) throws Exception {
        HttpRequest request = HttpRequest.newBuilder(URI.create(baseUrl + path))
            .header("Authorization", "Bearer " + token)
            .GET()
            .build();
        return http.send(request, HttpResponse.BodyHandlers.ofString());
    }
}

The four pillars

Encapsulation

Encapsulation means keeping an object’s internal state private and exposing only the operations callers need. In automation, the classic example is a page object that hides locators behind intent-revealing methods:

import { type Page, type Locator, expect } from '@playwright/test';

export class LoginPage {
  private readonly email: Locator;
  private readonly password: Locator;
  private readonly submit: Locator;

  constructor(private readonly page: Page) {
    this.email = page.getByLabel('Email');
    this.password = page.getByLabel('Password');
    this.submit = page.getByRole('button', { name: 'Sign in' });
  }

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

  async signIn(email: string, password: string) {
    await this.email.fill(email);
    await this.password.fill(password);
    await this.submit.click();
  }

  async expectError(message: string) {
    await expect(this.page.getByRole('alert')).toHaveText(message);
  }
}

Tests call signIn(...); they never touch the locators. When the label changes from “Email” to “Email address”, one line changes. The full pattern, including where assertions should live, is in The Page Object Model.

Note that TypeScript’s private is a compile-time check. JavaScript’s #field syntax gives true runtime privacy; for test code, either is fine as long as the team is consistent.

Inheritance

Inheritance lets a class extend another and reuse its members. The subclass “is a” kind of the parent:

class BasePage {
  constructor(protected readonly page: Page) {}

  async dismissCookieBanner() {
    const accept = this.page.getByRole('button', { name: 'Accept all' });
    if (await accept.isVisible()) await accept.click();
  }
}

class CheckoutPage extends BasePage {
  async placeOrder() {
    await this.page.getByRole('button', { name: 'Place order' }).click();
  }
}

protected makes page visible to subclasses but not to outside callers. In Java the same is written with extends and a super(driver) call in the subclass constructor.

Polymorphism

Polymorphism means code can work with different types through a shared interface, and each type supplies its own behaviour. Two forms matter in test code:

  • Overriding (runtime polymorphism): a subclass replaces a parent method.
  • Overloading (compile-time polymorphism, Java): several methods with the same name but different parameter lists, such as click(By locator) and click(WebElement element). TypeScript has overload signatures, but they are less common.

A practical use is swapping implementations behind one interface:

interface UserStore {
  createUser(email: string): Promise<string>; // returns the new user's id
}

class ApiUserStore implements UserStore {
  constructor(private readonly api: ApiClient) {}
  async createUser(email: string) {
    // POST /users and return the id (details omitted)
    return 'u_' + email;
  }
}

class InMemoryUserStore implements UserStore {
  private readonly users = new Map<string, string>();
  async createUser(email: string) {
    const id = `u_${this.users.size + 1}`;
    this.users.set(id, email);
    return id;
  }
}

async function seedUsers(store: UserStore, count: number) {
  const ids: string[] = [];
  for (let i = 0; i < count; i++) ids.push(await store.createUser(`user${i}@example.com`));
  return ids;
}

seedUsers does not care which store it gets. Tests of your framework helpers can use the in-memory version, while real test runs use the API one. Selenium is built on the same idea: ChromeDriver, FirefoxDriver and RemoteWebDriver all implement the WebDriver interface, so your page objects accept a WebDriver and work with any of them.

Abstraction

Abstraction means exposing what something does while hiding how. Interfaces and abstract classes are the language tools for it:

public abstract class BaseComponent {
    protected final WebDriver driver;

    protected BaseComponent(WebDriver driver) {
        this.driver = driver;
    }

    /** Every component must say how to recognise that it is loaded. */
    public abstract boolean isLoaded();
}

An abstract class can contain shared code and abstract methods that subclasses must implement; it cannot be instantiated. An interface (in Java, since Java 8, it can also have default methods) defines a contract. A rule of thumb: use an interface to describe a capability several unrelated classes share, and an abstract class when subclasses genuinely share state and code.

Pillar One-line definition Framework example
Encapsulation Hide state, expose behaviour Private locators, public signIn() in a page object
Inheritance Reuse by extending a parent CheckoutPage extends BasePage
Polymorphism One interface, many implementations WebDriver implemented by Chrome, Firefox, Remote drivers
Abstraction Expose what, hide how UserStore interface; abstract BaseComponent

Composition over inheritance

Inheritance is easy to overuse. A common decay pattern is a BaseTest or BasePage that grows to thousands of lines because every helper ends up there: waits, screenshots, database access, API calls, date utilities. Every page then inherits everything, and changing the base class risks breaking all tests.

Composition means building an object from other objects it holds, rather than inheriting from them. The relationship is “has a” rather than “is a”:

class Header {
  constructor(private readonly page: Page) {}
  async openCart() {
    await this.page.getByRole('link', { name: 'Cart' }).click();
  }
  cartCount() {
    return this.page.getByTestId('cart-count');
  }
}

class ProductPage {
  readonly header: Header;
  constructor(private readonly page: Page) {
    this.header = new Header(page);
  }
  async addToCart() {
    await this.page.getByRole('button', { name: 'Add to cart' }).click();
  }
}

// In a test:
// await productPage.addToCart();
// await expect(productPage.header.cartCount()).toHaveText('1');

Every page that shows the header composes a Header object, instead of inheriting header methods from a base class. Small, focused components are easier to test, reuse and understand.

Static members and utility classes

static members belong to the class, not to instances. They are fine for pure helpers with no state (DateUtils.isoToday()), but static mutable state is dangerous in test frameworks that run in parallel. A static WebDriver driver shared across threads is a well-known cause of tests interfering with each other in Java suites. Give each test its own driver, for example through a ThreadLocal or the framework’s dependency injection, or in Playwright, through fixtures.

Other principles worth knowing

  • Single responsibility: a class should have one reason to change. A page object that also reads Excel files has two.
  • Don’t repeat yourself (DRY) applies to framework code, but tests themselves can tolerate some repetition if it keeps each test readable on its own.
  • Builders for test data: a builder class with sensible defaults and fluent with... methods produces variations without long constructors.
public record User(String name, String email, String role) {}

public class UserBuilder {
    private String name = "Test User";
    private String email = "test.user@example.com";
    private String role = "viewer";

    public UserBuilder withRole(String role) { this.role = role; return this; }
    public UserBuilder withEmail(String email) { this.email = email; return this; }
    public User build() { return new User(name, email, role); }
}

User admin = new UserBuilder().withRole("admin").build();

Java record types (Java 16+) are immutable data carriers with generated constructor, accessors, equals, hashCode and toString, which makes them ideal for test data and expected values.

Practice exercise

Design a small set of classes for testing a web shop with Playwright and TypeScript:

  1. A SearchBox component used on several pages, with a search(term) method.
  2. A ProductListPage that composes SearchBox and exposes productNames() returning the visible product names.
  3. A Notifier interface with send(message: string) and two implementations: one that logs to the console, one that collects messages in an array (for unit-testing your framework).

Explain which OOP pillar each part demonstrates. Try it in the Playwright Lab or your own project.

Show a model answer
import { type Page, type Locator } from '@playwright/test';

export class SearchBox {
  private readonly input: Locator;
  constructor(private readonly page: Page) {
    this.input = page.getByRole('searchbox', { name: 'Search products' });
  }
  async search(term: string) {
    await this.input.fill(term);
    await this.input.press('Enter');
  }
}

export class ProductListPage {
  readonly searchBox: SearchBox;
  constructor(private readonly page: Page) {
    this.searchBox = new SearchBox(page);
  }
  async goto() {
    await this.page.goto('/products');
  }
  async productNames(): Promise<string[]> {
    return this.page.getByTestId('product-name').allInnerTexts();
  }
}

export interface Notifier {
  send(message: string): void;
}

export class ConsoleNotifier implements Notifier {
  send(message: string) {
    console.log(`[notify] ${message}`);
  }
}

export class CollectingNotifier implements Notifier {
  readonly messages: string[] = [];
  send(message: string) {
    this.messages.push(message);
  }
}
  • Encapsulation: SearchBox keeps its locator private and exposes search.
  • Composition: ProductListPage has a SearchBox rather than inheriting from a base page.
  • Abstraction and polymorphism: code depends on the Notifier interface; either implementation can be passed in.

productNames() returns data rather than asserting, so tests decide what to check, for example expect(names).toContain('Kettle'). Alternatively return the locator and use web-first assertions such as toHaveText for automatic retrying.

Key takeaways

  • A class bundles state and behaviour; objects are instances with their own state.
  • Encapsulation hides locators and internals; inheritance reuses; polymorphism swaps implementations behind an interface; abstraction exposes what, not how.
  • Prefer composition for page components and helpers; keep inheritance shallow.
  • Avoid static mutable state in frameworks that run tests in parallel.
  • Use builders and immutable records for clear, flexible test data.

Key terms

Finished this lesson?

Progress is saved in this browser. No account needed.

Type to search across SDETRole.

Full-text search by Pagefind · runs in your browser Open search page →