Skip to main content
Java advanced Lesson 48 of 58

Advanced Testing in Java

Master advanced Java testing — JUnit 5 extensions, Mockito deep dive, TestContainers for integration tests, and test architecture patterns.

JUnit 5 — Advanced Features

Parameterized Tests

import org.junit.jupiter.api.*;
import org.junit.jupiter.params.*;
import org.junit.jupiter.params.provider.*;
import java.util.stream.Stream;

class ParameterizedDemo {

    // @ValueSource — simple single-argument cases
    @ParameterizedTest
    @ValueSource(strings = {"[email protected]", "[email protected]", "[email protected]"})
    void validEmailsPass(String email) {
        assertTrue(EmailValidator.isValid(email));
    }

    @ParameterizedTest
    @ValueSource(strings = {"", "notanemail", "@no-local", "no-at-sign"})
    void invalidEmailsReject(String email) {
        assertFalse(EmailValidator.isValid(email));
    }

    // @CsvSource — multiple arguments per test case
    @ParameterizedTest
    @CsvSource({
        "1,  1,  2",
        "5,  3,  8",
        "0, -1, -1",
        "-5, 5,  0"
    })
    void additionWorks(int a, int b, int expected) {
        assertEquals(expected, Calculator.add(a, b));
    }

    // @MethodSource — complex objects from a factory method
    static Stream<Arguments> passwordStrengthCases() {
        return Stream.of(
            Arguments.of("password",    false, "too common"),
            Arguments.of("P@ssw0rd!",   true,  "strong"),
            Arguments.of("Short1!",     false, "too short"),
            Arguments.of("nouppercase1!", false, "no uppercase")
        );
    }

    @ParameterizedTest(name = "{2}")
    @MethodSource("passwordStrengthCases")
    void passwordStrength(String password, boolean expectedStrong, String reason) {
        assertEquals(expectedStrong, PasswordValidator.isStrong(password), reason);
    }

    // @EnumSource — iterate over enum values
    @ParameterizedTest
    @EnumSource(DayOfWeek.class)
    void everyDayHasAName(DayOfWeek day) {
        assertNotNull(day.getDisplayName(TextStyle.FULL, Locale.ENGLISH));
    }
}

Custom Extensions

import org.junit.jupiter.api.extension.*;
import java.lang.annotation.*;

// 1. Define annotation
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@ExtendWith(RetryExtension.class)
public @interface Retry {
    int times() default 3;
}

// 2. Implement extension
public class RetryExtension implements TestExecutionExceptionHandler {

    @Override
    public void handleTestExecutionException(ExtensionContext ctx, Throwable ex) throws Throwable {
        Retry retry = ctx.getRequiredTestMethod().getAnnotation(Retry.class);
        if (retry == null) throw ex;

        int maxAttempts = retry.times();
        for (int attempt = 2; attempt <= maxAttempts; attempt++) {
            try {
                ctx.getRequiredTestMethod().invoke(ctx.getRequiredTestInstance());
                return; // success
            } catch (Exception e) {
                if (attempt == maxAttempts) throw ex; // throw original exception
            }
        }
    }
}

// 3. Use it
class FlakyTest {
    @Test
    @Retry(times = 3)
    void sometimesFlaky() {
        // This test will be retried up to 3 times before failing
        assertThat(unreliableNetwork.ping()).isEqualTo("pong");
    }
}

Test Lifecycle Hooks

@TestInstance(TestInstance.Lifecycle.PER_CLASS) // share state across tests in the class
class DatabaseIntegrationTest {

    private static Connection connection;

    @BeforeAll  // runs once before all tests (no longer needs to be static with PER_CLASS)
    void setUpDatabase() throws Exception {
        connection = DriverManager.getConnection("jdbc:h2:mem:testdb", "sa", "");
        runMigrations(connection);
    }

    @AfterAll
    void tearDownDatabase() throws Exception {
        connection.close();
    }

    @BeforeEach
    void startTransaction() throws Exception {
        connection.setAutoCommit(false); // each test runs in a transaction
    }

    @AfterEach
    void rollback() throws Exception {
        connection.rollback(); // undo changes — test isolation
    }

    @Test
    void savesUser() throws Exception {
        // This change is rolled back after the test
        userRepo.save(new User("alice", "[email protected]"), connection);
        assertThat(userRepo.findAll(connection)).hasSize(1);
    }
}

Mockito — Deep Dive

Mocking and Stubbing

import org.junit.jupiter.api.*;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.*;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.mockito.Mockito.*;
import static org.assertj.core.api.Assertions.*;

