Polymorphism in Practice

8 min read

Polymorphism is the single word that ties together everything you've learned in this chapter. It means "many shapes" — one method call, dispatched at runtime to whichever concrete implementation is actually behind the reference. When you write driver.findElement(...) against a WebDriver variable, the JVM looks at the real object — ChromeDriver, FirefoxDriver, RemoteWebDriver — and runs that class's findElement. Your test never knows which one. That ignorance is what makes test code portable across browsers, page types, and frameworks. This lesson shows polymorphism in real code, distinguishes runtime polymorphism (overriding) from compile-time polymorphism (overloading), and connects both to the QA frameworks you'll use every day.

Two flavours of polymorphism

Java actually has two:

  • Runtime polymorphism — method overriding. Java decides which version to call when the program runs, based on the actual object's type. This is what people usually mean by "polymorphism."
  • Compile-time polymorphism — method overloading. Java decides which version to call at compile time, based on the static types of the arguments.

Both let one name (e.g., findElement) cover several behaviours. The difference is when Java picks. Runtime polymorphism is the one that powers real frameworks; overloading is genuinely useful but rarely the headline.

Runtime polymorphism — the WebDriver example

public interface BrowserDriver {
    void open(String url);
    String pageTitle();
}
 
public class ChromeDriver implements BrowserDriver {
    @Override public void open(String url) {
        System.out.println("[Chrome] navigating to " + url);
    }
    @Override public String pageTitle() {
        return "[Chrome] My App — Login";
    }
}
 
public class FirefoxDriver implements BrowserDriver {
    @Override public void open(String url) {
        System.out.println("[Firefox] navigating to " + url);
    }
    @Override public String pageTitle() {
        return "[Firefox] My App — Login";
    }
}
 
public class SafariDriver implements BrowserDriver {
    @Override public void open(String url) {
        System.out.println("[Safari] navigating to " + url);
    }
    @Override public String pageTitle() {
        return "[Safari] My App — Login";
    }
}

The whole test:

public class CrossBrowserSmoke {
 
    static BrowserDriver browserFor(String name) {
        return switch (name) {
            case "chrome"  -> new ChromeDriver();
            case "firefox" -> new FirefoxDriver();
            case "safari"  -> new SafariDriver();
            default        -> throw new IllegalArgumentException("Unknown browser: " + name);
        };
    }
 
    static void smokeTest(BrowserDriver driver) {
        driver.open("https://staging.myapp.com/login");
        System.out.println("title = " + driver.pageTitle());
    }
 
    public static void main(String[] args) {
        for (String name : new String[]{"chrome", "firefox", "safari"}) {
            BrowserDriver driver = browserFor(name);
            smokeTest(driver);
        }
    }
}

Output:

[Chrome] navigating to https://staging.myapp.com/login
title = [Chrome] My App — Login
[Firefox] navigating to https://staging.myapp.com/login
title = [Firefox] My App — Login
[Safari] navigating to https://staging.myapp.com/login
title = [Safari] My App — Login

Read smokeTest(BrowserDriver driver) carefully: it accepts a BrowserDriver interface reference. It has no idea which concrete class is behind it. The JVM picks ChromeDriver.open(...) vs FirefoxDriver.open(...) based on the actual object type, not on the variable's declared type. That decision happens at runtime — hence "runtime polymorphism." Your test code stays the same when you add EdgeDriver to the lineup; only the factory method grows.

What's actually happening — step by step

Step 1 of 6

Variable: BrowserDriver driver

The compiler only knows the static type — BrowserDriver, an interface. It checks that .open(String) is in the interface (it is) and accepts the call.

The crucial moment is step 3: the JVM uses the runtime class of the object, not the declared type of the variable. That's the engine of runtime polymorphism. Every @Override you've written this chapter is a method that participates in this lookup.

Override + abstract class — the same engine

Polymorphism doesn't need an interface; it works the same way for class inheritance and overriding (chapter 4.5):

public abstract class BasePage {
    public abstract void waitForLoad();
}
 
public class LoginPage extends BasePage {
    @Override public void waitForLoad() {
        System.out.println("[login] waiting for #email input");
    }
}
 
public class DashboardPage extends BasePage {
    @Override public void waitForLoad() {
        System.out.println("[dashboard] waiting for #widget");
    }
}
 
public class PageRunner {
    static void load(BasePage page) {
        page.waitForLoad();        // dispatched at runtime
    }
    public static void main(String[] args) {
        load(new LoginPage());
        load(new DashboardPage());
    }
}

