|

Day 8: JavaScript OOP and Inheritance — From Class to BasePage

Compact Page Object Model diagram: LoginPage extends BasePage

This is Day 8 of the 21-day JS to Playwright Framework series. One lesson a day. JavaScript first. TypeScript next. Playwright after that. A production framework by Day 21.

On Day 1 we put values in boxes. On Day 2 we compared them. On Day 3 we decided. On Day 4 we walked a list. On Day 5 we wrapped the walk in a function. On Day 6 we grouped values into objects. On Day 7 we waited — callbacks, promises, async/await. Today we name the thing that owns the behaviour: a class. Then we reuse that class with inheritance. Then we export the parent and the child so a spec can say new LoginPage() and call open() it never defined.

A Page Object is not a Playwright feature. Playwright does not ship BasePage. You write a class. You put goto, click, fill, and screenshot helpers on the parent. You put login(user, password) on the child. The spec imports the child. That is today’s whole job. If you skip this day, you will paste page.goto(baseURL) into forty specs, change the login URL once, and spend a Friday grepping.

I am Pramod Dutta. I teach SDETs in India for a living. Week six of my live batch is not “open Playwright and invent a POM from a blog.” It is class Person, then #apiKey, then static summary(), then class LoginPage extends BasePage, then three files in Exporting_Class/. I fetched every lab from LearningPlaywrightBatch on branch main. I am not inventing files. I quote the blobs below. Folder names, typos, empty skeletons — I keep them.

Two names you must type as they sit on GitHub:

  • EXPORT_IMPORT/152_Loggger.js — three gs.
  • Exporting_Class/Basepage.js — lowercase p in page. LoginPage.js imports ./Basepage.js. If you invent BasePage.js, the import breaks.

Hierarchial_Inheritance/174_HI.js is an 80-byte skeleton: Father, Son1, Son2. I will not invent methods for it. I say so when we reach it.

The root package.json on main has Playwright ^1.58.2 and does not set "type": "module". Files 153–163 and 164–172 and 174 are plain class scripts — node runs them. Files 150–152 plus logger.js / testutil.js / utils.js, and the three Exporting_Class files, use import / export. If Node says Cannot use import statement outside a module, that is this gap. I will not invent a folder package.json to hide it. Quote the files. When we reach Playwright later in this series, the test runner already understands ESM.

*Want to master this with real projects? Join the Playwright Automation Mastery course at The Testing Academy.*

Compact Page Object Model diagram: LoginPage extends BasePage

Contents

Why JavaScript OOP comes before Playwright POM

Playwright gives you page. A page is already an object. page.goto, page.locator, page.screenshot are methods on that object. Your job as an SDET is not to call those methods from every spec. Your job is to wrap the app so a login change lives in one class.

That wrap is Object-Oriented Programming:

  • A class is the blueprint. LoginPage is not one login. It is the shape of every login you will construct.
  • An object is one instance. new LoginPage() in this spec. Another new LoginPage() in the next spec. Same class. Two objects.
  • Attributes are the data — name, baseURL, #apiKey, this.page later.
  • Behaviours are the methods — open(), login(user), verify(), getBalance().
  • Encapsulation hides what a spec must not touch — the API key, the raw balance, the engine name.
  • Inheritance is class LoginPage extends BasePage. The child gets open() without rewriting it.
  • Method overriding is the child writing its own verify() so a list of pages can all be verified the same way from the spec.
  • Export / import is how a class in pages/ reaches a spec in tests/.

I have interviewed enough SDETs to see the pattern. Someone can record a login with codegen and still cannot explain super(), #balance, or why class C extends A, B is a SyntaxError in JavaScript. That gap shows up later as a 400-line spec, a copied open() in twelve page files, a password sitting in a public field, and an interview whiteboard that asks “what is a Page Object?” while they draw a function.

Day 8 is the fix. You will run a Car. You will hide a bank balance. You will export a logger. You will extend BasePage. You will override verify(). You will import LoginPage into 173_Test_2.js. Then I show you why Playwright cares.

Tomorrow (Day 9) we put types on this same shape — TypeScript interface, enum, generics, private / protected / public. Today the language is still JavaScript.

What you will be able to do after Day 8

