Lesson 14 of 14

Lesson 14 — OOP for QA automation

Title: Classes, Encapsulation, Composition, and Page Object Thinking

Description: This lesson introduces object-oriented design in JavaScript using classes. It covers class definitions, constructors, methods, private fields, inheritance, composition, and aggregation. It also explains how to structure reusable components like UI drivers, API clients, and test data builders using patterns such as the Page Object Model (POM).

Why it matters for QA: Tools like Playwright, WebdriverIO, and Appium scale better when test code is organized into objects with clear responsibilities. Separating actions (like login or logout) from internal selectors makes tests easier to maintain and reduces breakage when the UI changes.


1. What OOP means in QA automation

OOP (Object-Oriented Programming) is a way to organize code into small, clear objects. Each object keeps its own data and actions together.

A class is a blueprint (model) used to create objects with data and behavior.

The four core principles of OOP in JavaScript

The four core principles of Object-Oriented Programming (OOP) in JavaScript are Encapsulation, Abstraction, Inheritance, and Polymorphism.

Encapsulation — data and behavior are bundled inside an object, and external code cannot directly access or modify private fields.

Abstraction — hide technical implementation details and expose only a simple, meaningful interface for interacting with a system.

Inheritance — allows a class to reuse and extend shared logic from a parent class via extends.

Polymorphism — different classes implement the same method name in different ways, allowing the same call to behave differently depending on the object type.

In QA automation, OOP helps make tests:

  • easier to read
  • easier to maintain
  • easier to reuse

How OOP Is Used in Automation A page object contains everything related to one page.

Examples: LoginPage

  • knows login page locators
  • knows how to perform login actions

DashboardPage

  • knows dashboard checks
  • knows navigation actions

ApiClient

  • knows base URL
  • knows headers
  • knows request methods

UserBuilder

  • creates test users
  • allows small data changes for different tests

Main Goal

The goal is to make tests clear and easy to understand.

Test files should describe business behavior.

Technical details should stay inside objects.

typescripttypescript
await loginPage.login({
  email: "qa@example.com",
  password: "Secret123!",
});

await dashboardPage.expectLoaded();

The test says what happens.

The page object handles how it happens.

Why This Is Useful

Without OOP:

  • tests contain repeated code
  • locators appear everywhere
  • maintenance becomes difficult

With OOP:

  • code becomes cleaner
  • updates are easier
  • tests become more stable
  • large projects become manageable

Simple Idea

Good automation code separates:

  • test logic → inside test files
  • technical implementation → inside objects

This makes the framework professional, scalable, and easier for teams to work with.


2. Class, object, constructor, method

A class is a template for creating objects. An object is a real instance created from that template.

A class describes:

  • what data the object has
  • what actions the object can perform
typescripttypescript
class TestRun {
  name: string;
  retryCount: number;

  constructor(name: string, retryCount = 0) {
    this.name = name;
    this.retryCount = retryCount;
  }

  describe(): string {
    return `${this.name} (retries=${this.retryCount})`;
  }
}

const smokeRun = new TestRun("smoke", 1);
console.log(smokeRun.describe());

Important Parts:

  • class TestRun TestRun is the class..
  • const smokeRun = new TestRun("smoke", 1); smokeRun is an object created from the TestRun class.
  • const regressionRun = new TestRun("regression", 2); You can create many objects from one class.
  • constructor(name: string, retryCount = 0) The constructor runs automatically when a new object is created.
  • this.name = name; this current object.
  • this.name the name property inside this object.
  • describe(): string A method is a function inside a class. Methods define object behavior.

3. Encapsulation: hide the details

Encapsulation means hiding internal implementation details and exposing only safe, simple actions.

In QA automation, tests should use clear business actions like:

await loginPage.login(email, password);

The test should not know:

  • selectors
  • click order
  • field handling
  • internal page logic

Those details stay inside the class.

typescripttypescript
class LoginPage {
  private readonly emailSelector = "[data-testid='email-input']";
  private readonly passwordSelector = "[data-testid='password-input']";
  private readonly submitSelector = "[data-testid='login-button']";

  async login(email: string, password: string): Promise<void> {
    await this.fillEmail(email);
    await this.fillPassword(password);
    await this.submit();
  }

  private async fillEmail(email: string): Promise<void> {
    console.log(`Fill ${this.emailSelector} with ${email}`);
  }

