JUnit 5 lets you use interfaces as test classes, complete with default methods. This is a powerful way to share setup logic, assertions, and even test methods across multiple test classes without using inheritance.
Basic Test Interface
public interface CleanDatabase {
@BeforeEach
default void cleanDatabase() {
System.out.println("Cleaning database before test");
DatabaseManager.getInstance().truncateAllTables();
}
@AfterEach
default void cleanupAfterTest() {
System.out.println("Cleaning up after test");
}
}
// Usage
class UserRepositoryTest implements CleanDatabase {
@Test
void shouldFindUser() {
// cleanDatabase() runs automatically before this test
}
@Test
void shouldSaveUser() {
// cleanDatabase() runs here too
}
}
Shared Test Methods
public interface EqualityContract<T> {
T createInstance();
@Test
default void sameInstanceEqualsItself() {
T instance = createInstance();
assertEquals(instance, instance);
}
@Test
default void differentInstancesNotEqual() {
T a = createInstance();
T b = createInstance();
assertNotEquals(a, b);
}
@Test
default void nullNotEqual() {
T instance = createInstance();
assertNotEquals(null, instance);
}
@Test
default void equalsIsSymmetric() {
T a = createInstance();
T b = createInstance();
assertEquals(a.equals(b), b.equals(a));
}
}
// Usage
class UserTest implements EqualityContract<User> {
@Override
public User createInstance() {
return new User("John", "john@example.com");
}
@Test
void shouldHaveValidEmail() {
User user = createInstance();
assertTrue(user.getEmail().contains("@"));
}
}
Multiple Interface Composition
public interface Loggable {
@BeforeEach
default void logTestStart(ExtensionContext context) {
System.out.println("Starting: " + context.getDisplayName());
}
@AfterEach
default void logTestEnd(ExtensionContext context) {
System.out.println("Finished: " + context.getDisplayName());
}
}
public interface Timed {
// Timing logic (from previous lesson)
}
public interface RandomSeed {
@TestFactory
default DynamicTest generateRandomTests() {
// Generate dynamic tests with random data
}
}
// Compose multiple interfaces
class FlexibleTest implements Loggable, Timed {
@Test
void myTest() {
// Gets logging and timing automatically
}
}
Generic Test Interfaces
public interface RepositoryTest<T, ID> {
Repository<T, ID> getRepository();
T createTestEntity();
ID getEntityId(T entity);
@Test
default void shouldSaveAndFindById() {
T entity = createTestEntity();
getRepository().save(entity);
T found = getRepository().findById(getEntityId(entity));
assertNotNull(found);
}
@Test
default void shouldReturnNullForNonexistent() {
T found = getRepository().findById(getNonexistentId());
assertNull(found);
}
ID getNonexistentId();
}
// Usage
class UserRepositoryTest
implements RepositoryTest<User, Long> {
private UserRepository repo = new UserRepository();
@Override
public Repository<User, Long> getRepository() {
return repo;
}
@Override
public User createTestEntity() {
return new User("Test", "test@example.com");
}
@Override
public Long getEntityId(User user) {
return user.getId();
}
@Override
public Long getNonexistentId() {
return -1L;
}
@Test
void customTest() {
// Additional test specific to UserRepository
}
}
Default Methods vs Abstract Classes
// Interfaces with default methods:
// - Can implement multiple
// - Cannot have instance fields (only static constants)
// - More flexible composition
// Abstract classes:
// - Can only extend one
// - Can have instance fields
// - Can have constructors
// Choose interfaces when:
// - You want to share behavior across unrelated classes
// - You want to compose multiple behaviors
// Choose abstract classes when:
// - You need shared state (instance fields)
// - You need constructors
// - There is a clear "is-a" relationship
Best Practices
- Keep default methods simple and focused
- Use descriptive interface names (e.g.,
CleanDatabase,Loggable) - Avoid deep interface hierarchies
- Document the contract each interface expects implementors to fulfill
- Consider using
@ExtendWithfor more complex behaviors