By the end of this post you can:

  1. Write a class with attributes and behaviours, construct it with new, and say what this is.
  2. Tell a function from a method — a method is a function that lives on the class.
  3. Hold two instances of the same class with different data — two browsers, two API clients, two test cases.
  4. Hide a field with # and expose it only through a method. Explain why cred.#apiKey is a SyntaxError and cred.apiKey is undefined.
  5. Use static for data that belongs to the class, not the instance — a pass-count, a college name, later a shared default timeout.
  6. Encapsulate with get/set, including a guard (isCashier) so a setter is not a public hole.
  7. export a named binding and a default, import with and without as, and explain why fname in testutil.js cannot be imported.
  8. Write class Child extends Parent, call super() in the constructor, and call super.method() when you override.
  9. Walk a list of subclasses and call the same method name — polymorphism — the way a suite walks Unit / API / E2E or Login / Dashboard / Cart.
  10. Say out loud: JavaScript has no class C extends A, B. Mixins in 172.js are the classroom stand-in.
  11. Export BasePage and LoginPage as separate files and drive them from a spec. That is the Playwright POM you will write on Day 16.

That is the skill. Not the keyword class. The skill is one parent for shared page behaviour, one child per screen, one export per file, and a spec that never rewrites open().

The labs we are actually using

Clone the repo and stay on main:

git clone https://github.com/PramodDutta/LearningPlaywrightBatch.git
cd LearningPlaywrightBatch
git checkout main

Chapter 16 — OOP (chapter_16_OOps/)

EXPORT_IMPORT:

  • EXPORT_IMPORT/150_Export_import.js
  • EXPORT_IMPORT/151_Export_Import.js
  • EXPORT_IMPORT/152_Loggger.js (filename is spelled that way)

Shared modules sitting next to those folders:

  • logger.js
  • testutil.js
  • utils.js

CLASS_OBJECT:

  • CLASS_OBJECT/153_Class_Objects.js
  • CLASS_OBJECT/154_Car.js
  • CLASS_OBJECT/155_Class_Object_Browser.js
  • CLASS_OBJECT/156_Browser.js
  • CLASS_OBJECT/157_IQ.js
  • CLASS_OBJECT/158_Private_Public.js
  • CLASS_OBJECT/159_Static.js
  • CLASS_OBJECT/160_Static_p2.js

Encapsulation (nested under CLASS_OBJECT):

  • CLASS_OBJECT/Encapsulation/161_Pramod_Child.js
  • CLASS_OBJECT/Encapsulation/162_Car.js
  • CLASS_OBJECT/Encapsulation/163_Bank.js

Chapter 17 — Inheritance (chapter_17_OOPs_Inheritance/)

Single:

  • Single_Inheritance/164_Inheritance.js
  • Single_Inheritance/165_SI.js
  • Single_Inheritance/166_Method_Overriding.js
  • Single_Inheritance/167_MO_IQ.js
  • Single_Inheritance/168_MO_BIG.js
  • Single_Inheritance/169_PageObject.js
  • Single_Inheritance/170_RO.js

Then:

  • Multi_Level_Inheritance/171_MI.js
  • Multiple_Inheritance/172.js
  • Exporting_Class/Basepage.js
  • Exporting_Class/LoginPage.js
  • Exporting_Class/173_Test_2.js
  • Hierarchial_Inheritance/174_HI.js

I teach class and object first (153–160), then encapsulation (161–163), then the modules those classes will live in (150–152 plus the three helpers), then inheritance (164–174). The folder numbers in chapter 16 put EXPORT_IMPORT before CLASS_OBJECT. In the classroom I flip that order so import { LoginPage } has a class to import.

You need Node.js 18 or newer. No Playwright config today. The Page Object you build is still console.log. The shape is the same shape you will later fill with this.page.

Class and object — CAB, constructor, and new

Lab 153: a class is attributes plus behaviour

Lab: chapter_16_OOps/CLASS_OBJECT/153_Class_Objects.js

Exact file on main:

class Person {
    // Attribute
    name;
    email;
    salary


    // Behaviour
    sleep() { }
    eat() { }

}

// CAB -> Class contains attribute, behaviour

Run it:

node chapter_16_OOps/CLASS_OBJECT/153_Class_Objects.js

Nothing prints. That is the lesson. A class is a declaration. It does not run until you construct an object. name, email, salary are fields. sleep() and eat() are empty methods. The comment at the bottom is the mnemonic I want you to keep: CAB — Class contains Attribute, Behaviour.