@ExtendWith(MockitoExtension.class)
class UserServiceTest {

    @Mock
    UserRepository userRepo;

    @Mock
    EmailService emailService;

    @InjectMocks  // creates UserService and injects the mocks
    UserService userService;

    @Test
    void createsUserAndSendsWelcomeEmail() {
        // Arrange
        var request = new CreateUserRequest("alice", "[email protected]");
        var savedUser = new User(1L, "alice", "[email protected]");

        when(userRepo.existsByEmail("[email protected]")).thenReturn(false);
        when(userRepo.save(any(User.class))).thenReturn(savedUser);

        // Act
        User result = userService.createUser(request);

        // Assert
        assertThat(result.id()).isEqualTo(1L);
        assertThat(result.email()).isEqualTo("[email protected]");

        // Verify interactions
        verify(userRepo).save(any(User.class));
        verify(emailService).sendWelcome(savedUser);
        verifyNoMoreInteractions(emailService);
    }

    @Test
    void rejectsEmailAlreadyInUse() {
        when(userRepo.existsByEmail("[email protected]")).thenReturn(true);

        assertThatThrownBy(() ->
            userService.createUser(new CreateUserRequest("alice", "[email protected]"))
        ).isInstanceOf(EmailAlreadyUsedException.class)
         .hasMessageContaining("[email protected]");

        verify(userRepo, never()).save(any());
        verifyNoInteractions(emailService);
    }
}

Argument Captors

@Test
void capturesArgumentSentToRepository() {
    var request = new CreateUserRequest("alice", "[email protected]");
    when(userRepo.existsByEmail(anyString())).thenReturn(false);
    when(userRepo.save(any())).thenAnswer(inv -> inv.getArgument(0)); // return what was passed

    userService.createUser(request);

    // Capture the exact argument passed to save()
    ArgumentCaptor<User> captor = ArgumentCaptor.forClass(User.class);
    verify(userRepo).save(captor.capture());

    User saved = captor.getValue();
    assertThat(saved.username()).isEqualTo("alice");
    assertThat(saved.email()).isEqualTo("[email protected]");
    assertThat(saved.createdAt()).isNotNull();
}

Spy — Partial Mocks

@Test
void spyCallsRealMethodsUnlessStubbedˈ() {
    List<String> realList = new ArrayList<>();
    List<String> spy = spy(realList);

    // Real method is called
    spy.add("one");
    spy.add("two");
    assertThat(spy).hasSize(2);

    // Stub only size()
    doReturn(100).when(spy).size();
    assertThat(spy.size()).isEqualTo(100); // stubbed
    assertThat(spy.get(0)).isEqualTo("one"); // real
}

Answer — Dynamic Stubbing

@Test
void dynamicStubbing() {
    when(userRepo.findById(anyLong())).thenAnswer(invocation -> {
        long id = invocation.getArgument(0);
        if (id <= 0) return Optional.empty();
        return Optional.of(new User(id, "User " + id, "user" + id + "@example.com"));
    });

    assertThat(userRepo.findById(5L)).isPresent();
    assertThat(userRepo.findById(5L).get().name()).isEqualTo("User 5");
    assertThat(userRepo.findById(-1L)).isEmpty();
}

TestContainers

TestContainers spins up real Docker containers for integration tests:

Setup

<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>testcontainers</artifactId>
    <version>1.19.3</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>postgresql</artifactId>
    <version>1.19.3</version>
    <scope>test</scope>
</dependency>

PostgreSQL Integration Test

import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.*;

@Testcontainers
class UserRepositoryIntegrationTest {

    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
        .withDatabaseName("testdb")
        .withUsername("test")
        .withPassword("test");

    private UserRepository repo;
    private Connection connection;

    @BeforeEach
    void setUp() throws Exception {
        connection = DriverManager.getConnection(
            postgres.getJdbcUrl(),
            postgres.getUsername(),
            postgres.getPassword()
        );
        runMigrations(connection);
        repo = new UserRepository(connection);
    }

    @AfterEach
    void tearDown() throws Exception {
        connection.close();
    }

    @Test
    void persistsAndRetrievesUser() {
        User saved = repo.save(new User(0, "alice", "[email protected]"));

        assertThat(saved.id()).isGreaterThan(0);
        Optional<User> found = repo.findById(saved.id());
        assertThat(found).isPresent();
        assertThat(found.get().email()).isEqualTo("[email protected]");
    }

    @Test
    void uniqueEmailConstraintEnforced() {
        repo.save(new User(0, "alice", "[email protected]"));

        assertThatThrownBy(() ->
            repo.save(new User(0, "alice2", "[email protected]"))
        ).isInstanceOf(DataIntegrityException.class);
    }
}

