Base Page Class and Inheritance

8 min read

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:

  • abstractBasePage itself isn't a real page. You can't instantiate it; you must extend it. Java enforces this contract.
  • protected fields, not private. Subclasses need to read driver and wait to do their own things; protected exposes them within the inheritance tree without making them public.
  • protected methods, not public. The interaction helpers are for use by subclasses building public methods — not for tests to call directly. The test calls loginPage.loginAs(...), which internally calls click(...) and type(...). Keeping these helpers protected enforces that the test never sees raw By constants.
  • final fields. Once the page is constructed, the driver and wait don't change. final makes 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 on BasePage. Don't pollute the base with niche code.
  • Independent concerns that aren't part of the page hierarchy. A ScreenshotTaker service is composition — BaseTest uses a ScreenshotTaker; 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.ts with custom commands; or a BasePage.ts superclass for class-based tests.
  • Playwright — a BaseTest.ts defines shared fixtures; page objects extend a BasePage.
  • 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 public instead of protected. A public click(By) lets tests call loginPage.click(SOME_LOCATOR) directly, bypassing the encapsulation POM exists to provide. Use protected — 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 BasePage with 50 helper methods becomes the next god-object. Keep it small; extract specialised concerns into separate classes that pages compose with.
  • Putting @Test methods on BaseTest. 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. BaseTest should hold setup/teardown only. Tests live in concrete subclasses.

🎯 Practice task

Refactor your existing tests onto BaseTest and BasePage. 40–50 minutes.

  1. Add BasePage.java and BaseTest.java from this lesson under src/test/java/com/mycompany/tests/base/. Mark both abstract.
  2. Update LoginPage, InventoryPage, and any other page objects to extend BasePage. Their constructors become super(driver). Their internal click/type calls now invoke the inherited helpers. Run your tests; everything should still pass — but page files should be smaller.
  3. Update every test class to extend BaseTest. Delete the per-class @BeforeMethod and @AfterMethod driver setup. Run the suite; every test should still pass. Notice how much shorter test classes are — the boilerplate is gone.
  4. Don't repeat yourself test. Add a new helper to BasePage: protected void scrollIntoView(By locator) { ... } using JavascriptExecutor. Call it from one page method. Now every page object has access to scrolling, instantly. That single change demonstrates the inheritance payoff.
  5. 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 that BasePage composes with. Aim for BasePage under 100 lines.
  6. Stretch — ThreadLocal driver in BaseTest. Replace protected WebDriver driver; with private static ThreadLocal<WebDriver> driverHolder = new ThreadLocal<>(); and update getDriver() to return driverHolder.get(). Run the suite with parallel="methods" thread-count="4" in testng.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.

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