SDETs coming from Java look for public class Person and a main. JavaScript does not ask for that. One class block. Fields may be listed without this at the top of the class. Methods are written as sleep() { } — no function keyword.

A Playwright page object starts the same way: fields for locators or for this.page, methods for actions. Empty methods are legal. They are also useless until you fill them. Lab 153 is the empty box. Lab 154 puts a constructor in it.

Lab 154: constructor, this, and one object

Lab: chapter_16_OOps/CLASS_OBJECT/154_Car.js

Exact file:

class Car {
    // Attribute
    // Constructor
    constructor(assigned_name) {
        this.name = assigned_name;
    }
    drive() {
        console.log("Driving the car " + this.name);
    }
    printDetailsCar() {
        console.log("Details of  the car " + this.name);
    }

}

let hyndai_car = new Car("i10");
hyndai_car.drive();
node chapter_16_OOps/CLASS_OBJECT/154_Car.js

You should see:

Driving the car i10

Four things happen in this file.

First, constructor(assigned_name) runs when you write new Car("i10"). The argument "i10" becomes assigned_name. this.name = assigned_name stores it on this object. this is the instance you just created, not the class.

Second, drive() reads this.name. Without the constructor, this.name is undefined, and you print Driving the car undefined. I see that bug in page objects every batch: someone forgets this.page = page and then wonders why this.page.goto throws.

Third, new is not optional. Car("i10") without new is not how you construct a class instance. You want new Car("i10"). The variable hyndai_car holds the object. The filename spelling in the variable is hyndai — I will not “fix” the lab.

Fourth, printDetailsCar() exists and is never called. A class can have methods you do not use in this file. That is fine. A LoginPage will have forgotPassword() long before a spec calls it.

Playwright mapping: new LoginPage(page) is this lab with a different name. The constructor receives the Playwright page. Methods use this.page. If you skip the assignment, every method is a crash.

Lab 155: two objects, and method versus function

Lab: chapter_16_OOps/CLASS_OBJECT/155_Class_Object_Browser.js

The filename says Browser. The class on main is TestCase. I quote the file, not the filename.

class TestCase {
    constructor(name, status, priority) {
        this.name = name;
        this.status = status;
        this.priority = priority;
    }
    display() {
        console.log(this.name + " → " + this.status + " → " + this.priority);
    }
}

let loginTest_ref = new TestCase("Login Test", "PASS", "P0");
let signupTest_ref = new TestCase("Signup Test", "FAIL", "P1");

loginTest_ref.display();


// p0 - learn
// p1 - pratice
// p2 - test / implement
// p3 - IPL

// Function vs Method
// method is functions but inside the class :)
node chapter_16_OOps/CLASS_OBJECT/155_Class_Object_Browser.js
Login Test → PASS → P0

Same class. Two objects. loginTest_ref is P0 PASS. signupTest_ref is P1 FAIL. They do not share status. Changing one does not change the other. That is the point of new twice.

We only call display() on the login object. The signup object is constructed and silent. Construction is not execution.

The comment at the bottom is the interview line: a method is a function, but inside the class. display outside a class is a function. display() on TestCase is a method. Playwright’s expect is a function you import. page.goto is a method on the page object. Stop saying “I called the goto function.” You called a method.

The P0–P3 comments are classroom priority, including the joke p3 - IPL. I leave them. They are in the blob.

Playwright mapping: one TestCase class, many instances, is how a reporter thinks. One LoginPage class, many instances across tests, is how a suite thinks. Do not make a global loginPage that every test mutates. Construct per test, or receive it from a fixture later in this series.

Lab 156: a Browser class, and a method body that lies

Lab: chapter_16_OOps/CLASS_OBJECT/156_Browser.js

Exact file:

class Browser {

    // Param constructor (arguments)
    constructor(name) {
        this.name = name;
        this.isOpen = true;
        console.log(name + " launched");
    }

    startBrowser() {
        console.log("starting the browser")
    }
    closeBrowser() {
        console.log("starting the browser")
    }

}

let chrome = new Browser("Chrome");
let firefox = new Browser("Firefox");

console.log(chrome.isOpen);
node chapter_16_OOps/CLASS_OBJECT/156_Browser.js
Chrome launched
Firefox launched
true

