The Testing Academy · Class Notes Wednesday, 12 August (IST)
Live class · study guide

Filling the framework: BasePage, utilities, and the logger

The abstract BasePage every page object inherits, a Flex type that lets one utility accept a CSS string or a Playwright locator, the Winston logger that tags which file called it, and the first green test on the framework.

By Pramod Dutta, The Testing Academy. Study notes from the live Playwright class, rebuilt from the session recording. Code is reproduced in the shape it was written on screen; selector strings specific to the practice app are left as placeholders.

01

Where the framework stood, and what today added

Monday ended with an empty folder structure. Today filled it, and by the end a login test ran green with video, traces and an HTML report.

The build order:

  1. Page objects, one file per screen, all under pages/
  2. BasePage, the abstract parent every page object extends
  3. Utilities: the element-locator wrapper, the Faker data generator, the Winston logger
  4. The custom reporter, wired into playwright.config
  5. The first spec, run headless and then headed so the class could watch it

All files are TypeScript. .spec.ts is reserved for test files only; page objects and utilities are plain .ts.

02

The pages, and the parent they share

Walking the practice app gave the page list, and each one became a file in pages/:

Text
src/pages/
  BasePage.ts
  LoginPage.ts
  InventoryPage.ts
  ItemDetailPage.ts
  CartPage.ts
  CheckoutStepOnePage.ts
  CheckoutStepTwoPage.ts
  CheckoutCompletePage.ts

BasePage is the one that matters. Every other page extends it, so anything common lives in exactly one place.

login.spec.ts arrange, act, assert describe, beforeEach, steps LoginPage private locators, open, login extends BasePage BasePage abstract, protected members UtilElementLocator this.el Winston logger this.log CALLS EXTENDS BasePage constructs both, so every page and every test can use them
Four layers, one direction. Locators and actions live in the page object, never in the spec.

BasePage builds the shared pieces once, in its constructor:

TypeScript
import { Page } from "@playwright/test";
import { UtilElementLocator } from "../utils/UtilElementLocator";
import { createLogger } from "../utils/logger";

export abstract class BasePage {
  protected readonly page: Page;
  protected readonly el: UtilElementLocator;
  protected readonly log;

  constructor(page: Page, scope = "BasePage") {
    this.page = page;
    this.el = new UtilElementLocator(page, scope);
    this.log = createLogger(scope);
  }
}

Three decisions in that block, and each was asked about in class:

  • abstract. The class cannot be constructed on its own. Page objects extend it and add their own locators and actions. As written it declares no abstract methods, so nothing is forced on the children; the keyword is there to stop anyone doing new BasePage().
  • protected on the base, private on the children. Protected members are reachable inside the class and inside its subclasses, but not from outside. Page objects keep their own locators private, so a test cannot reach past the page object's methods.
  • scope. A plain string, defaulting to the class name, handed to both the utility and the logger so log lines say which file produced them. More on it below.

The car analogy for overriding, from class: your father has a WagonR, you buy an MG Hector. You override what you inherited because you want something better. That is the reason page objects extend BasePage. The utility class is a different move: it wraps Playwright's methods rather than overriding them, which is why it can add a log line to every one.

03

The utility wrapper, and the Flex type

This is the centrepiece of the session.

Writing page.locator("#user-name").fill(...) directly in a page object works, but it has three problems: it is not reusable, there is nowhere to hang a log line, and changing how clicks behave means editing every file that clicks.

So every interaction goes through one class, UtilElementLocator. The problem it has to solve first: testers write locators two different ways.

TypeScript
page.locator("#login-button").click();              // a CSS or XPath string
page.getByRole("button", { name: "Login" }).click(); // a Playwright locator

A wrapper that takes only a string rejects the second form; one that takes only a Locator rejects the first. The fix is a union type:

TypeScript
import { Locator, Page } from "@playwright/test";

export type Flex = string | Locator;

const DEFAULT_TIMEOUT = 15000;

Flex is a data type, not a function. It says "either of these two". The name is arbitrary; it stands for flexible.

CSS or XPath string "#login-button" Playwright locator page.getByRole(...) Flex string | Locator toLocator() returns one real Locator every wrapped method (click, fill, hover, and the rest) then works with either input el.click("#login-button") el.click(page.getByRole("button", { name: "Login" }))
One union type, one normaliser, and every wrapped method accepts both locator styles.

One private method turns either form into a real Locator, and it is a plain ternary:

TypeScript
private toLocator(target: Flex): Locator {
  return typeof target === "string" ? this.page.locator(target) : target;
}

