One Suite, Many Implementations
Let’s create a single suite of tests for the List contract, and then run that same suite against multiple implementations.
package list;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.fail;
public abstract class ListTest {
private List<Integer> list;
protected abstract List<Integer> createList();
@BeforeEach
public void setup() {
list = createList();
}
@Test
public void newListIsEmpty() {
assertEquals(0, list.size());
}
@Test
public void addThenGetReturnsElement() {
list.add(42);
assertEquals(42, list.get(0));
}
@Test
public void getBelowRangeThrows() {
try {
list.get(-1);
fail("expected IndexOutOfBoundsException for index -1");
} catch (IndexOutOfBoundsException e) {
return;
}
}
}
Notice three things about this class:
ListTestis an abstract class, so it cannot be instantiated. JUnit will not try to run it on its own, because it may contain abstract methods that have no implementation yet.createList()is an abstract method. It has no implementation here. Some subclass has to implement it and return aList. A method that an abstract class declares for creating an object, and that lets subclasses decide which concrete class to instantiate, is called a factory method.setup()callscreateList()instead of instantiating a concrete implementation ofList.
The ListTest is written entirely against the List interface. It never names the implementation it is testing. That is program to the interface from the last chapter, applied to the tests themselves.
The concrete test classes
For each implementation of List, we create a small subclass of ListTest that overrides createList() to return an instance of that implementation:
public class ArrayListTest extends ListTest {
@Override
protected List<Integer> createList() {
return new ArrayList<>();
}
}
public class LinkedListTest extends ListTest {
@Override
protected List<Integer> createList() {
return new LinkedList<>();
}
}
Notice that neither subclass has any @Test methods of its own. They inherit those from ListTest, and they override only the createList() method.
You will place these three files in the same directory:
src/
test/
list/
ListTest.java # abstract: the contract suite
ArrayListTest.java # extends ListTest, builds an ArrayList
LinkedListTest.java # extends ListTest, builds a LinkedList
What happens when you run the tests?
JUnit discovers the two concrete classes, ArrayListTest and LinkedListTest. For ArrayListTest, it runs every inherited @Test with list built by new ArrayList<>(). For LinkedListTest, it runs the same inherited tests with list built by new LinkedList<>().
If we add one new contract test to ListTest, it runs against every implementation. If we write a third implementation of List someday, testing it takes one small subclass, and that subclass inherits the entire contract suite. This is what “test the interface, not the implementation” means. The contract is written down as tests in one place, and every implementation is checked against those same tests.