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:
- You cannot do
new BaseTest()directly. The compiler refuses — abstract classes are templates, not runnable things. - 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 abstractA 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 (setUp → runTest → tearDown) 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 (
executehere) is oftenfinal, meaning subclasses can't override it. The lifecycle is the framework's responsibility; the test class only fills in the abstract slots. protectedis preferred overpublicfor 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":
- There's an "is-a" relationship. Every
LoginTestis aBaseTest. (Same rule as plain inheritance.) - You have shared concrete code —
setUp,tearDown,screenshot, helpers — that all subclasses use. - You have one or more methods that every subclass must implement differently —
runTest,getPageLocator,assertExpectedState. - 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 yourBaseTestnon-abstract (and accept that callers might donew BaseTest()and callrunTest()— which would do whatever the empty body does, often silently nothing). - Forgetting to mark the method
abstract.public void runTest();(no body, noabstract) 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. CallingrunTest()then does nothing — silent failures hours later. If a method must be supplied, declare itabstract.
🎯 Practice task
Build a BaseTest template-method framework. 25-30 minutes.
- Create
BaseTest.java. Declarepublic abstract class BaseTestwith aprotected String name;field and a constructorpublic BaseTest(String name) { this.name = name; }. - 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(); }
- Add the abstract method:
public abstract void runTest();. No body. Nofinal. - Create three subclasses in their own files:
LoginTest,SearchTest,CheckoutTest. Each one callssuper("Login Test")(etc.) in its constructor and implementsrunTest()with one or twoSystem.out.printlnlines describing what it would do. - Try to write
BaseTest broken = new BaseTest("X");in amainmethod. Compile and read the error. - Try to write a fourth subclass
BrokenTest extends BaseTestthat does not implementrunTest(). Read the compile error. - In a
Runnerclass, declareBaseTest[] suite = { new LoginTest(), new SearchTest(), new CheckoutTest() };. Loop and callt.execute();on each. Confirm each subclass'srunTestruns between the sharedsetUp/tearDown. - Stretch: mark
execute()asfinal. Now try to overrideexecute()inLoginTest. The compiler will refuse — that's the framework declaring "the lifecycle is mine; you only get to fill inrunTest." 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.