Abstraction — Abstract Classes and Methods

8 min read

Inheritance lets a child class extend a parent. Overriding lets a child replace one of the parent's methods. Abstraction takes the next step: declaring a method on the parent without supplying a body and forcing every child to provide one. The parent becomes a template — it sets the rules every child must follow but refuses to be used on its own. In QA frameworks, this is the shape every base test class has: shared setUp/tearDown, with runTest() left abstract for each suite to fill in. The compiler enforces "every test must define what to run." That guarantee is the entire point of abstraction.

Abstract classes — templates that can't be instantiated

An abstract class is a class marked with the abstract keyword. Two consequences:

  1. You cannot do new BaseTest() directly. The compiler refuses — abstract classes are templates, not runnable things.
  2. The class is allowed (but not required) to declare abstract methods — methods with no body that subclasses must implement.
public abstract class BaseTest {
    protected String env = "staging";
 
    // Concrete method — fully implemented, shared by every subclass
    public void setUp() {
        System.out.println("[BaseTest] connecting to " + env);
    }
 
    // Abstract method — no body. Every subclass MUST override.
    public abstract void runTest();
 
    public void tearDown() {
        System.out.println("[BaseTest] cleaning up " + env);
    }
 
    // Concrete template method that orchestrates the lifecycle
    public final void execute() {
        setUp();
        runTest();
        tearDown();
    }
}

Trying to instantiate it fails:

BaseTest t = new BaseTest();   // ❌ compile error: BaseTest is abstract

A class can have a mix of abstract and concrete methods. The abstract ones declare what must happen; the concrete ones provide the shared behaviour every subclass gets for free.

Subclassing an abstract class

Any subclass of an abstract class must either implement every abstract method or itself be declared abstract. The compiler enforces this at compile time, not runtime — exactly the safety net you want.

public class LoginTest extends BaseTest {
    @Override
    public void runTest() {
        System.out.println("[LoginTest] driver.get('/login'); fill form; assert dashboard");
    }
}
 
public class SearchTest extends BaseTest {
    @Override
    public void runTest() {
        System.out.println("[SearchTest] search 'java'; assert results > 0");
    }
}

Each subclass supplies its own runTest. If you forget, the build fails:

LoginTest.java: error: LoginTest is not abstract and does not override
abstract method runTest() in BaseTest

That error is the compiler doing the work a code review would otherwise have to do. Subclasses cannot drift away from the contract.

Running it end to end

public class TestRunner {
    public static void main(String[] args) {
        BaseTest[] tests = {
            new LoginTest(),
            new SearchTest()
        };
 
        for (BaseTest t : tests) {
            t.execute();          // runs setUp -> runTest -> tearDown
            System.out.println("---");
        }
    }
}

Output:

[BaseTest] connecting to staging
[LoginTest] driver.get('/login'); fill form; assert dashboard
[BaseTest] cleaning up staging
---
[BaseTest] connecting to staging
[SearchTest] search 'java'; assert results > 0
[BaseTest] cleaning up staging
---

The BaseTest[] variable holds two different concrete types. The loop calls execute() — which is fully implemented on the parent — and execute itself calls the child's runTest() thanks to overriding. The runner code knows nothing about which test is which. That separation is what lets a test framework call any number of test classes through one uniform surface.

The template method pattern

The pattern above is so common it has a name — the template method pattern. The parent class defines the skeleton of an algorithm (setUprunTesttearDown) and uses abstract methods to let subclasses fill in the variable steps. JUnit 5's @BeforeEach, @Test, @AfterEach, TestNG's @BeforeMethod / @AfterMethod, Selenide's BaseTest, every framework — all use this shape. Abstract classes exist precisely to make it expressible.

Two refinements you'll see in real frameworks:

  • The orchestrator method (execute here) is often final, meaning subclasses can't override it. The lifecycle is the framework's responsibility; the test class only fills in the abstract slots.
  • protected is preferred over public for fields a subclass needs to reach (driver, env). Tests should not be able to read those fields from outside the inheritance chain.

Abstract methods are different from empty methods