The constructor has a side effect. The moment you new Browser("Chrome"), it prints Chrome launched and sets isOpen = true. You did not call startBrowser(). Construction launched. That is a teaching choice. In Playwright you do not want new LoginPage(page) to navigate. Construction stores page. open() or goto() navigates. Keep those jobs separate or every import of the class hits the network.

chrome and firefox are two objects. chrome.isOpen is true because the constructor set it. We never print firefox.isOpen. It is also true. Same class. Same initial field. Different name.

Look at closeBrowser(). The body says "starting the browser". That is a classroom leftover. I will not rewrite the file in this post. I will tell you: a method name is a contract. If the body does not match the name, your future self will trust the name and ship a lie. When you write async close() on a Playwright page object, close the page. Do not paste the start body a second time.

Lab 157: two API clients, one class

Lab: chapter_16_OOps/CLASS_OBJECT/157_IQ.js

Exact file:

class APIClient {
    constructor(baseURL) {
        this.baseURL = baseURL;
    }

    get(path) {
        return this.baseURL + path;
    }
}

let staging = new APIClient("https://staging.api.com");
let prod = new APIClient("https://prod.api.com");

console.log(staging.get("/users"));
console.log(prod.get("/users"));
node chapter_16_OOps/CLASS_OBJECT/157_IQ.js
https://staging.api.com/users
https://prod.api.com/users

This is the IQ I actually ask. Same get("/users"). Different objects. Different baseURL. The method does not know about staging or prod. It knows this.baseURL.

If you write get as a loose function with a global baseURL, switching env means editing the function. If you write a class, switching env means constructing with a different string. That is Day 3’s env branch, now sitting in a constructor.

Playwright mapping: your API helper in the advanced framework (Day 19 in this series) is this file with request.get instead of string concat. Restful Booker staging versus prod is two instances, not two copies of the helper.

Interview answer, one sentence: the class is the client; the object is the environment.

Private, public, and static

Lab 158: #apiKey is hidden; user is not

Lab: chapter_16_OOps/CLASS_OBJECT/158_Private_Public.js

Exact file:

// Private Fields (#) — Hidden Data
// PUBIC Fields 
class Credentials {
    #apiKey;
    user;

    constructor(user, key) {
        this.user = user; // public
        this.#apiKey = key;
    }
    // Custom made fuction by us
    pramodgetAuthHeader() {
        return "Bearer " + this.#apiKey;
    }
}

let cred = new Credentials("admin", "scret_key_1234");
console.log(cred.user);
// console.log(cred.apiKey); undefined
// console.log(cred.#apiKey); //error

console.log(cred.pramodgetAuthHeader());

// cred.apiKey is undefined
// (it doesn't exist). 
// cred.#apiKey would throw a SyntaxError. 
// The ONLY way to access it is through the public method getAuthHeader()
node chapter_16_OOps/CLASS_OBJECT/158_Private_Public.js
admin
Bearer scret_key_1234

Read the comments in the file. They are the lesson.

  • user is a public field. cred.user prints admin.
  • #apiKey is a private field. You declare it with # on the class. You assign with this.#apiKey = key.
  • cred.apiKey is not the private field. It is a missing public property. Value: undefined. No throw.
  • cred.#apiKey from outside the class is a SyntaxError. The # name is only legal inside Credentials.
  • The only way to read the key is pramodgetAuthHeader(), which concatenates "Bearer " plus the hidden value.

The comment at the bottom says getAuthHeader(). The method is named pramodgetAuthHeader. Classroom naming. I will not invent a rename. The idea holds: a public method is the door; the field is not.

The constructor argument is "scret_key_1234" — that spelling is in the blob.

Playwright mapping: storage state paths, API tokens, webhook secrets. They do not live as this.apiKey on a page object that a spec can overwrite. They live as #apiKey or, later in TypeScript, private. The spec calls getAuthHeader() or a fixture injects the header. A junior SDET logging console.log(cred) should not dump the secret. Private fields do not enumerate the same way public ones do.

If you only remember one line from 158: undefined means you looked at the wrong name; SyntaxError means you tried to reach through the wall.

Lab 159: static belongs to the class

Lab: chapter_16_OOps/CLASS_OBJECT/159_Static.js

Exact file:

class TestRunner {
    static totalTests = 0;
    static passCount = 0;