  private async fillPassword(password: string): Promise<void> {
    console.log(`Fill ${this.passwordSelector} with ${password}`);
  }

  private async submit(): Promise<void> {
    console.log(`Click ${this.submitSelector}`);
  }
}

Important Idea

The class exposes only one public action:

login(email, password)

Everything else is hidden.

private

private readonly emailSelector

  • this property or method can only be used inside the class
  • External code cannot access it.

Example: loginPage.emailSelector - This would cause an error.

Why Encapsulation Helps

Without encapsulation:

  • tests directly use selectors
  • selectors are duplicated everywhere
  • UI changes break many tests

With encapsulation:

  • selectors stay in one place
  • page logic stays organized
  • tests become cleaner
  • maintenance becomes easier

Public vs Private

Public methods - Used by tests.

Example:

await loginPage.login(email, password); - These methods describe business behavior.

Private methods - Used only inside the class.

Examples:

fillEmail() fillPassword() submit()

These methods handle technical implementation details.

Good Automation Practice

The test layer should call: loginPage.login(...)

The test layer should NOT:

  • click internal selectors
  • fill fields directly
  • access private properties

This separation makes frameworks professional and scalable.


4. A real Playwright-style Page Object

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

interface LoginCredentials {
  email: string;
  password: string;
}

export class LoginPage {
  private readonly page: Page;
  private readonly emailInput: Locator;
  private readonly passwordInput: Locator;
  private readonly submitButton: Locator;
  private readonly errorMessage: Locator;

  constructor(page: Page) {
    this.page = page;
    this.emailInput = page.getByLabel("Email");
    this.passwordInput = page.getByLabel("Password");
    this.submitButton = page.getByRole("button", { name: "Log in" });
    this.errorMessage = page.getByRole("alert");
  }

  async open(): Promise<void> {
    await this.page.goto("/login");
    await expect(this.emailInput).toBeVisible();
  }

  async login(credentials: LoginCredentials): Promise<void> {
    await this.emailInput.fill(credentials.email);
    await this.passwordInput.fill(credentials.password);
    await this.submitButton.click();
  }

  async expectErrorMessage(message: string): Promise<void> {
    await expect(this.errorMessage).toHaveText(message);
  }
}

This is professional OOP design in QA automation:

  • locators stay inside the class
  • public methods describe real user actions
  • TypeScript types validate method inputs
  • tests focus on business scenarios

An interface is a blueprint that defines the structure of an object:

  • its properties and methods
  • without implementing them.

5. Inheritance: use carefully

Inheritance means one class extends another using extends.

It allows child classes to reuse shared behavior from a parent class.

typescripttypescript
class BasePage {
  async waitForAppReady(): Promise<void> {
    console.log("Wait for app shell");
  }
}

class SettingsPage extends BasePage {
  async open(): Promise<void> {
    await this.waitForAppReady();
    console.log("Open settings");
  }
}

SettingsPage Extends BasePage

  • class SettingsPage extends BasePage - SettingsPage automatically gets all BasePage methods
  • this.waitForAppReady() - this.waitForAppReady()

Risk of Deep Inheritance

Avoid long inheritance chains: BasePage -> AuthenticatedPage -> AdminPage -> ReportsPage


6. API Client Object (Abstraction)

OOP is not only used for UI automation. It is also very useful for API testing.

Abstraction in practice: the test calls createUser, not fetch, headers, or JSON parsing. Technical details stay inside the client.

An API client object groups together:

  • base URL
  • authentication (token)
  • request methods

This keeps API logic centralized and reusable.

typescripttypescript
interface CreateUserRequest {
  email: string;
  role: "admin" | "viewer";
}

interface CreatedUser {
  id: string;
  email: string;
  role: string;
}

class UsersApiClient {
  private readonly baseUrl: string;
  private readonly token: string;

  constructor(baseUrl: string, token: string) {
    this.baseUrl = baseUrl;
    this.token = token;
  }

  async createUser(request: CreateUserRequest): Promise<CreatedUser> {
    console.log(`POST ${this.baseUrl}/users with token ${this.token}`);

    return {
      id: "user-123",
      email: request.email,
      role: request.role,
    };
  }
}

What This Class Does

Stores Configuration private readonly baseUrl: string; private readonly token: string;