Spring Boot + TestContainers

@SpringBootTest
@Testcontainers
class OrderApiIntegrationTest {

    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");

    @DynamicPropertySource
    static void configureDataSource(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url",      postgres::getJdbcUrl);
        registry.add("spring.datasource.username", postgres::getUsername);
        registry.add("spring.datasource.password", postgres::getPassword);
    }

    @Autowired MockMvc mockMvc;

    @Test
    void createOrderReturns201() throws Exception {
        String requestBody = """
                {
                    "customerId": 1,
                    "items": [{"productId": 42, "quantity": 2}]
                }
                """;

        mockMvc.perform(post("/api/orders")
                .contentType(MediaType.APPLICATION_JSON)
                .content(requestBody))
            .andExpect(status().isCreated())
            .andExpect(jsonPath("$.id").isNumber())
            .andExpect(jsonPath("$.status").value("PENDING"));
    }
}

Redis Container

@Container
static GenericContainer<?> redis = new GenericContainer<>("redis:7-alpine")
    .withExposedPorts(6379);

@DynamicPropertySource
static void configureRedis(DynamicPropertyRegistry registry) {
    registry.add("spring.data.redis.host", redis::getHost);
    registry.add("spring.data.redis.port", () -> redis.getMappedPort(6379));
}

Architecture — Test Pyramid

         /\
        /  \        E2E / UI Tests
       /    \       (Selenium, Playwright)
      /------\      Few, slow, brittle
     /        \
    /          \    Integration Tests
   /            \   (TestContainers, @SpringBootTest)
  /              \  Moderate count, moderate speed
 /----------------\
/                  \ Unit Tests
/                    \ (JUnit 5 + Mockito)
/____________________\ Many, fast, isolated

Unit tests — test a single class in isolation, mock all dependencies. Runs in milliseconds. Aim for 70-80% of your test count.

Integration tests — test multiple layers together (controller → service → repository → real database). Use TestContainers. Slower but catch wiring bugs.

E2E tests — test the full running application from the outside. Use sparingly — they are slow and brittle.

Test Quality Checklist

// Good test structure: Arrange / Act / Assert
@Test
void deactivatesUserWhenAccountExpires() {
    // Arrange — set up the scenario
    var user = new User(1L, "alice", active: true, expiresAt: LocalDate.now().minusDays(1));
    when(userRepo.findById(1L)).thenReturn(Optional.of(user));

    // Act — exercise the code under test
    userService.checkAndDeactivateExpiredAccounts();

    // Assert — verify the outcome
    ArgumentCaptor<User> captor = ArgumentCaptor.forClass(User.class);
    verify(userRepo).save(captor.capture());
    assertThat(captor.getValue().isActive()).isFalse();
}

// Test names: describe the scenario and expected outcome
// Pattern: methodName_scenario_expectedBehaviour
@Test void findById_existingUser_returnsUser() { ... }
@Test void findById_unknownId_returnsEmpty() { ... }
@Test void save_duplicateEmail_throwsEmailAlreadyUsedException() { ... }

Frequently Asked Questions

What is the difference between a mock, a stub, and a spy?
A stub returns canned responses for method calls — it replaces a dependency with a simple fake. A mock is a stub that also verifies interactions — you assert that specific methods were called with specific arguments. A spy wraps a real object and records calls while still executing real methods unless explicitly stubbed. In Mockito: Mockito.mock() creates a mock, Mockito.spy() creates a spy.
When should I use TestContainers instead of an in-memory database?
Use TestContainers when you need to test against the actual database engine — dialect differences, JSON column types, full-text search, stored procedures, or database-specific behaviour. In-memory databases (H2) are faster but may behave differently from your production database. TestContainers spins up a real Docker container, so tests are slower but more accurate.
What is the @ExtendWith annotation in JUnit 5?
@ExtendWith registers a JUnit 5 extension. Common uses: @ExtendWith(MockitoExtension.class) integrates Mockito (enables @Mock, @InjectMocks), @ExtendWith(SpringExtension.class) loads the Spring context (usually via @SpringBootTest), and @ExtendWith(TempDirExtension.class) provides a temporary directory.
What is test isolation and why does it matter?
Test isolation means each test is independent — it does not depend on the order tests run, does not share mutable state with other tests, and cleans up after itself. Without isolation, tests pass in isolation but fail together (order-dependent tests), which makes CI flaky and debugging hard. Use @BeforeEach/@AfterEach for setup/teardown, and @Transactional to roll back DB changes.