A common newcomer trap: thinking void runTest() {} (an empty body) is "the same" as abstract void runTest();. They aren't.

  • An empty body is a complete implementation. Subclasses inherit it; they may override but don't have to. Calling runTest() does nothing — silently.
  • An abstract method has no body and forces every concrete subclass to override. The compiler refuses to build a subclass that forgot to implement it.

For a test framework, you want the abstract version. Forgetting to set up your test scenario should fail the build, not the test run.

Constructors on abstract classes

Abstract classes can have constructors — and should, when they need to initialise shared state. The constructor is called by subclasses via super(...), exactly as in lesson 4 of chapter 4:

public abstract class BaseTest {
    protected final String env;
 
    public BaseTest(String env) {
        this.env = env;
    }
    public abstract void runTest();
}
 
public class LoginTest extends BaseTest {
    public LoginTest() {
        super("staging");        // ← required because BaseTest has no zero-arg constructor
    }
    @Override public void runTest() { /* ... */ }
}

The fact that you can't instantiate BaseTest directly with new BaseTest("staging") doesn't mean its constructor is useless — Java still runs the constructor when a subclass is built, to initialise the inherited fields.

Abstract classes vs ordinary inheritance — the FlowChart

The amber root cannot be instantiated; only the three green children can. Every child must implement runTest(). Shared behaviour lives once on the parent; variant behaviour is the only thing children supply.

When to reach for an abstract class

The four signals that say "this should be abstract":

  1. There's an "is-a" relationship. Every LoginTest is a BaseTest. (Same rule as plain inheritance.)
  2. You have shared concrete codesetUp, tearDown, screenshot, helpers — that all subclasses use.
  3. You have one or more methods that every subclass must implement differentlyrunTest, getPageLocator, assertExpectedState.
  4. There is no single sensible default for the abstract method. If a default existed, you'd write a concrete method and let subclasses override.

If you only have shared code and no abstract method, an ordinary (non-abstract) base class is fine. If you only have a contract and no shared code, you probably want an interface (lesson 2).

⚠️ Common mistakes

  • Trying to instantiate an abstract class. new BaseTest() is a compile error. Either pick a concrete subclass to instantiate, or make your BaseTest non-abstract (and accept that callers might do new BaseTest() and call runTest() — which would do whatever the empty body does, often silently nothing).
  • Forgetting to mark the method abstract. public void runTest(); (no body, no abstract) is a compile error. The keyword tells the compiler "this is intentional, force subclasses to implement it." Without it the compiler thinks you forgot the body.
  • Using empty-body methods to fake abstraction. public void runTest() {} lets subclasses skip overriding entirely. Calling runTest() then does nothing — silent failures hours later. If a method must be supplied, declare it abstract.

🎯 Practice task

Build a BaseTest template-method framework. 25-30 minutes.

  1. Create BaseTest.java. Declare public abstract class BaseTest with a protected String name; field and a constructor public BaseTest(String name) { this.name = name; }.
  2. Add concrete methods:
    • public void setUp() that prints [setup] starting <name>
    • public void tearDown() that prints [teardown] finished <name>
    • public final void execute() { setUp(); runTest(); tearDown(); }
  3. Add the abstract method: public abstract void runTest();. No body. No final.
  4. Create three subclasses in their own files: LoginTest, SearchTest, CheckoutTest. Each one calls super("Login Test") (etc.) in its constructor and implements runTest() with one or two System.out.println lines describing what it would do.
  5. Try to write BaseTest broken = new BaseTest("X"); in a main method. Compile and read the error.
  6. Try to write a fourth subclass BrokenTest extends BaseTest that does not implement runTest(). Read the compile error.
  7. In a Runner class, declare BaseTest[] suite = { new LoginTest(), new SearchTest(), new CheckoutTest() };. Loop and call t.execute(); on each. Confirm each subclass's runTest runs between the shared setUp / tearDown.
  8. Stretch: mark execute() as final. Now try to override execute() in LoginTest. The compiler will refuse — that's the framework declaring "the lifecycle is mine; you only get to fill in runTest." That single keyword is the difference between a framework you can trust and one any subclass can break.

You can now write the parent half of every test framework. Lesson 2 introduces interfaces — the other half, used for cross-cutting capabilities and the multi-implementation contract that powers Selenium's WebDriver.

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