These values are stored inside the object:

  • baseUrl → API server address
  • token → authentication credential

They are not exposed outside the class.

Why this is useful

  • no duplicated API logic
  • clean tests
  • easy reuse
  • easy maintenance

await api.createUser({ email, role: "admin" });

The test does not know about fetch, status codes, or token headers. That is abstraction.


7. Polymorphism and interfaces

Polymorphism — different classes implement the same method name in different ways. One helper can work with any object that follows the same contract.

An interface is a blueprint that defines the structure of an object:

  • its properties and methods

  • without implementing them.

  • Class → how it works (implementation)

  • Interface → what it must have (shape / rules)

typescripttypescript
interface Page {
  expectLoaded(): Promise<void>;
}

class LoginPage implements Page {
  async expectLoaded(): Promise<void> {
    console.log("Expect login form");
  }
}

class DashboardPage implements Page {
  async expectLoaded(): Promise<void> {
    console.log("Expect dashboard heading");
  }
}

async function assertPageReady(page: Page): Promise<void> {
  await page.expectLoaded();
}

await assertPageReady(new LoginPage());
await assertPageReady(new DashboardPage());

assertPageReady does not care which page it receives. Each class provides its own expectLoaded implementation. That is polymorphism.


8. Composition and aggregation: build objects from smaller objects

Both composition and aggregation are has-a relationships: one object uses another.

The difference is who owns the lifecycle of the inner object.

Simple difference

ConceptWho creates object?Ownership
CompositionInside classStrong
AggregationOutside classWeak

Difference

CompositionAggregation
OwnershipParent creates and owns the partParent uses the part but does not own it
LifecyclePart usually dies with the parentPart can exist before and after the parent

Composition (strong ownership)

Composition — the parent creates the child inside itself. If the parent is discarded, the child goes with it.

typescripttypescript
class HeaderComponent {
  async logout(): Promise<void> {
    console.log("Click log out");
  }
}

class DashboardPage {
  readonly header: HeaderComponent;

  constructor() {
    this.header = new HeaderComponent();
  }

  async expectLoaded(): Promise<void> {
    console.log("Expect dashboard heading");
  }
}

const dashboardPage = new DashboardPage();
await dashboardPage.header.logout();
  • this.header = new HeaderComponent() — the page creates the header; that is composition.
  • The same HeaderComponent class can be composed into SettingsPage, ProfilePage, and so on without duplicating logout logic.

Aggregation (weak ownership)

Aggregation — the parent holds a reference to another object but does not create it. The part is passed in (for example from a test fixture or factory).

typescripttypescript
class LoginPage {
  async login(email: string, password: string): Promise<void> {
    console.log(`Login as ${email}`);
  }
}

class DashboardPage {
  async expectLoaded(): Promise<void> {
    console.log("Expect dashboard");
  }
}

class AuthFlow {
  constructor(
    private readonly loginPage: LoginPage,
    private readonly dashboardPage: DashboardPage
  ) {}

  async loginAndOpenDashboard(email: string, password: string): Promise<void> {
    await this.loginPage.login(email, password);
    await this.dashboardPage.expectLoaded();
  }
}

const loginPage = new LoginPage();
const dashboardPage = new DashboardPage();
const authFlow = new AuthFlow(loginPage, dashboardPage);

await authFlow.loginAndOpenDashboard("qa@example.com", "Secret123!");
  • AuthFlow uses LoginPage and DashboardPage but does not construct them.
  • The same loginPage instance can be reused in other flows or tests. That is aggregation.

When to use which

  • Use composition for reusable UI pieces that belong to one page (header, modal, tab bar).
  • Use aggregation when a higher-level object orchestrates existing pages or clients passed from outside.

Prefer composition and aggregation over deep inheritance for shared automation code.


9. Builder object for test data

A Builder is used to create test data step by step. It helps avoid repeating full objects in every test.

typescripttypescript
interface TestUser {
  email: string;
  password: string;
  role: "admin" | "viewer";
  isActive: boolean;
}

class TestUserBuilder {
  private user: TestUser = {
    email: `qa-${Date.now()}@example.com`,
    password: "Secret123!",
    role: "viewer",
    isActive: true,
  };

  withRole(role: TestUser["role"]): TestUserBuilder {
    this.user = { ...this.user, role };
    return this;
  }

