Abstract classes and interfaces both express abstraction. They overlap enough that beginners often guess wrong about which to reach for. The good news: there's a clean rule. Abstract classes encode is-a relationships with shared implementation. Interfaces encode can-do capabilities that any class can adopt. This lesson works through the technical differences, then gives you decision rules backed by real QA examples — when to use which, and what a healthy framework uses both for.
The technical differences
Item by item:
| Feature | Abstract class | Interface |
|---|---|---|
| How a class adopts it | extends (one only) | implements (many) |
| Fields with state | Yes — any access modifier | Only public static final constants |
| Constructors | Yes | No |
| Concrete methods | Yes (any access modifier) | Only default and static (Java 8+); private helpers (Java 9+) |
| Default access modifier on methods | None — you specify | public (you can omit it) |
final methods | Yes — locks the implementation | No on instance methods (defaults can be overridden) |
Constructor chaining via super(...) | Yes | n/a |
| Can be instantiated directly | No | No |
The two that drive most decisions: abstract classes can hold instance state and constructors; interfaces cannot. If your shared abstraction needs fields and a constructor — protected String url; this.url = url; — you want an abstract class. If your shared abstraction is purely a contract — void search(String query); — you want an interface.
Use an abstract class when…
The four signals:
- Shared state. Fields that every subclass needs —
protected WebDriver driver,protected String env,protected long createdAtMs. - Shared concrete code. Several methods with bodies that every subclass uses unchanged —
setUp,tearDown,screenshot,log. - One or more methods every subclass must implement differently.
runTest(),getPageLocator(),assertExpectedState()— abstract methods. - A genuine "is-a" relationship.
LoginTest *is a* BaseTest,ProductPage *is a* BasePage. (Same rule as plain inheritance from chapter 4.)
The classic shape is the template method from lesson 1: a parent that orchestrates a lifecycle (execute() { setUp(); runTest(); tearDown(); }) and forces subclasses to fill in the variable step. Frameworks like JUnit, TestNG, Selenide, and Spring's AbstractIntegrationTest all expose this shape as an abstract base class.
Use an interface when…
The four signals:
- A pure contract — no state, no implementation.
WebDriver,Searchable,Sortable,Loggable. - Multiple unrelated classes need the same capability. Both
ProductPageandUserDirectoryPageareSearchable, but they're not in the same inheritance chain. - The class adopting it already extends something. Java allows only one parent class but many implemented interfaces — interfaces are how you mix in new capabilities without rewiring the inheritance tree.
- You're decoupling test code from a specific implementation.
WebDriver driveris the canonical case: tests reference the interface so the implementation can be swapped.
Java 8's default methods slightly blurred the line — interfaces can now ship a method body. Resist the temptation to use default as "abstract class lite" for shared implementation. The intent of default is backward-compatible evolution of an interface (add a method without breaking the 200 classes already implementing it), not large-scale code reuse.
Side-by-side at a glance
Abstract class vs Interface — when each fits
Abstract class
Use for: shared state + shared implementation + abstract slots
Adopted with extends — one parent only
Can hold protected fields, constructors, final methods
Models is-a relationships: LoginTest IS A BaseTest
QA example: BaseTest, BasePage, AbstractApiClient
Interface
Use for: a contract, no state, no enforced implementation
Adopted with implements — a class can implement many
Only constants and default/static methods; no fields, no constructors
Models can-do capabilities: ProductPage IS Searchable
QA example: WebDriver, ITestListener, Searchable, Reportable
The rule of thumb: if you can finish the sentence "this thing is a …", it's a candidate for an abstract class. If you can finish "this thing can …", it's a candidate for an interface. The most expressive frameworks use both — abstract classes for hierarchies, interfaces for capabilities crossing them.
A real test framework uses both
A skeleton you'll recognise:
// Capability — anything reportable
public interface Reportable {
String reportName();
default String prettyName() { return "🧪 " + reportName(); }
}
// Capability — anything retryable
public interface Retryable {
int maxRetries();
}
// Hierarchy — every test extends this
public abstract class BaseTest implements Reportable {
protected final String name;
public BaseTest(String name) { this.name = name; }
@Override public String reportName() { return name; }
public void setUp() { System.out.println("[setup] " + name); }
public void tearDown() { System.out.println("[teardown] " + name); }
public abstract void runTest();
public final void execute() {
setUp();
try { runTest(); }
finally { tearDown(); }
}
}
// A test that mixes the hierarchy with an extra capability
public class FlakyLoginTest extends BaseTest implements Retryable {
public FlakyLoginTest() { super("flaky-login"); }
@Override public int maxRetries() { return 3; }
@Override public void runTest() {
System.out.println("[test] login flow with " + maxRetries() + " retries");
}
}
// Runner that uses both abstractions
public class FrameworkDemo {
public static void main(String[] args) {
BaseTest t = new FlakyLoginTest();
t.execute();
// Treat the same object as a capability
Reportable r = t;
System.out.println("report: " + r.prettyName());
if (t instanceof Retryable retryable) {
System.out.println("retry budget: " + retryable.maxRetries());
}
}
}Output:
[setup] flaky-login
[test] login flow with 3 retries
[teardown] flaky-login
report: 🧪 flaky-login
retry budget: 3
BaseTest (abstract class) gives FlakyLoginTest shared state (name), shared lifecycle (setUp / tearDown / execute), and forces it to implement runTest(). Reportable and Retryable (interfaces) layer on cross-cutting capabilities — Reportable applies to everything that has a name; Retryable applies only to tests that flake. The same FlakyLoginTest is a BaseTest, is Reportable, is Retryable. That orthogonal layering is exactly what real frameworks ship.
A simple decision flowchart
When you sit down to design a new abstraction, ask these in order:
- Do classes adopting this share fields or constructor logic? Yes → abstract class. No → continue.
- Will multiple, unrelated classes need this contract? Yes → interface. No → continue.
- Can the existing class hierarchy already provide this without a new abstraction? Yes → don't add one; use composition or a regular method.
Most QA frameworks end up with one or two abstract classes (per layer) and many interfaces. A page object hierarchy is one abstract class (BasePage) with many concrete pages, plus interfaces (Searchable, Sortable, HasError) that some pages implement. A test runner usually has one abstract test class plus interfaces for TestListener, Reporter, RetryPolicy.
⚠️ Common mistakes
- Reaching for
defaultmethods to share implementation. If your interface ends up with three abstract methods and sevendefaults full of logic, the design wants an abstract class.defaultis for "I added a new method to an old interface and don't want to break callers" — not "I want fields and shared state but only used the wrong tool." - Using an abstract class for a pure contract. If your
BaseSearchablehas zero fields and only abstract methods, it's an interface in disguise — and it costs you a single-inheritance slot. Convert it to an interface and gain the freedom to mix it in alongside other contracts. - Both at once for the same role. Don't define
interface Searchableandabstract class AbstractSearchablefor the same idea. Pick one. (The "AbstractFoo" companion-class pattern from older Java collections is largely a historical workaround that modern code doesn't need.)
🎯 Practice task
Design a small framework using both. 25-30 minutes.
- Create
Reportable.java:public interface Reportable { String reportName(); default String prettyName() { return "🧪 " + reportName(); } }. - Create
Retryable.java:public interface Retryable { int maxRetries(); }. - Create
BaseTest.java:public abstract class BaseTest implements Reportable. Give itprotected final String name;, a constructor, concretesetUp()/tearDown()/execute()(final), andpublic abstract void runTest();. ImplementreportName()to returnname. - Create
LoginTest extends BaseTest. No retries. ImplementrunTest()to print one line. - Create
FlakyApiTest extends BaseTest implements Retryable. OverridemaxRetries()to return 3. ImplementrunTest()to mention the retry budget. - In a
Runnermain, build both tests in aBaseTest[]array and callexecute()on each. - Cast (or
instanceofpattern-match) each one toReportableand callprettyName(). Then checkif (t instanceof Retryable r)and printr.maxRetries()only when applicable. Notice that the array is typedBaseTest[](the hierarchy) but the loop also asks "and is it Retryable?" (the capability). Two complementary abstractions, one runtime. - Stretch: convert
BaseTestfrom an abstract class to an interface and see what you lose. Thenamefield can't live on the interface; subclasses now hold their own copy. The constructor disappears.execute()could be adefaultmethod, but locking itfinalis no longer possible. Reading the diff is the fastest way to feel which tool fits which job.
That's the end of Chapter 5 — and the OOP foundation of the course. Chapter 6 builds on it with the Java collections framework: ArrayList, HashMap, HashSet and the iteration patterns every test data layer is built on.