Day 8: JavaScript OOP and Inheritance — From Class to 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— threegs.Exporting_Class/Basepage.js— lowercasepinpage.LoginPage.jsimports./Basepage.js. If you inventBasePage.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.*

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.
LoginPageis not one login. It is the shape of every login you will construct. - An object is one instance.
new LoginPage()in this spec. Anothernew LoginPage()in the next spec. Same class. Two objects. - Attributes are the data —
name,baseURL,#apiKey,this.pagelater. - 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 getsopen()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 intests/.
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:
- Write a
classwith attributes and behaviours, construct it withnew, and say whatthisis. - Tell a function from a method — a method is a function that lives on the class.
- Hold two instances of the same class with different data — two browsers, two API clients, two test cases.
- Hide a field with
#and expose it only through a method. Explain whycred.#apiKeyis a SyntaxError andcred.apiKeyisundefined. - Use
staticfor data that belongs to the class, not the instance — a pass-count, a college name, later a shared default timeout. - Encapsulate with get/set, including a guard (
isCashier) so a setter is not a public hole. exporta named binding and adefault,importwith and withoutas, and explain whyfnameintestutil.jscannot be imported.- Write
class Child extends Parent, callsuper()in the constructor, and callsuper.method()when you override. - Walk a list of subclasses and call the same method name — polymorphism — the way a suite walks Unit / API / E2E or Login / Dashboard / Cart.
- Say out loud: JavaScript has no
class C extends A, B. Mixins in172.jsare the classroom stand-in. - Export
BasePageandLoginPageas 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.jsEXPORT_IMPORT/151_Export_Import.jsEXPORT_IMPORT/152_Loggger.js(filename is spelled that way)
Shared modules sitting next to those folders:
logger.jstestutil.jsutils.js
CLASS_OBJECT:
CLASS_OBJECT/153_Class_Objects.jsCLASS_OBJECT/154_Car.jsCLASS_OBJECT/155_Class_Object_Browser.jsCLASS_OBJECT/156_Browser.jsCLASS_OBJECT/157_IQ.jsCLASS_OBJECT/158_Private_Public.jsCLASS_OBJECT/159_Static.jsCLASS_OBJECT/160_Static_p2.js
Encapsulation (nested under CLASS_OBJECT):
CLASS_OBJECT/Encapsulation/161_Pramod_Child.jsCLASS_OBJECT/Encapsulation/162_Car.jsCLASS_OBJECT/Encapsulation/163_Bank.js
Chapter 17 — Inheritance (chapter_17_OOPs_Inheritance/)
Single:
Single_Inheritance/164_Inheritance.jsSingle_Inheritance/165_SI.jsSingle_Inheritance/166_Method_Overriding.jsSingle_Inheritance/167_MO_IQ.jsSingle_Inheritance/168_MO_BIG.jsSingle_Inheritance/169_PageObject.jsSingle_Inheritance/170_RO.js
Then:
Multi_Level_Inheritance/171_MI.jsMultiple_Inheritance/172.jsExporting_Class/Basepage.jsExporting_Class/LoginPage.jsExporting_Class/173_Test_2.jsHierarchial_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: 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.
useris a public field.cred.userprintsadmin.#apiKeyis a private field. You declare it with#on the class. You assign withthis.#apiKey = key.cred.apiKeyis not the private field. It is a missing public property. Value:undefined. No throw.cred.#apiKeyfrom outside the class is a SyntaxError. The#name is only legal insideCredentials.- 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: 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