  inactive(): TestUserBuilder {
    this.user = { ...this.user, isActive: false };
    return this;
  }

  build(): TestUser {
    return { ...this.user };
  }
}

const adminUser = new TestUserBuilder().withRole("admin").build();
const inactiveUser = new TestUserBuilder().inactive().build();

Default data is inside the builder private user: TestUser - Every new builder starts with valid default values.

Methods modify only what you need withRole("admin") inactive() - Each method changes one small part of the object.

build() returns final object build(): TestUser - This returns a clean test user ready for use.

Why Builder Is Useful

  • no repeated test data
  • safe default values
  • easy overrides
  • readable test intent

10. Common OOP mistakes in QA code

Mistake 1: Page Object exposes locators

Bad Page Objects expose internal UI details to tests.

typescripttypescript
// Bad: the test still knows page internals.
await loginPage.submitButton.click();

Prefer:

typescripttypescript
await loginPage.login(credentials);

Mistake 2: one giant class for the whole app

Avoid one huge AppPage class with all locators and logic—split automation by pages and reusable components.

Mistake 3: methods that do too much

typescripttypescript
// Too much hidden behavior.
await checkoutPage.completeFullPurchaseAndVerifyEmailAndLogout();

Prefer smaller methods:

typescripttypescript
await checkoutPage.payWithCard(card);
await orderPage.expectConfirmation();
await header.logout();

Mistake 4: inheritance for everything

Use inheritance only for real shared behavior; use composition for reusable components, API clients, and helpers.


11. How this connects to Playwright and Appium

ConceptPlaywrightAppium
ClassLoginPageLoginScreen
Constructorgets pagegets driver
Private statelocatorsselectors
Methodlogin()tapLogin()
Compositionpage has headerscreen has menu
Aggregationflow gets pagesflow gets screens
Abstractionhelper hides API callshelper hides driver commands
Polymorphismsame helper works with many pagessame helper works with many screens
API clientcreates test userprepares mobile account

Use OOP to organize automation clearly—focus on readable tests, not overly complex abstractions.


Official docs


Quick recap

TopicMeaning for QA automation
ClassGroups data and actions together
ConstructorRuns when object is created
Four pillarsEncapsulation, Abstraction, Inheritance, Polymorphism
EncapsulationHide locators, show only actions
AbstractionSimple public API; hide implementation
PolymorphismSame method name, behavior per class
CompositionPage owns and creates a component
AggregationFlow uses pages passed from outside
InheritanceUse only for shared logic
Page ObjectModel for clean UI tests
API clientObject for backend requests
BuilderObject for easy test data creation

Suggested Exercises (Simple Technical English)

  1. Create a LoginPage class with methods:
  • open()
  • login()
  • expectErrorMessage()
  1. Create a HeaderComponent class and use it inside DashboardPage.

  2. Create a UsersApiClient class with:

  • createUser()
  • deleteUser()
  1. Create a TestUserBuilder with:
  • default valid user data
  • one method to override a field
  1. Review an existing Page Object and check:
  • does it expose locators?
  • does it expose too many internal details?

Homework

Short tasks (about 30-40 minutes). Click a task title to reveal the prompt.

Task 1: LoginPage class

Create a TypeScript LoginPage class.

  • It must take Page in the constructor.
  • It must keep locators private inside the class.
  • It must have an open() method to open the login page.
  • It must have a login(credentials) method to perform login using email and password.
  • Tests should use only these public methods, not locators directly.
Task 2: Credentials interface

Create an interface LoginCredentials with two fields: email and password.

Use this interface as the parameter type for the login method in the LoginPage class.

Task 3: Header composition

Create a HeaderComponent class with a logout() method.

Add this component to DashboardPage as a readonly property.

DashboardPage should use composition to access the header and call logout() through it.

Task 4: API client

Create a UsersApiClient class.

The constructor must receive baseUrl and token. Store both values inside the class. Add a createUser method. The method must accept typed user data as input. The client is used to send requests to the users API.

Task 5: Builder

Create a TestUserBuilder class.

  • It must have valid default user data.
  • It must include a withRole(role) method to change the user role.
  • It must include an inactive() method to set the user as inactive.
  • It must include a build() method that returns a copy of the final user object.
  • The builder should be used to create clean and flexible test data.