You wrote LoginPage, then InventoryPage, then CartPage. Each one has a WebDriver field, a WebDriverWait, a private clickWhenReady helper, a typeAfterClear helper, a waitForInvisibility. Three pages, three copies of the same boilerplate. The fix is the most familiar Java idiom of all: pull the shared code into a superclass. BasePage holds the driver, the wait, and the interaction helpers; every concrete page extends it. BaseTest does the same for test-class boilerplate — driver setup and teardown. This lesson builds both, applies them across your existing tests, and shows the surprisingly clean inheritance hierarchy that emerges.
Why BasePage exists
Three pages, three constructors that look like this:
public LoginPage(WebDriver driver) {
this.driver = driver;
this.wait = new WebDriverWait(driver, Duration.ofSeconds(10));
}
public InventoryPage(WebDriver driver) { /* same body */ }
public CartPage(WebDriver driver) { /* same body */ }Three copies of the same clickWhenReady. Three copies of the same waitForVisibility. Forty pages later, you're maintaining 40 copies of the exact same six methods. The first time you decide you want a new helper (say, screenshotElement), you have to add it to all 40. That's the Don't-Repeat-Yourself wound POM is supposed to heal — and inheritance heals it inside POM itself.
BasePage — the shared parent
package com.mycompany.tests.base;
import org.openqa.selenium.By;
import org.openqa.selenium.NoSuchElementException;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.support.ui.ExpectedConditions;
import org.openqa.selenium.support.ui.WebDriverWait;
import java.time.Duration;
public abstract class BasePage {
protected final WebDriver driver;
protected final WebDriverWait wait;
protected BasePage(WebDriver driver) {
this.driver = driver;
this.wait = new WebDriverWait(driver, Duration.ofSeconds(10));
}
// --- Interaction helpers ---
protected void click(By locator) {
wait.until(ExpectedConditions.elementToBeClickable(locator)).click();
}
protected void type(By locator, String text) {
WebElement el = wait.until(ExpectedConditions.visibilityOfElementLocated(locator));
el.clear();
el.sendKeys(text);
}
protected String getText(By locator) {
return wait.until(ExpectedConditions.visibilityOfElementLocated(locator)).getText();
}
protected boolean isDisplayed(By locator) {
try {
return driver.findElement(locator).isDisplayed();
} catch (NoSuchElementException e) {
return false;
}
}
protected void waitForInvisibility(By locator) {
wait.until(ExpectedConditions.invisibilityOfElementLocated(locator));
}
// --- Page-level info ---
public String pageTitle() {
return driver.getTitle();
}
public String currentUrl() {
return driver.getCurrentUrl();
}
}A few details worth noticing:
abstract—BasePageitself isn't a real page. You can't instantiate it; you must extend it. Java enforces this contract.protectedfields, notprivate. Subclasses need to readdriverandwaitto do their own things;protectedexposes them within the inheritance tree without making them public.protectedmethods, notpublic. The interaction helpers are for use by subclasses building public methods — not for tests to call directly. The test callsloginPage.loginAs(...), which internally callsclick(...)andtype(...). Keeping these helpersprotectedenforces that the test never sees rawByconstants.finalfields. Once the page is constructed, the driver and wait don't change.finalmakes that explicit and prevents accidental reassignment in subclasses.
A LoginPage that uses BasePage
package com.mycompany.tests.pages;
import com.mycompany.tests.base.BasePage;
import org.openqa.selenium.By;
import org.openqa.selenium.WebDriver;
public class LoginPage extends BasePage {
private static final By USERNAME = By.id("user-name");
private static final By PASSWORD = By.id("password");
private static final By LOGIN_BUTTON = By.id("login-button");
private static final By ERROR = By.cssSelector("[data-test='error']");
public LoginPage(WebDriver driver) {
super(driver);
}
public LoginPage navigateTo() {
driver.get("https://www.saucedemo.com");
return this;
}
public InventoryPage loginAs(String username, String password) {
type(USERNAME, username);
type(PASSWORD, password);
click(LOGIN_BUTTON);
return new InventoryPage(driver);
}
public String errorText() {
return getText(ERROR);
}
public boolean errorVisible() {
return isDisplayed(ERROR);
}
}The constructor is one line: super(driver). Every method in the body is now a single readable verb — type(USERNAME, username), click(LOGIN_BUTTON). The wait machinery, the findElement boilerplate, the staleness handling — all hidden behind helpers in BasePage. The page itself is only about its specific intent: log in.
BaseTest — the test-class twin
The same idea applies to test classes. Every test class needs a WebDriver field, a @BeforeMethod to create it, an @AfterMethod to quit. Hoist it into a parent:
package com.mycompany.tests.base;
import io.github.bonigarcia.wdm.WebDriverManager;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.chrome.ChromeDriver;
import org.openqa.selenium.chrome.ChromeOptions;
import org.testng.annotations.AfterMethod;
import org.testng.annotations.BeforeMethod;
public abstract class BaseTest {
protected WebDriver driver;
@BeforeMethod
public void createDriver() {
WebDriverManager.chromedriver().setup();
ChromeOptions options = new ChromeOptions();
if ("true".equals(System.getenv("CI"))) {
options.addArguments("--headless=new", "--no-sandbox", "--disable-dev-shm-usage");
}
options.addArguments("--window-size=1920,1080");
driver = new ChromeDriver(options);
}
@AfterMethod
public void quitDriver() {
if (driver != null) driver.quit();
}
public WebDriver getDriver() {
return driver;
}
}Every test class that extends BaseTest gets free driver setup, free CI-aware headless mode, and a free getDriver() method (the listener from chapter 5 used this to take screenshots on failure). The test class itself becomes pure intent:
public class LoginTest extends BaseTest {
@Test
public void shouldLoginValidUser() {
InventoryPage inventory = new LoginPage(driver)
.navigateTo()
.loginAs("standard_user", "secret_sauce");
Assert.assertEquals(inventory.productCount(), 6);
}
}No @BeforeMethod, no @AfterMethod, no driver bookkeeping. Five lines for a real end-to-end test that creates a browser, runs a flow, asserts the result, and cleans up.
The class hierarchy
Two abstract parents at the top. Concrete classes inherit. New tests join the tree by extending — no boilerplate to copy. Same for new pages.
If you've worked through Core Java for QA's OOP chapters, this is exactly the inheritance pattern from the abstraction lesson, applied to the QA domain. The protected keyword, the abstract modifier, the super(driver) call — all the same Java mechanics.
A complete BaseTest + BasePage test
Putting both together:
// LoginTest extends BaseTest, uses LoginPage which extends BasePage
package com.mycompany.tests.tests;
import com.mycompany.tests.base.BaseTest;
import com.mycompany.tests.pages.InventoryPage;
import com.mycompany.tests.pages.LoginPage;
import org.testng.Assert;
import org.testng.annotations.Test;
public class LoginInheritanceTest extends BaseTest {
@Test
public void shouldLoginAndShowProducts() {
InventoryPage inventory = new LoginPage(driver)
.navigateTo()
.loginAs("standard_user", "secret_sauce");
Assert.assertEquals(inventory.productCount(), 6);
}
@Test
public void shouldShowErrorForLockedOutUser() {
LoginPage login = new LoginPage(driver).navigateTo();
// Stay on the login page — don't navigate
login.fillUsername("locked_out_user");
login.fillPassword("secret_sauce");
login.submit(); // returns InventoryPage on success — but here we expect error
Assert.assertTrue(login.errorVisible());
Assert.assertTrue(login.errorText().contains("locked out"));
}
}Two tests, no setup/teardown ceremony in the test class. Adding a CartTest next is one new file with extends BaseTest at the top.
When NOT to use inheritance
Three cases where composition or static utilities work better:
- Cross-cutting helpers that don't need driver state. A
RandomData.username()that returns a string doesn't belong on a base class. Static utility class. - Page-specific helpers that don't generalise. A
Calendar.parseDate(...)that's only useful for one date-picker page object belongs on that page, not onBasePage. Don't pollute the base with niche code. - Independent concerns that aren't part of the page hierarchy. A
ScreenshotTakerservice is composition —BaseTestuses aScreenshotTaker; it doesn't extend one.
The rule of thumb: inheritance for the "is-a" relationship (a LoginPage is a BasePage); composition for the "has-a" relationship (a BaseTest has a ScreenshotTaker).
Comparison with other frameworks
The base-class pattern is universal:
- Cypress — engineers write a
BaseCommands.tswith custom commands; or aBasePage.tssuperclass for class-based tests. - Playwright — a
BaseTest.tsdefines shared fixtures; page objects extend aBasePage. - pytest — base classes are common, but most teams prefer fixtures (composition over inheritance).
The Java/TestNG version of this pattern is more inheritance-heavy than the JavaScript or Python equivalents. That's idiomatic — Java leans into class hierarchies more than the other languages do.
The Selenium tool entry covers driver helpers; the TestNG cheat sheet covers @BeforeMethod patterns.
⚠️ Common mistakes
- Making BasePage's helper methods
publicinstead ofprotected. A publicclick(By)lets tests callloginPage.click(SOME_LOCATOR)directly, bypassing the encapsulation POM exists to provide. Useprotected— only subclasses (the pages themselves) can use them. - Giving BasePage too many responsibilities. Helper methods are fine. Logging, screenshot-taking, custom analytics, A/B-test toggles — those are different concerns. A 500-line
BasePagewith 50 helper methods becomes the next god-object. Keep it small; extract specialised concerns into separate classes that pages compose with. - Putting
@Testmethods onBaseTest. They run for every subclass — usually not what you want, and the test names appear under every concrete test class in reports, doubling apparent count.BaseTestshould hold setup/teardown only. Tests live in concrete subclasses.
🎯 Practice task
Refactor your existing tests onto BaseTest and BasePage. 40–50 minutes.
- Add
BasePage.javaandBaseTest.javafrom this lesson undersrc/test/java/com/mycompany/tests/base/. Mark bothabstract. - Update
LoginPage,InventoryPage, and any other page objects to extendBasePage. Their constructors becomesuper(driver). Their internalclick/typecalls now invoke the inherited helpers. Run your tests; everything should still pass — but page files should be smaller. - Update every test class to extend
BaseTest. Delete the per-class@BeforeMethodand@AfterMethoddriver setup. Run the suite; every test should still pass. Notice how much shorter test classes are — the boilerplate is gone. - Don't repeat yourself test. Add a new helper to
BasePage:protected void scrollIntoView(By locator) { ... }usingJavascriptExecutor. Call it from one page method. Now every page object has access to scrolling, instantly. That single change demonstrates the inheritance payoff. - Keep BasePage small. Open
BasePage.java. Count its public/protected methods. If you have more than ~12, you're probably doing too much. Extract anything related to a specific concern (waits, screenshots, navigation) into separate utility classes thatBasePagecomposes with. Aim forBasePageunder 100 lines. - Stretch — ThreadLocal driver in BaseTest. Replace
protected WebDriver driver;withprivate static ThreadLocal<WebDriver> driverHolder = new ThreadLocal<>();and updategetDriver()to returndriverHolder.get(). Run the suite withparallel="methods" thread-count="4"intestng.xml. Each thread now has its own driver. (We'll come back to thread safety in chapter 8.)
Next lesson: the fluent style. Methods that return the next page object so tests read as a pipeline — loginPage.navigateTo().loginAs(...).addToCart(...).checkout(). The final design polish on the POM.