Inheritance and the extends Keyword

9 min read

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 from ParentClass. 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 Parent as "every Child is a Parent"?

  • LoginPage extends BasePageevery login page is a base page.
  • Dog extends Animalevery dog is an animal.
  • Stack extends ArrayListevery stack is an array list? ❌ (a stack is conceptually different)
  • LoginPage extends Loggerevery 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 Logger at the top of your hierarchy and making every test class extends Logger works but is wrong: a test is not a logger. Use composition (a Logger field) instead. The "is-a" test catches this in five seconds.
  • Forgetting super(...) when the parent has no zero-arg constructor. If BasePage has only BasePage(String url), every child constructor must start with super(someUrl). Otherwise the compiler complains: no default constructor available in BasePage. The fix is to call super(...) explicitly as the first line.
  • Marking everything protected "just in case." Treat protected like public: once a subclass depends on it, you can't change it. Default to private (or even immutable final fields), expose protected only when a subclass genuinely needs reach.

🎯 Practice task

Build a real Page Object hierarchy. 25-30 minutes.

  1. Create BasePage.java, LoginPage.java, DashboardPage.java, and PageDemo.java in the same folder.
  2. 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); }
  3. In LoginPage extends BasePage:
    • constructor public LoginPage() { super("https://staging.myapp.com/login"); }
    • public void login(String email, String password) — call log("login start"), then print the action, then log("login complete"). Notice you can reach log because it's protected on the parent.
  4. In DashboardPage extends BasePage:
    • constructor that passes the dashboard URL to super(...)
    • public int unreadCount() returning a hard-coded number, with log(...) calls before and after
  5. In PageDemo's main, build a LoginPage and a DashboardPage, call navigate, waitForLoad, and the page-specific method on each. Confirm the shared methods are inherited.
  6. Compile all four files at once: javac BasePage.java LoginPage.java DashboardPage.java PageDemo.java. Run with java PageDemo.
  7. Stretch: add a private long createdAtMs = System.currentTimeMillis(); field on BasePage. Try to access it from LoginPage — read the compile error. That error is protected vs private doing its job: private truly 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().

// tip to track lessons you complete and pick up where you left off across devices.