When to Use Abstract Classes vs Interfaces

7 min read

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:

FeatureAbstract classInterface
How a class adopts itextends (one only)implements (many)
Fields with stateYes — any access modifierOnly public static final constants
ConstructorsYesNo
Concrete methodsYes (any access modifier)Only default and static (Java 8+); private helpers (Java 9+)
Default access modifier on methodsNone — you specifypublic (you can omit it)
final methodsYes — locks the implementationNo on instance methods (defaults can be overridden)
Constructor chaining via super(...)Yesn/a
Can be instantiated directlyNoNo

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:

  1. Shared state. Fields that every subclass needs — protected WebDriver driver, protected String env, protected long createdAtMs.
  2. Shared concrete code. Several methods with bodies that every subclass uses unchanged — setUp, tearDown, screenshot, log.
  3. One or more methods every subclass must implement differently. runTest(), getPageLocator(), assertExpectedState() — abstract methods.
  4. 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:

  1. A pure contract — no state, no implementation. WebDriver, Searchable, Sortable, Loggable.
  2. Multiple unrelated classes need the same capability. Both ProductPage and UserDirectoryPage are Searchable, but they're not in the same inheritance chain.
  3. 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.
  4. You're decoupling test code from a specific implementation. WebDriver driver is 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:

  1. Do classes adopting this share fields or constructor logic? Yes → abstract class. No → continue.
  2. Will multiple, unrelated classes need this contract? Yes → interface. No → continue.
  3. 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 default methods to share implementation. If your interface ends up with three abstract methods and seven defaults full of logic, the design wants an abstract class. default is 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 BaseSearchable has 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 Searchable and abstract class AbstractSearchable for 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.

  1. Create Reportable.java: public interface Reportable { String reportName(); default String prettyName() { return "🧪 " + reportName(); } }.
  2. Create Retryable.java: public interface Retryable { int maxRetries(); }.
  3. Create BaseTest.java: public abstract class BaseTest implements Reportable. Give it protected final String name;, a constructor, concrete setUp() / tearDown() / execute() (final), and public abstract void runTest();. Implement reportName() to return name.
  4. Create LoginTest extends BaseTest. No retries. Implement runTest() to print one line.
  5. Create FlakyApiTest extends BaseTest implements Retryable. Override maxRetries() to return 3. Implement runTest() to mention the retry budget.
  6. In a Runner main, build both tests in a BaseTest[] array and call execute() on each.
  7. Cast (or instanceof pattern-match) each one to Reportable and call prettyName(). Then check if (t instanceof Retryable r) and print r.maxRetries() only when applicable. Notice that the array is typed BaseTest[] (the hierarchy) but the loop also asks "and is it Retryable?" (the capability). Two complementary abstractions, one runtime.
  8. Stretch: convert BaseTest from an abstract class to an interface and see what you lose. The name field can't live on the interface; subclasses now hold their own copy. The constructor disappears. execute() could be a default method, but locking it final is 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.

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