If the caller passed a string, wrap it in page.locator(). If it was already a locator, hand it straight back. Note the string branch covers CSS and XPath both, because page.locator() accepts either; to the type system they are the same thing.

Every public method then follows the same three-line shape:

TypeScript
async click(target: Flex, timeout = DEFAULT_TIMEOUT) {
  const locator = this.toLocator(target);
  this.log.debug(`Clicking on ${target}`);
  await locator.click({ timeout });
}

The full set wrapped in class covered the interactions and the queries:

Group Methods
Mouse click, doubleClick, rightClick, hover
Input fill, selectByIndex, selectByValue, selectByText
Waits waitForElement, waitForHidden, waitForVisible
State isChecked, isEnabled, isVisible, count
Reads getValue, getAttribute, getAll, getInnerText

Three reasons to wrap, and they were spelled out: reusability, so a test calls el.click instead of retyping page.locator(...).click(); maintainability, because changing how every click behaves is a one-file edit; and logging, because a wrapper is the only place you can put a log line that fires on every interaction in the suite.

04

The logger, and what scope is for

Logging uses Winston, installed from npm. The setup is a one-time utility; nobody writes it per test.

TypeScript
import { createLogger } from "../utils/logger";

const log = createLogger("LoginPage");
log.debug("Clicking the login button");

The levels chart shown in class, most severe last:

Level Used for
debug ordinary progress: clicked this, filled that
info information a reader of the run needs (the chart called this row information)
notice something worth flagging
warning a problem that did not stop the run
error an action that failed
critical, alert, emergency reserved for the severe end

Debug carries 90 to 95 percent of the traffic, with info a distant second. Deliberate advice from class: avoid error for ordinary test failures, because a log full of error lines gives readers the wrong impression of a healthy run.

scope is the string threaded from the page object into both the utility and the logger. It is a tag naming the file that produced the line, so a log reads as "LoginPage clicked the submit button" rather than an anonymous click. Each class passes its own name when it calls super, so LoginPage tags its lines LoginPage. Both constructors carry a fallback default for anyone who omits it.

05

The data generator

Faker is wrapped in a class of static methods, so tests never call Faker directly:

TypeScript
export class DataGenerator {
  static userProfile() { /* username, password, first name, last name, full name */ }
  static checkoutCustomerDetails() { /* first name, last name, postal code */ }
  static credentials() { /* login credentials */ }
}

The checkout flow needs a first name, last name and postal code, so that shape gets its own method rather than being assembled at the call site each time.

06

The login page object

Locators private, methods public, constructor calling super:

TypeScript
import { Page } from "@playwright/test";
import { BasePage } from "./BasePage";

export class LoginPage extends BasePage {
  private readonly path = "index.html";
  // selectors for the practice app's login form
  private readonly usernameInput = "...";
  private readonly passwordInput = "...";
  private readonly loginButton = "...";
  private readonly errorMessage = "...";

  constructor(page: Page) {
    super(page, "LoginPage");
  }

  async open() {
    await this.page.goto(this.path);
    await this.page.waitForLoadState("domcontentloaded");
  }

  async login(username: string, password: string) {
    await this.el.fill(this.usernameInput, username);
    await this.el.fill(this.passwordInput, password);
    await this.el.click(this.loginButton);
  }

  async isLoginFormVisible() {
    return await this.el.isVisible(this.loginButton);
  }
}

Two details that drew questions:

  • super(page, "LoginPage") calls the parent constructor. Without it, this.page, this.el and this.log never get built.
  • The path is relative. baseURL already lives in playwright.config, so the page object only holds the rest of the URL and Playwright joins them.

waitForLoadState("domcontentloaded") is the earliest of the three readiness states, firing once the document is parsed. load comes next, after images and stylesheets, and networkidle last, once requests fall quiet. Waiting on the document is usually enough and is much faster than waiting for the network.

Worth knowing beyond what the class covered: Playwright's own documentation marks networkidle as discouraged for testing, because a page that polls in the background never goes idle and the wait becomes flaky. Prefer domcontentloaded plus a web assertion on the element you actually care about.

07

The first test

Arrange, act, assert, with test.step marking the parts:

TypeScript
import { test, expect } from "@playwright/test";
import { LoginPage } from "../pages/LoginPage";
import { createLogger } from "../utils/logger";

const log = createLogger("login.spec");

// the standard user's credentials for the practice app
const username = "...";
const password = "...";

