Inheritance lets a child class add methods. Method overriding lets a child class replace a parent's method with its own version — same name, same parameters, different behaviour. This is the OOP feature that makes a generic BasePage.waitForLoad() work on a LoginPage (waits for the login form) and on a DashboardPage (waits for the widgets to render). Each page knows how to wait for itself; the test framework just calls page.waitForLoad() and lets polymorphism dispatch to the right version. This lesson covers the rules of overriding, the @Override annotation that catches typos, and super.method() for "extend the parent, don't replace it entirely."
What overriding looks like
A parent with a generic implementation:
public class BasePage {
protected String url;
public BasePage(String url) { this.url = url; }
public void waitForLoad() {
System.out.println("Generic wait — checking title is non-empty");
}
}A child that supplies its own version:
public class DashboardPage extends BasePage {
public DashboardPage() { super("https://staging.myapp.com/dashboard"); }
@Override
public void waitForLoad() {
System.out.println("Dashboard wait — checking #widget is visible");
}
}Calling code:
public class WaitDemo {
public static void main(String[] args) {
BasePage generic = new BasePage("https://staging.myapp.com/about");
DashboardPage dash = new DashboardPage();
generic.waitForLoad();
dash.waitForLoad();
}
}Output:
Generic wait — checking title is non-empty
Dashboard wait — checking #widget is visible
The two calls look identical at the call site (x.waitForLoad()), but Java picks the right version based on the actual type of the object — not the type of the variable. That decision is made at runtime, which is what makes this polymorphism (lesson 3 of chapter 5 will dig into the term properly).
The @Override annotation — always use it
@Override on a method declaration tells the compiler: I intend to override a method from the parent. If there's no matching parent method, the compiler throws an error and you find your typo immediately:
@Override
public void waitFroLoad() { // typo!
...
}error: method does not override or implement a method from a supertype
Without @Override, this typo would silently create a brand-new method on the child — waitFroLoad — that no one ever calls. The parent's waitForLoad would still run. The bug is invisible until the test relies on the override and gets the wrong behaviour.
@Override is technically optional (the override works either way) but every Java style guide requires it. Treat it as mandatory. IntelliJ adds it automatically when you generate or override a method.
Rules — what counts as overriding
For Java to consider a child method an override:
- Same name.
- Same parameter types in the same order.
- Same return type (or a subtype of the return type — called a covariant return).
- Equal or more permissive access modifier. Parent
public→ childpublic✅. Parentpublic→ childprotected❌. - Cannot throw broader checked exceptions than the parent declared (we'll meet checked exceptions in chapter 7).
If any of these don't match, the child method isn't overriding — it's a different method, and @Override will make the compiler tell you so.
Overriding vs overloading — keep them straight
These two words sound alike and mean opposite things. Internalise this once:
Overriding vs Overloading
Overriding — child REPLACES parent's method
Same name, SAME parameter types
Method is declared on parent and child
Annotated with @Override on the child
Runtime decides which version runs based on the object's actual type
QA: BasePage.waitForLoad() → LoginPage's override of waitForLoad()
Overloading — multiple methods, SAME class
Same name, DIFFERENT parameter types or counts
All overloads usually live on the same class
Compile time decides which version runs based on argument types
QA: findElement(String css) and findElement(By locator)
Has nothing to do with inheritance
The cheat sheet: overriding crosses the parent-child boundary; overloading stays within one class. The compiler resolves overloads at compile time; the JVM resolves overrides at runtime. They are fully independent features that happen to sound similar.
super.method() — extend, don't replace
Sometimes the child wants to add to the parent's behaviour, not replace it. super.method(...) calls the parent's version from inside the child:
public class BasePage {
public void waitForLoad() {
System.out.println("• generic check: title non-empty");
}
}
public class DashboardPage extends BasePage {
@Override
public void waitForLoad() {
super.waitForLoad(); // run the parent first
System.out.println("• dashboard check: #widget visible");
}
}Calling new DashboardPage().waitForLoad() prints both lines:
• generic check: title non-empty
• dashboard check: #widget visible
This pattern — do the parent's work, then add my own — is everywhere in test framework hierarchies. BasePage.setUp() clears cookies; subclass setUp() calls super.setUp() then logs in. The order matters: super.setUp() first, page-specific work after.
You can also call the parent after doing your own work, or skip super.method() entirely if you genuinely want to replace the parent's behaviour wholesale. The choice is yours; super.method() is a tool, not a requirement.
A real example — failure-aware screenshot
A BasePage has a generic screenshot method. A FailurePage overrides it to include the error in the filename:
public class BasePage {
protected String url;
public BasePage(String url) { this.url = url; }
public String screenshot() {
return "screenshot-of-" + slug(url) + ".png";
}
protected String slug(String s) {
return s.replace("https://", "").replace("/", "_");
}
}
public class FailurePage extends BasePage {
private final String errorCode;
public FailurePage(String url, String errorCode) {
super(url);
this.errorCode = errorCode;
}
@Override
public String screenshot() {
String base = super.screenshot(); // reuse parent's logic
return base.replace(".png", "-" + errorCode + ".png");
}
}
public class ScreenshotDemo {
public static void main(String[] args) {
BasePage about = new BasePage("https://staging.myapp.com/about");
FailurePage fail = new FailurePage("https://staging.myapp.com/checkout", "ERR_500");
System.out.println(about.screenshot());
System.out.println(fail.screenshot());
}
}Output:
screenshot-of-staging.myapp.com_about.png
screenshot-of-staging.myapp.com_checkout-ERR_500.png
FailurePage.screenshot() calls super.screenshot() to get the standard filename, then appends the error code. The slug logic only lives on BasePage; the override layers on top. If you later change the slug rules in the parent, every subclass picks up the change automatically — that's the leverage of super.method().
Polymorphism in one line
Here's why this matters for test code:
BasePage[] pages = {
new LoginPage(),
new DashboardPage(),
new FailurePage("https://staging.myapp.com/checkout", "ERR_500")
};
for (BasePage page : pages) {
page.navigate();
page.waitForLoad(); // ← each page's own override runs
System.out.println(page.screenshot());
}The variable type is BasePage[], so the compiler only knows it's holding BasePage references. At runtime, Java looks at each object's actual type and dispatches to the right waitForLoad and screenshot. Three different behaviours, one loop, no if/switch on type. That is the whole point of overriding, and we'll come back to it as polymorphism in chapter 5.3.
⚠️ Common mistakes
- Skipping
@Overrideand silently creating a sibling method. Without the annotation, a typo (waitFroLoad) compiles fine and creates a separate method that nobody calls. The parent'swaitForLoadkeeps running, masquerading as the override. Use@Overrideon every override. - Confusing overriding with overloading. Adding a
findElement(WebElement parent, String css)to a child class next to an inheritedfindElement(String css)is overloading, not overriding. Both methods exist. Useful sometimes, surprising when you expected a replacement. - Forgetting
super.method()and replacing setup that the parent needed. IfBasePage.setUp()clears cookies, and your child override forgetssuper.setUp(), every test starts with stale cookies and you spend a day chasing flakes. When the parent does meaningful work, callsuperunless you're sure you don't want it.
🎯 Practice task
Override behaviour in a small page hierarchy. 25-30 minutes.
- Open the
BasePage/LoginPage/DashboardPagefiles from lesson 4 (or recreate them). - On
BasePage, definepublic void waitForLoad() { System.out.println("[base] generic wait for " + url); }. - On
LoginPage, override it: print[login] waiting for #email-input. Do not callsuper.waitForLoad(). (Login pages typically don't need the generic title check.) - On
DashboardPage, override it: callsuper.waitForLoad()first, then print[dashboard] waiting for #widget. (Dashboards do want the generic check + the widget check.) - In a
Democlass, declareBasePage[] pages = { new LoginPage(), new DashboardPage(), new BasePage("https://staging.myapp.com/about") };. Loop and callpage.waitForLoad()on each. Confirm each prints the right output — proof that the JVM is picking the override based on the actual object type. - Add
@Overrideto both child overrides. Now deliberately misspell one aswaitForLaod— read the compile error and confirm that@Overridecaught it. - Stretch: override
screenshot()onLoginPageto callsuper.screenshot()then append-login. Run it and inspect the filename. Then accidentally change the parameter list on the override — say, take aString nameargument — and watch the override silently turn into an overload (a brand-new method with two arguments).@Overridewill fail to compile, which is exactly the safety net we wanted.
You now have the full Chapter 4 toolkit: classes, constructors, encapsulation, inheritance, and overriding. Chapter 5 takes the next step into OOP — abstract classes, interfaces, and polymorphism — the foundations of every test framework you'll write.