An interface is a contract: a list of method signatures with no implementation. Any class that implements the interface must supply concrete code for every method in it. Interfaces are how Java answers "what should this class be able to do?" without saying anything about how it does it. Selenium's WebDriver is the most famous example in the QA world: WebDriver is an interface; ChromeDriver, FirefoxDriver, SafariDriver, and a dozen others all implement it. Your tests reference the interface, and you swap concrete drivers without touching test code. That decoupling is the entire reason interfaces exist.
Declaring an interface
The keyword is interface. Methods inside have no body — they're implicitly public abstract:
public interface Searchable { void search(String query); int getResultCount();}public interface Sortable { void sortBy(String field);}
A class adopts an interface with implements (not extends). The compiler then forces every interface method to be implemented:
public class ProductPage implements Searchable, Sortable { private int lastResultCount; private String lastQuery = ""; private String sortField = "relevance"; @Override public void search(String query) { this.lastQuery = query; this.lastResultCount = 12; // pretend we hit the API System.out.println("ProductPage: searching for '" + query + "'"); } @Override public int getResultCount() { return lastResultCount; } @Override public void sortBy(String field) { this.sortField = field; System.out.println("ProductPage: sorted by " + field); }}
Notes:
implements is the keyword for interfaces, paralleling extends for classes.
A class can implement multiple interfaces, comma-separated. This is Java's escape hatch from the single-inheritance rule.
Interface methods are public abstract by default — you don't write the keywords. Adding them is legal but redundant.
@Override is conventional on methods that satisfy an interface, just like for class overrides.
Calling code:
public class ShopDemo { public static void main(String[] args) { ProductPage page = new ProductPage(); page.search("java book"); page.sortBy("price"); System.out.println("Results: " + page.getResultCount()); }}
Output:
ProductPage: searching for 'java book'
ProductPage: sorted by price
Results: 12
Programming to the interface — the WebDriver pattern
The real power shows when you store an instance via the interface type. The compiler only knows the interface's method signatures; the JVM dispatches to whichever concrete implementation is actually behind the reference:
public class SearchHelpers { // Accepts ANY Searchable. Doesn't know or care which implementation. public static void runSearch(Searchable target, String query) { target.search(query); int n = target.getResultCount(); System.out.println("→ got " + n + " results for '" + query + "'"); } public static void main(String[] args) { Searchable products = new ProductPage(); Searchable users = new UserDirectoryPage(); // also implements Searchable runSearch(products, "java book"); runSearch(users, "alice"); }}
SearchHelpers.runSearch(...) doesn't import or reference ProductPage or UserDirectoryPage — only Searchable. Every class that implements Searchable is a valid argument. Adding a new searchable page is a matter of writing a new class with implements Searchable; no helper needs to change. That decoupling is what "program to an interface, not an implementation" means in practice.
This is exactly the WebDriver story:
WebDriver driver; // interface typedriver = new ChromeDriver(); // any implementationdriver.get("https://staging.myapp.com"); // method comes from the WebDriver interface
Switch to new FirefoxDriver() and your test code is unchanged because every line of test logic only knows the WebDriver interface. That swap-without-rewriting capability is why every Selenium tutorial declares WebDriver driver; and not ChromeDriver driver;.
A class can implement many interfaces
Single class inheritance + multiple interface implementation is Java's compromise. The compiler is happy with:
public class ProductPage extends BasePage implements Searchable, Sortable, Loggable { ... }
ProductPage is aBasePage (single inheritance) and is SearchableandSortableandLoggable. Each interface contributes a contract; the parent class contributes shared implementation. This is the everyday shape of complex test classes — one parent (or none), several interfaces.
Default methods (Java 8+)
An interface with only abstract methods has a problem: adding a new method to a popular interface breaks every class that already implements it. To allow safe evolution, Java 8 introduced default methods — interface methods with a body that implementing classes inherit unless they override:
public interface Loggable { String getName(); // abstract — every implementer must supply default void log(String message) { System.out.println("[" + getName() + "] " + message); }}public class TestRun implements Loggable { private final String name; public TestRun(String name) { this.name = name; } @Override public String getName() { return name; } // log(...) inherited for free from the interface}public class LogDemo { public static void main(String[] args) { TestRun run = new TestRun("smoke-suite"); run.log("started"); run.log("3 of 4 passed"); }}
Output:
[smoke-suite] started
[smoke-suite] 3 of 4 passed
log(...) lives on the interface but has a body. TestRun doesn't have to implement it; it just inherits the default. The convention: use default for backward-compatible additions to an interface, not as a way to share large amounts of implementation. If you're tempted to put real logic on an interface, it's usually a sign that an abstract class would be a better fit (lesson 4 covers the trade-off).
Interfaces can also have static methods (utility helpers attached to the interface) and private methods (Java 9+, used to factor common helpers out of default methods). You'll meet these less often.
Interfaces vs abstract classes — the headline differences
(Lesson 4 covers the full rules; here's the cheat-sheet:)
A class can extendsone abstract class but implementsmany interfaces.
Abstract classes can have fields (instance variables); interfaces can only have constants (public static final).
Abstract classes can have constructors; interfaces cannot.
Interface methods are public by default; abstract classes can use any access modifier.
Use an abstract class for "is-a + shared implementation"; use interfaces for "can-do" capabilities.
Searchable + Sortable + ProductPage, visualised
ProductPage (class)
– search(String)
– getResultCount()
– sortBy(String)
getName() — abstract –
log(msg) — default body inherited –
ProductPage plays three roles at once. Each interface contributes a small, focused contract; together they describe what ProductPagecan do. A UserDirectoryPage could implement just Searchable + Loggable and skip Sortable. Tests that take a Searchable parameter accept both. That's composition by contract.
⚠️ Common mistakes
Using extends for an interface. It compiles in some weird cases (interfaces doextends other interfaces), but for a class adopting an interface the keyword is implements. The error message is concrete: interface expected here. Use implements.
Trying to add fields to an interface. Anything you write in an interface that looks like a field is silently public static final — a constant. There are no instance fields on an interface. If you need state, the type wants to be an abstract class.
Treating default methods as "abstract class lite." Default methods exist for backward compatibility, not to share large implementations. If your interface has more default code than abstract method signatures, the design wants to be an abstract class (or a class composed of helpers).
🎯 Practice task
Build interface-driven test capabilities. 25-30 minutes.
Create Searchable.java. Declare public interface Searchable with two methods: void search(String query); and int getResultCount();.
Create Sortable.java. Declare public interface Sortable with void sortBy(String field);.
Create Loggable.java. Declare public interface Loggable with abstract String getName(); and a default void log(String msg) { System.out.println("[" + getName() + "] " + msg); }.
Create ProductPage.java declaring public class ProductPage implements Searchable, Sortable, Loggable. Implement all three interfaces' abstract methods and inherit log(...) from the default. Keep state in private fields (lastQuery, lastResultCount, sortField).
Create UserDirectoryPage.java that implements Searchable, Loggable only. Implement the abstract methods.
Call it once with a ProductPage and once with a UserDirectoryPage. Confirm both work through the same parameter type.
Use Loggable polymorphism: declare Loggable[] components = { new ProductPage(), new UserDirectoryPage() }; and loop calling c.log("hello") on each. Confirm the right names appear in brackets.
Stretch: add a static utility on the interface — public interface Searchable { ... static int safeCount(Searchable s) { return s == null ? 0 : s.getResultCount(); } }. Call Searchable.safeCount(null) and confirm it returns 0 without crashing. Static helpers on interfaces are an under-used Java 8 feature that's perfect for null-safe wrappers.
You now have the two halves of polymorphism: abstract classes for shared lifecycles, interfaces for cross-cutting contracts. Lesson 3 ties them together and shows what polymorphism actually means at runtime.
// tip to track lessons you complete and pick up where you left off across devices.