Labs ICT
โญ Pro Login

Test Interfaces and Default Methods

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 @ExtendWith for more complex behaviors

๐Ÿงช Quick Quiz

What annotation allows tests to implement interfaces for shared behavior?