    constructor(name, passed) {
        this.name = name;
        TestRunner.totalTests++; // 1
        if (passed) {
            TestRunner.passCount++;  //1
        }

    }
    non_static_display() {
        return this.name;

    }
    static summary() {
        return TestRunner.passCount + "/" + TestRunner.totalTests + " passed";
    }

}
// Flow of the Amazon Website
new TestRunner("Login", true);
new TestRunner("Signup", false);
new TestRunner("Cart", true);
new TestRunner("Checkout", true);

console.log(TestRunner.summary());

// You call static with ClassName.method(), NOT object.method().
node chapter_16_OOps/CLASS_OBJECT/159_Static.js
3/4 passed

Four constructions. Three true. One false (Signup). static totalTests and static passCount live on TestRunner, not on Login or Cart. Every new increments the class counters. TestRunner.summary() reads those counters.

The last comment is the rule: ClassName.method(), not object.method(). new TestRunner("Login", true).summary is not how this file calls it. We never even keep the objects. new TestRunner(...) four times, throw the instances away, ask the class for the score.

non_static_display() exists and is never called. It would need an instance: (new TestRunner("Login", true)).non_static_display(). Static summary() does not.

Playwright mapping: a custom reporter’s totals are static-shaped — they belong to the run, not to one test. A shared BasePage.defaultTimeout can be static. Do not put this.totalTests++ on a page instance and expect the next test to see it after the fixture tears the page down. Instance data dies with the instance. Static data lasts for the process.

Interview trap: this inside a static method is the class, not an instance. Lab 160 shows the confusion.

Lab 160: static field versus instance field

Lab: chapter_16_OOps/CLASS_OBJECT/160_Static_p2.js

Exact file:

class Student {
    static collegeName = "PW AT Batch";

    constructor(name) {
        this.name = name;
    }
    static display() {
        console.log(this.name + " are part of the ", Student.collegeName)
    }
}

let amit = new Student("amit");
let miti_jha = new Student("miti_jha");
let sumu = new Student("sumu");
let padmini = new Student("padmini");

console.log(Student.collegeName);
console.log(amit.name);
console.log(miti_jha.name);
node chapter_16_OOps/CLASS_OBJECT/160_Static_p2.js
PW AT Batch
amit
miti_jha

collegeName is one string for every student. amit.name is "amit". We construct four students and print two names plus the college.

Student.display() is never called in this file. If you call it, this.name inside a static method is the class’s name — Student — not "amit". The template " are part of the " is written for a classroom demo of that mix-up. I will not invent a call. I will tell you: do not read instance fields from a static method. Pass them in, or make the method non-static.

Playwright mapping: static baseURL = process.env.BASE_URL on a config class is lab 160. this.username on a page object is not static. Mixing them is how you print LoginPage are part of the https://app.vwo.com and then file a flaky-test ticket.

Encapsulation — hide the field, guard the door

Private fields hide. Encapsulation is the policy on top of hide: get, set, and sometimes refuse.

Lab 161: get and set a hidden child name

Lab: chapter_16_OOps/CLASS_OBJECT/Encapsulation/161_Pramod_Child.js

Exact file:

class Person {
    // Hide you childs
    #child1;
    #child2;

    constructor(name, ch1, ch2) {
        this.name = name;
        this.#child1 = ch1
        this.#child2 = ch2;
    }

    getChild1() {
        return this.#child1;
    }

    setChild1(changed_name) {
        this.#child1 = changed_name;
    }


}

let p = new Person("Pramod", "Vrad", "Jenny");

console.log(p.name);
// console.log(p.#child1);
console.log(p.getChild1());
p.setChild1("VIRAD");
console.log(p.getChild1());
node chapter_16_OOps/CLASS_OBJECT/Encapsulation/161_Pramod_Child.js
Pramod
Vrad
VIRAD

p.name is public. #child1 and #child2 are not. The commented console.log(p.#child1) would throw. We read through getChild1() and write through setChild1("VIRAD"). #child2 has no getter in this file. Encapsulated and unreachable from outside. That is allowed. You do not owe the world a getter for every private field.

The setter here has no guard. Anyone who can call setChild1 can change the value. Lab 163 adds the guard. Learn the door first. Then lock it.

Lab 162: same pattern on a car engine

Lab: chapter_16_OOps/CLASS_OBJECT/Encapsulation/162_Car.js

Exact file:

class Car {
    #engine;

    constructor(name, engineName) {
        this.name = name;
        this.#engine = engin