Look at any Selenium test framework on GitHub and you'll see a BasePage class — goto(), waitForLoad(), screenshot() — and dozens of pages that extend it: LoginPage extends BasePage, DashboardPage extends BasePage, CheckoutPage extends BasePage. Each child class gets every method on the parent for free, then adds its own. That mechanism is inheritance, and the keyword is extends. It's the OOP feature that lets you write the shared parts of your test framework once and reuse them across every page object — without copy-paste, without dragging callers along.
The shape of inheritance
A parent class with shared behaviour:
public class BasePage {
protected String url; // accessible to subclasses
public BasePage(String url) {
this.url = url;
}
public String getTitle() {
// pretend we're calling driver.getTitle();
return "Title for " + url;
}
public void navigate() {
System.out.println("Navigating to " + url);
}
}A child class that extends it:
public class LoginPage extends BasePage {
public LoginPage(String url) {
super(url); // pass to the parent constructor
}
public void login(String email, String password) {
System.out.println("Filling email: " + email);
System.out.println("Filling password: ***");
System.out.println("Clicking submit");
}
}Using both:
public class FrameworkDemo {
public static void main(String[] args) {
LoginPage login = new LoginPage("https://staging.myapp.com/login");
login.navigate(); // inherited from BasePage
System.out.println(login.getTitle()); // inherited from BasePage
login.login("alice@x.com", "secret"); // declared on LoginPage
}
}Output:
Navigating to https://staging.myapp.com/login
Title for https://staging.myapp.com/login
Filling email: alice@x.com
Filling password: ***
Clicking submit
LoginPage doesn't redeclare navigate() or getTitle() — it inherits them from BasePage. From a caller's point of view a LoginPage is a BasePage, with the extras LoginPage adds on top. That's the single sentence of inheritance: child has everything the parent has, plus its own additions.
extends and the super keyword
Two new keywords show up in inheritance:
extends ParentClass— declares that this class inherits fromParentClass. Goes in the class header.super(...)— calls the parent's constructor from the child's constructor. Must be the first statement in the child constructor.
If the parent has no zero-arg constructor (because you wrote one with parameters and lost the freebie — see lesson 2), the child must explicitly call super(...) with the matching arguments. If the parent has a zero-arg constructor, Java inserts super() for you implicitly.
super is also useful as super.method(...) to call the parent's version of a method from inside the child — that's the topic of lesson 5.
Single inheritance — only one parent
A Java class can extend exactly one other class. There is no extends Parent1, Parent2. The reason is "the diamond problem" — if two parents define the same method, which version does the child get? Java sidesteps the question by allowing only one parent.
For the cases where a class genuinely needs to play multiple roles ("a class that's both a TestListener and a Reportable"), Java uses interfaces — a class can implements many interfaces. Lesson 1 of chapter 5 picks up that thread.
Access modifiers and inheritance — protected
Lesson 3 introduced private and public. Inheritance brings the third one to the foreground:
private— only the same class. Subclasses cannot see private fields or methods of the parent. They exist; you just can't reach them by name.protected— same package and any subclass. This is the modifier you use for fields the parent's subclasses need.public— everyone, including subclasses.- (default) — same package only.
In BasePage above, protected String url means: LoginPage can read this field directly (this.url), but a random caller cannot. That's how shared state is exposed down the inheritance hierarchy without leaking it sideways. A common shape:
public class BasePage {
protected String url;
private long createdAtMs = System.currentTimeMillis(); // truly private
protected void log(String msg) { System.out.println("[" + url + "] " + msg); }
}
public class CheckoutPage extends BasePage {
public CheckoutPage(String url) {
super(url);
log("created"); // ✅ inherited protected method, accessible
// long age = createdAtMs; // ❌ private to BasePage, not visible
}
}"is-a" — the test for whether to use inheritance
The trap with inheritance is using it for code reuse alone: "this class has methods I want, so I'll extend it." That's how you end up with LoginPage extends Logger because Logger has a log method — which makes nonsense of the type system (LoginPage is not, in any real sense, a Logger).
The standard sanity check is the "is-a" test:
Can I read
Child extends Parentas "every Child is a Parent"?
LoginPage extends BasePage— every login page is a base page. ✅Dog extends Animal— every dog is an animal. ✅Stack extends ArrayList— every stack is an array list? ❌ (a stack is conceptually different)LoginPage extends Logger— every login page is a logger? ❌
If "is-a" doesn't read true, you want composition instead — has-a. LoginPage has a Logger. Make Logger a private field, call its methods. The class doesn't gain Logger's public surface; it just uses Logger internally. Composition is the more flexible default; inheritance is for genuine "is-a" relationships.
A real Page Object hierarchy
The shape every Selenium framework uses:
public class BasePage {
protected String url;
public BasePage(String url) { this.url = url; }
public void navigate() { System.out.println("→ " + url); }
public void waitForLoad() { System.out.println("✓ loaded: " + url); }
public String screenshot() { return "screenshot-of-" + url + ".png"; }
}
public class LoginPage extends BasePage {
public LoginPage() { super("https://staging.myapp.com/login"); }
public void login(String email, String password) {
System.out.println("login(" + email + ", ***)");
}
}
public class DashboardPage extends BasePage {
public DashboardPage() { super("https://staging.myapp.com/dashboard"); }
public int unreadCount() {
System.out.println("reading unread badge");
return 7;
}
}
public class CheckoutPage extends BasePage {
public CheckoutPage() { super("https://staging.myapp.com/checkout"); }
public void placeOrder() {
System.out.println("placing order");
}
}
public class PageDemo {
public static void main(String[] args) {
LoginPage login = new LoginPage();
login.navigate();
login.waitForLoad();
login.login("alice@x.com", "secret");
DashboardPage dash = new DashboardPage();
dash.navigate();
dash.waitForLoad();
System.out.println("Unread: " + dash.unreadCount());
CheckoutPage checkout = new CheckoutPage();
checkout.navigate();
checkout.placeOrder();
}
}Output:
→ https://staging.myapp.com/login
✓ loaded: https://staging.myapp.com/login
login(alice@x.com, ***)
→ https://staging.myapp.com/dashboard
✓ loaded: https://staging.myapp.com/dashboard
reading unread badge
Unread: 7
→ https://staging.myapp.com/checkout
placing order
Three pages share navigate, waitForLoad, and screenshot without duplicating them. Adding a new page is one short class. Adding a new shared behaviour — say, setViewport(int w, int h) — happens once on BasePage and every page acquires it instantly. That is the leverage inheritance gives you.
The Page Object hierarchy, visualised
One parent, many children. Each child gets the parent's surface for free; each adds its own page-specific behaviour. Reading the chart top to bottom, every arrow is the word extends.
⚠️ Common mistakes
- Using inheritance for code reuse with no "is-a" relationship. Putting a generic
Loggerat the top of your hierarchy and making every test classextends Loggerworks but is wrong: a test is not a logger. Use composition (aLoggerfield) instead. The "is-a" test catches this in five seconds. - Forgetting
super(...)when the parent has no zero-arg constructor. IfBasePagehas onlyBasePage(String url), every child constructor must start withsuper(someUrl). Otherwise the compiler complains: no default constructor available in BasePage. The fix is to callsuper(...)explicitly as the first line. - Marking everything
protected"just in case." Treatprotectedlikepublic: once a subclass depends on it, you can't change it. Default toprivate(or even immutablefinalfields), exposeprotectedonly when a subclass genuinely needs reach.
🎯 Practice task
Build a real Page Object hierarchy. 25-30 minutes.
- Create
BasePage.java,LoginPage.java,DashboardPage.java, andPageDemo.javain the same folder. - In
BasePage:protected String url;- constructor
public BasePage(String url) { this.url = url; } public void navigate() { System.out.println("→ " + url); }public void waitForLoad() { System.out.println("✓ loaded: " + url); }protected void log(String msg) { System.out.println("[" + url + "] " + msg); }
- In
LoginPage extends BasePage:- constructor
public LoginPage() { super("https://staging.myapp.com/login"); } public void login(String email, String password)— calllog("login start"), then print the action, thenlog("login complete"). Notice you can reachlogbecause it'sprotectedon the parent.
- constructor
- In
DashboardPage extends BasePage:- constructor that passes the dashboard URL to
super(...) public int unreadCount()returning a hard-coded number, withlog(...)calls before and after
- constructor that passes the dashboard URL to
- In
PageDemo'smain, build aLoginPageand aDashboardPage, callnavigate,waitForLoad, and the page-specific method on each. Confirm the shared methods are inherited. - Compile all four files at once:
javac BasePage.java LoginPage.java DashboardPage.java PageDemo.java. Run withjava PageDemo. - Stretch: add a
private long createdAtMs = System.currentTimeMillis();field onBasePage. Try to access it fromLoginPage— read the compile error. That error isprotectedvsprivatedoing its job:privatetruly means the parent class only.
Inheritance lets you extend shared behaviour. Lesson 5 covers the next step — replacing parent behaviour with child-specific implementations: method overriding and super.method().