Output:

[login] waiting for #email input
[dashboard] waiting for #widget

load(BasePage page) works on any subclass of BasePage. page.waitForLoad() dispatches polymorphically — same mechanism as the interface case. Whether you abstract over a class or an interface, the runtime lookup is identical.

Compile-time polymorphism — overloading

Method overloading (chapter 3.2) is "polymorphism" in a weaker sense — Java picks among several methods at compile time based on argument types, not the runtime object:

public class Logger {
    public static void log(int code)        { System.out.println("int: " + code); }
    public static void log(String msg)      { System.out.println("string: " + msg); }
    public static void log(boolean passed)  { System.out.println("bool: " + passed); }
 
    public static void main(String[] args) {
        log(200);                // resolves to log(int) at compile time
        log("hello");            // resolves to log(String)
        log(true);               // resolves to log(boolean)
    }
}

Output:

int: 200
string: hello
bool: true

The compiler reads each call site, checks the argument types, and bakes the choice into the bytecode. There is no runtime lookup — no surprise. You'll see this in JUnit's assertEquals(int expected, int actual) vs assertEquals(String expected, String actual) vs assertEquals(double expected, double actual, double delta) overloads; the compiler picks the right one for your assertion.

Why this matters for QA

Three concrete payoffs of polymorphism:

  1. Tests are decoupled from drivers. WebDriver driver = new ChromeDriver(); swaps to FirefoxDriver with one line; the rest of the suite is identical. A test framework that hard-codes ChromeDriver everywhere is a framework that can't run on Safari for free.
  2. Page Object Model is a polymorphism story. A test runner can hold a BasePage reference and call waitForLoad, screenshot, tearDown without caring which page it is. Adding a new page is one new class — no if (instanceof LoginPage) chains.
  3. Test framework hooks (TestNG ITestListener, JUnit Extension) are interfaces. You implement them; the framework holds your class through the interface; the framework's runtime calls your overrides at the right moments. You only see this magic working because of runtime polymorphism.

⚠️ Common mistakes

  • if (driver instanceof ChromeDriver) ... chains. When you find yourself branching on the actual type, polymorphism is failing — you're hand-rolling the dispatch the JVM should be doing. Add a method to the interface (takeBrowserSpecificScreenshot()) and let each concrete class override it; the instanceof chain disappears.
  • Mistaking overloading for runtime polymorphism. Overloading resolves at compile time on the static argument types. log((Object)"hi") calls log(Object) — not log(String) — even though the runtime value is still a String. Overloading is a different mechanism; don't expect runtime dispatch from it.
  • Casting back to the concrete type "just to be safe." ((ChromeDriver) driver).chromeOnlyMethod(); ties your test to Chrome and breaks the moment you swap browsers. If you really need a Chrome-specific feature, isolate it behind an interface or a config flag — don't cast.

🎯 Practice task

Build a polymorphic test runner. 25-30 minutes.

  1. Create BrowserDriver.java. Declare public interface BrowserDriver with void open(String url); and String pageTitle();.
  2. Create ChromeDriver.java, FirefoxDriver.java, SafariDriver.java. Each implements BrowserDriver and prints a class-specific message in open(...) and returns a class-specific title from pageTitle().
  3. Create Runner.java with a main method.
  4. Add static BrowserDriver browserFor(String name) that returns the matching driver — use a switch expression and throw IllegalArgumentException for an unknown name.
  5. Add static void smokeTest(BrowserDriver driver) that calls driver.open("https://staging.myapp.com/login"); and prints title = " + driver.pageTitle();. The method must reference only the interface.
  6. In main, loop over {"chrome", "firefox", "safari"} and call smokeTest(browserFor(name)). Confirm three different concrete behaviours run from the same line of code.
  7. Stretch — overloading vs overriding side-by-side:
    • Add static void log(String msg) and static void log(int code) to Runneroverloading. Call log("hi") and log(404) and confirm the right one runs based on argument type at compile time.
    • Then write Object value = "hi"; log(value); — note the compile error or which overload is picked. Java resolves overloading on the static type, not the runtime value. That's the difference from overriding in two lines of code.
    • Finally, add EdgeDriver implements BrowserDriver. Notice you change one line of browserFor and one new file. The rest of the runner is unchanged. That's runtime polymorphism doing the work for you.

You now understand the engine that drives every Java test framework. Lesson 4 helps you decide which abstraction tool — abstract class or interface — fits each design problem.

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