test.describe("Login", () => {
  let loginPage: LoginPage;

  test.beforeEach(async ({ page }) => {
    loginPage = new LoginPage(page);
    await loginPage.open();
  });

  test("standard user can log in @P0", async ({ page }) => {
    await test.step("login as a standard user", async () => {
      log.info("Logging in as a standard user");
      await loginPage.login(username, password);
    });

    await test.step("verify the login form is gone", async () => {
      expect(await loginPage.isLoginFormVisible()).toBe(false);
    });
  });
});

The test uses Playwright's built-in page fixture. Custom fixtures are not in this session; they open the level 2 work that resumes after the CI/CD classes below.

The assertion is worth noting for what it does not do: rather than looking for a dashboard element, it verifies the login form is no longer visible. Absence of the form is the evidence that authentication happened.

Notice the check goes through a page-object method rather than reaching for a selector in the spec. The locator stays private, the spec stays readable, and the visibility call runs through the utility built earlier in the session.

The nesting to keep straight: test.describe holds tests, a test holds test.step calls. Steps are for breaking one test into readable parts, and they show up as named rows in the HTML report.

08

Wiring the reporter, and the first green run

The custom reporter from an earlier session was copied in, and Claude Code was used to register it in playwright.config along with video, traces and screenshots. The run went headless first, then headed so the class could watch the browser drive itself.

The test passed. The report opened with the video, the traces and the step breakdown all present.

Called out explicitly: this was a dry run. Its purpose was to prove the plumbing works end to end, not to test the application. Real multi-page flows, and putting the data generator to work, come once the base is trusted.

09

What is done, and what the levels mean

The framework was described as a pizza base: complete enough to build on, deliberately plain.

0 Page Object Model pages and specs 1 The base, done today BasePage, utils, logger, reporter, one green test 2 The advanced layer fixtures, data-driven, API, Cucumber, agent factory 3 AI orchestration not where the industry is yet CI/CD sits between 1 and 2: get this base running on Jenkins or GitHub Actions before the advanced layer lands
Where the batch is. Level 1 is finished; CI/CD comes before level 2 opens.

Asked what kind of framework this is, Pramod called it hybrid, and the reasoning was the OOP inventory: single inheritance through BasePage, encapsulation through private and protected members, abstraction through the abstract base, and polymorphism in the wrapped methods. It can carry data-driven tests, BDD and keyword-driven work without changing shape.

10

Questions from the floor

Explaining the framework in an interview. Walk the framework in order: page objects, utilities, test data, logging, the Playwright configuration, how the tests run, the reports, and CI/CD. Building it yourself is what makes the walkthrough possible; memorising the utility method list is not the point.

Is there a JAR equivalent for sharing code? No, and the reason is that Java compiles while TypeScript and JavaScript resolve at runtime. The equivalent is publishing an npm module to a private registry, something like JFrog. Package the base page and the login page, publish, and another team imports just those files without inheriting your checkout pages.

Mobile automation in the same framework? Yes. Either Playwright's mobile support or WebdriverIO, in its own folder under src.

Jenkins versus GitHub Actions versus Azure DevOps? Functionally interchangeable for this purpose. Jenkins is open source, Azure DevOps is the Microsoft stack, GitHub Actions runs where the code already lives. Both Jenkins and GitHub Actions are covered in the upcoming sessions.

Inheriting an over-engineered framework at work? Ask Claude or Copilot to generate an architecture diagram and an execution flow diagram from the repository, then read the diagrams before the code. The concepts underneath are the same ones in this class; the extra layers are usually a module layer and fixtures.

11

Tasks and announcements

  • Build this framework from scratch, on your own. First pass with the recording, second with AI help, third unaided. Minimum three times, five or more to be fluent. Pramod's own count is more than fifty rebuilds, which is why he can write it live.
  • Push it to GitHub, complete through the passing login test.
  • You have roughly a week. The advanced topics do not resume until the batch has a framework running on CI.
  • Next three morning classes, 7:00 to 8:00 AM, led by Deepak: installing Jenkins, running this framework on Jenkins with an emailable report, and GitHub Actions. Starting Friday.
  • After that, the level 2 additions: fixtures first, then data-driven testing, API testing, Cucumber BDD, and the AI agent factory. Parallel running and sharding come in the same stretch.
  • Today's code has been pushed to the class repository.

Interview questions this session answers directly: why wrap Playwright's own methods in a utility class, what a union type like string | Locator buys you, and the difference between private and protected in a page object hierarchy. If you can answer the third from the BasePage code alone, the inheritance model has landed.