A robust test suite is what separates production-grade code from prototype code. Java's testing ecosystem is mature: JUnit 5 handles test structure and lifecycle, Mockito isolates dependencies with mocks, Spring Boot Test provides application context slices for integration testing, and Testcontainers spins up real Docker containers so your database tests run against actual Postgres or MySQL rather than an H2 approximation.


Step 1 — JUnit 5 Fundamentals

JUnit 5 (Jupiter) is the standard test framework for Java. It consists of three modules: JUnit Platform (launcher), JUnit Jupiter (API for writing tests), and JUnit Vintage (runs JUnit 4 tests). Spring Boot Test auto-includes JUnit 5 via spring-boot-starter-test.

pom.xml (dependencies)xml
<dependencies>
    <!-- spring-boot-starter-test includes JUnit 5, Mockito, AssertJ, Hamcrest -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>

    <!-- Testcontainers for real database integration tests -->
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>junit-jupiter</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>postgresql</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>
JUnit5Basics.javajava
import org.junit.jupiter.api.*;
import org.junit.jupiter.api.condition.*;
import java.time.Duration;
import static org.junit.jupiter.api.Assertions.*;
import static org.junit.jupiter.api.Assumptions.*;

class JUnit5Basics {

    // ============================================================
    // Lifecycle annotations
    // ============================================================
    @BeforeAll  // runs ONCE before all tests in this class. Must be static.
    static void initAll() {
        System.out.println("BeforeAll: one-time setup (e.g., start a test server)");
    }

    @BeforeEach // runs before EACH test method
    void init() {
        System.out.println("BeforeEach: reset state before each test");
    }

    @AfterEach  // runs after EACH test method
    void tearDown() {
        System.out.println("AfterEach: cleanup after each test");
    }

    @AfterAll   // runs ONCE after all tests. Must be static.
    static void tearDownAll() {
        System.out.println("AfterAll: release global resources");
    }

    // ============================================================
    // Basic assertions
    // ============================================================
    @Test
    @DisplayName("Basic assertions — clear names in test reports")
    void basicAssertions() {
        assertEquals(4, 2 + 2, "Math should work");
        assertNotEquals(5, 2 + 2);
        assertTrue("hello".startsWith("h"));
        assertFalse("".isBlank() == false); // wait — isBlank() on empty IS true
        assertNull(null);
        assertNotNull("value");

        // assertAll: runs ALL assertions, reports all failures at once
        // Without assertAll, the first failure stops the test
        assertAll("person",
            () -> assertEquals("Alice", "Alice"),
            () -> assertEquals(30, 30),
            () -> assertTrue(true)
        );
    }

    // ============================================================
    // Exception testing
    // ============================================================
    @Test
    @DisplayName("Expect specific exception")
    void exceptionTesting() {
        // assertThrows: verifies an exception IS thrown, returns the exception
        IllegalArgumentException ex = assertThrows(
            IllegalArgumentException.class,
            () -> Integer.parseInt("not-a-number"),
            "Should throw for non-numeric input"
        );
        assertTrue(ex.getMessage().contains("not-a-number"));

        // assertDoesNotThrow: verifies no exception is thrown
        assertDoesNotThrow(() -> Integer.parseInt("123"));
    }

    // ============================================================
    // Timeout assertions
    // ============================================================
    @Test
    @DisplayName("Performance assertion")
    void timeoutTest() {
        // assertTimeout: waits for completion, then checks (test still completes)
        String result = assertTimeout(Duration.ofSeconds(1),
            () -> { Thread.sleep(50); return "done"; });
        assertEquals("done", result);

        // assertTimeoutPreemptively: aborts if too slow (runs in separate thread)
        assertTimeoutPreemptively(Duration.ofSeconds(1),
            () -> Thread.sleep(50));
    }

    // ============================================================
    // Assumptions: skip test if precondition not met
    // ============================================================
    @Test
    @DisplayName("Skips on non-Linux environments")
    void onlyOnLinux() {
        assumeTrue(System.getProperty("os.name").toLowerCase().contains("linux"),
            "Skipped: not running on Linux");
        // test only runs on Linux; on Windows/Mac it is "aborted" (not failed)
        assertTrue(true);
    }

    // ============================================================
    // Conditional test execution
    // ============================================================
    @Test
    @EnabledOnOs(OS.LINUX)      // only run on Linux
    void linuxOnlyTest() { }

    @Test
    @EnabledForJreRange(min = JRE.JAVA_11)
    void java11AndAbove() { }

    @Test
    @EnabledIfEnvironmentVariable(named = "CI", matches = "true")
    void ciOnly() { } // only runs when CI=true env var is set

    @Test
    @Disabled("Known bug JIRA-1234 — fix in sprint 42")
    void disabledTest() { // will not run, shows as skipped in report
        fail("This test is disabled");
    }

    // ============================================================
    // Nested tests: organize related tests
    // ============================================================
    @Nested
    @DisplayName("When input is valid")
    class ValidInput {
        @Test
        void parsesSuccessfully() {
            assertEquals(42, Integer.parseInt("42"));
        }
    }

    @Nested
    @DisplayName("When input is invalid")
    class InvalidInput {
        @Test
        void throwsException() {
            assertThrows(NumberFormatException.class, () -> Integer.parseInt(""));
        }
    }
}

Step 2 — Parameterized Tests

Parameterized tests run the same test method with different inputs. Instead of copy-pasting near-identical test methods, you declare the inputs as a source and JUnit generates one test case per input. This dramatically reduces test code duplication for boundary conditions.

ParameterizedTests.javajava
import org.junit.jupiter.params.*;
import org.junit.jupiter.params.provider.*;
import java.util.stream.Stream;
import static org.junit.jupiter.api.Assertions.*;

class ParameterizedTests {

    // ============================================================
    // @ValueSource: single parameter of a primitive type
    // ============================================================
    @ParameterizedTest
    @ValueSource(strings = {"hello", "world", "JUnit"})
    void stringsAreNotBlank(String word) {
        assertFalse(word.isBlank());
    }

    @ParameterizedTest
    @ValueSource(ints = {1, 3, 5, 7, 9})
    void oddNumbers(int number) {
        assertEquals(1, number % 2, number + " should be odd");
    }

    // ============================================================
    // @NullAndEmptySource: test null and empty edge cases
    // ============================================================
    @ParameterizedTest
    @NullAndEmptySource   // runs with null AND empty string
    @ValueSource(strings = {" ", "\t", "\n"}) // also whitespace
    void blankInputs(String input) {
        assertTrue(input == null || input.isBlank());
    }

    // ============================================================
    // @EnumSource: test against enum values
    // ============================================================
    enum Day { MON, TUE, WED, THU, FRI, SAT, SUN }

    @ParameterizedTest
    @EnumSource(value = Day.class, names = {"SAT", "SUN"}) // only these two
    void weekend(Day day) {
        assertTrue(day == Day.SAT || day == Day.SUN);
    }

    @ParameterizedTest
    @EnumSource(value = Day.class, names = {"SAT", "SUN"}, mode = EnumSource.Mode.EXCLUDE)
    void weekday(Day day) {
        assertFalse(day == Day.SAT || day == Day.SUN);
    }

    // ============================================================
    // @CsvSource: multiple parameters per test case
    // ============================================================
    @ParameterizedTest(name = "{0} + {1} = {2}")
    @CsvSource({
        "1, 2, 3",
        "10, 20, 30",
        "-5, 5, 0",
        "'hello', ' world', 'hello world'" // strings with spaces need quotes
    })
    void csvAddition(int a, int b, int result) {
        assertEquals(result, a + b);
    }

    // ============================================================
    // @CsvFileSource: read test cases from a CSV file in resources
    // ============================================================
    // @ParameterizedTest
    // @CsvFileSource(resources = "/test-data.csv", numLinesToSkip = 1) // skip header
    // void fromCsvFile(String input, int expected) { ... }

    // ============================================================
    // @MethodSource: complex objects from a factory method
    // ============================================================
    static Stream<Arguments> additionProvider() {
        return Stream.of(
            Arguments.of(1, 1, 2),
            Arguments.of(2, 3, 5),
            Arguments.of(Integer.MAX_VALUE, 1, Integer.MIN_VALUE) // overflow!
        );
    }

    @ParameterizedTest(name = "{0} + {1} should equal {2}")
    @MethodSource("additionProvider")
    void testAddition(int a, int b, int expected) {
        assertEquals(expected, a + b);
    }

    // ============================================================
    // Custom ParameterConverter + display name customization
    // ============================================================
    @ParameterizedTest
    @ValueSource(strings = {"2023-01-15", "2024-06-22", "2025-12-31"})
    void parseDates(String dateStr) {
        java.time.LocalDate date = java.time.LocalDate.parse(dateStr);
        assertNotNull(date);
        assertTrue(date.getYear() >= 2023);
    }
}

Step 3 — Mockito: Mocking Dependencies

Mockito creates test doubles (mocks) of classes and interfaces. A mock records method calls and returns values you configure. This lets you test a class in isolation by replacing real dependencies (database repos, HTTP clients, email services) with controllable fakes.

MockitoExamples.javajava
import org.junit.jupiter.api.*;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.*;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.BDDMockito.*;

// ==========================================================
// Domain classes for the demo
// ==========================================================
interface UserRepository {
    Optional<User> findById(Long id);
    User save(User user);
    boolean existsByEmail(String email);
}

record User(Long id, String name, String email) {}

interface EmailService {
    void sendWelcomeEmail(String email, String name);
}

class UserService {
    private final UserRepository userRepo;
    private final EmailService emailService;

    UserService(UserRepository userRepo, EmailService emailService) {
        this.userRepo = userRepo;
        this.emailService = emailService;
    }

    User createUser(String name, String email) {
        if (userRepo.existsByEmail(email)) {
            throw new IllegalStateException("Email already registered: " + email);
        }
        User user = new User(null, name, email);
        User saved = userRepo.save(user);
        emailService.sendWelcomeEmail(email, name); // side effect
        return saved;
    }

    User getUser(Long id) {
        return userRepo.findById(id)
            .orElseThrow(() -> new NoSuchElementException("User not found: " + id));
    }
}

// ==========================================================
// Test class
// ==========================================================
@ExtendWith(MockitoExtension.class) // enables @Mock, @Captor, @InjectMocks
class UserServiceTest {

    @Mock
    UserRepository userRepo; // Mockito creates a mock implementation

    @Mock
    EmailService emailService;

    @InjectMocks
    UserService userService; // Mockito injects @Mock fields into this class

    @Captor
    ArgumentCaptor<User> userCaptor; // captures arguments passed to mocks

    // ============================================================
    // Stubbing: configure what mocks return
    // ============================================================
    @Test
    void createUser_success() {
        // GIVEN (BDD style: given-when-then)
        given(userRepo.existsByEmail("alice@example.com")).willReturn(false);
        given(userRepo.save(any(User.class)))
            .willReturn(new User(1L, "Alice", "alice@example.com"));

        // WHEN
        User result = userService.createUser("Alice", "alice@example.com");

        // THEN
        assertNotNull(result);
        assertEquals(1L, result.id());
        assertEquals("Alice", result.name());

        // Verify interactions
        then(emailService).should(times(1)).sendWelcomeEmail("alice@example.com", "Alice");
        then(userRepo).should(times(1)).save(any(User.class));
    }

    @Test
    void createUser_emailAlreadyExists_throwsException() {
        given(userRepo.existsByEmail("dup@example.com")).willReturn(true);

        assertThrows(IllegalStateException.class,
            () -> userService.createUser("Bob", "dup@example.com"));

        // Verify save was NEVER called
        then(userRepo).should(never()).save(any());
        // Verify email was NEVER sent
        then(emailService).should(never()).sendWelcomeEmail(anyString(), anyString());
    }

    @Test
    void getUser_notFound_throwsException() {
        given(userRepo.findById(99L)).willReturn(Optional.empty());

        assertThrows(NoSuchElementException.class, () -> userService.getUser(99L));
    }

    // ============================================================
    // ArgumentCaptor: capture and inspect arguments
    // ============================================================
    @Test
    void createUser_capturesSavedUser() {
        given(userRepo.existsByEmail(anyString())).willReturn(false);
        given(userRepo.save(userCaptor.capture()))
            .willReturn(new User(1L, "Charlie", "charlie@example.com"));

        userService.createUser("Charlie", "charlie@example.com");

        User capturedUser = userCaptor.getValue();
        assertEquals("Charlie", capturedUser.name());
        assertEquals("charlie@example.com", capturedUser.email());
        assertNull(capturedUser.id()); // should be null before save assigns it
    }

    // ============================================================
    // Stubbing exceptions
    // ============================================================
    @Test
    void createUser_dbFailure_propagatesException() {
        given(userRepo.existsByEmail(anyString())).willReturn(false);
        given(userRepo.save(any()))
            .willThrow(new RuntimeException("Database connection failed"));

        RuntimeException ex = assertThrows(RuntimeException.class,
            () -> userService.createUser("Dave", "dave@example.com"));
        assertEquals("Database connection failed", ex.getMessage());
    }

    // ============================================================
    // Spy: wrap a real object, selectively override methods
    // ============================================================
    @Test
    void spyExample() {
        List<String> realList = new ArrayList<>();
        List<String> spyList = spy(realList); // spy wraps the real list

        spyList.add("one");   // REAL add() is called
        spyList.add("two");

        assertEquals(2, spyList.size()); // real size()

        // Override only specific method
        doReturn(100).when(spyList).size(); // fake size() but keep real add()
        assertEquals(100, spyList.size()); // mocked

        verify(spyList, times(2)).add(anyString());
    }

    // ============================================================
    // Verify interaction order with InOrder
    // ============================================================
    @Test
    void verifyOrder() {
        given(userRepo.existsByEmail(anyString())).willReturn(false);
        given(userRepo.save(any())).willReturn(new User(1L, "Eve", "eve@example.com"));

        userService.createUser("Eve", "eve@example.com");

        InOrder inOrder = inOrder(userRepo, emailService);
        inOrder.verify(userRepo).existsByEmail("eve@example.com");
        inOrder.verify(userRepo).save(any());
        inOrder.verify(emailService).sendWelcomeEmail(anyString(), anyString());
        // save must happen BEFORE sendWelcomeEmail
    }
}

Step 4 — Spring Boot Test: Controller Layer

@WebMvcTest loads only the web layer (controllers, filters, security config) without starting a full application context. It's fast and precise. MockMvc lets you fire HTTP requests directly to your controllers without a real HTTP server, asserting on response status, headers, and JSON body.

UserControllerTest.javajava
import org.junit.jupiter.api.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.List;
import static org.mockito.BDDMockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import static org.hamcrest.Matchers.*;

// Only loads UserController + Spring MVC infrastructure (no JPA, no services)
@WebMvcTest(UserController.class)
class UserControllerTest {

    @Autowired
    MockMvc mockMvc; // pre-configured for WebMvcTest

    @Autowired
    ObjectMapper objectMapper; // Spring's configured JSON mapper

    @MockBean // Spring-managed mock — registered in ApplicationContext
    UserService userService;

    @Test
    @DisplayName("GET /api/users/{id} returns 200 with user JSON")
    void getUser_found() throws Exception {
        User user = new User(1L, "Alice", "alice@example.com");
        given(userService.getUser(1L)).willReturn(user);

        mockMvc.perform(get("/api/users/1")
                    .accept(MediaType.APPLICATION_JSON))
            .andExpect(status().isOk())
            .andExpect(content().contentType(MediaType.APPLICATION_JSON))
            .andExpect(jsonPath("$.id").value(1))
            .andExpect(jsonPath("$.name").value("Alice"))
            .andExpect(jsonPath("$.email").value("alice@example.com"));
    }

    @Test
    @DisplayName("GET /api/users/{id} returns 404 when not found")
    void getUser_notFound() throws Exception {
        given(userService.getUser(99L))
            .willThrow(new java.util.NoSuchElementException("User not found: 99"));

        mockMvc.perform(get("/api/users/99"))
            .andExpect(status().isNotFound());
    }

    @Test
    @DisplayName("POST /api/users creates user and returns 201")
    void createUser_valid() throws Exception {
        var request = new CreateUserRequest("Bob", "bob@example.com");
        User saved = new User(2L, "Bob", "bob@example.com");
        given(userService.createUser("Bob", "bob@example.com")).willReturn(saved);

        mockMvc.perform(post("/api/users")
                    .contentType(MediaType.APPLICATION_JSON)
                    .content(objectMapper.writeValueAsString(request)))
            .andExpect(status().isCreated())
            .andExpect(jsonPath("$.id").value(2))
            .andExpect(header().string("Location", containsString("/api/users/2")));
    }

    @Test
    @DisplayName("POST /api/users returns 400 for blank name")
    void createUser_invalidInput() throws Exception {
        // Blank name should fail Bean Validation before reaching service
        String invalidBody = "{\"name\":\"\",\"email\":\"valid@example.com\"}";

        mockMvc.perform(post("/api/users")
                    .contentType(MediaType.APPLICATION_JSON)
                    .content(invalidBody))
            .andExpect(status().isBadRequest())
            .andExpect(jsonPath("$.errors", hasItem(containsString("name"))));

        // Service should never be called for invalid input
        then(userService).should(never()).createUser(anyString(), anyString());
    }

    @Test
    @DisplayName("GET /api/users returns paginated list")
    void listUsers() throws Exception {
        given(userService.listUsers(any()))
            .willReturn(List.of(
                new User(1L, "Alice", "alice@example.com"),
                new User(2L, "Bob", "bob@example.com")
            ));

        mockMvc.perform(get("/api/users")
                    .param("page", "0")
                    .param("size", "20"))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$", hasSize(2)))
            .andExpect(jsonPath("$[0].name").value("Alice"))
            .andExpect(jsonPath("$[1].name").value("Bob"));
    }

    // Helper DTO record
    record CreateUserRequest(String name, String email) {}
}

Step 5 — @DataJpaTest: Repository Layer

@DataJpaTest loads only JPA-related beans (repositories, EntityManager, datasource). By default it uses an embedded H2 in-memory database and rolls back each test transaction. It's fast and isolated — no Spring MVC, no services, no security.

UserRepositoryTest.javajava
import org.junit.jupiter.api.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.*;
import org.springframework.test.context.jdbc.Sql;
import javax.persistence.*;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.assertj.core.api.Assertions.*; // AssertJ: more readable than JUnit assertions

@DataJpaTest
// @AutoConfigureTestDatabase(replace = NONE) // use this to test against a REAL database
class UserRepositoryTest {

    @Autowired
    UserJpaRepository userRepository; // the real Spring Data repository (no mock!)

    @Autowired
    TestEntityManager entityManager; // helper for setting up test data via EntityManager

    @Test
    @DisplayName("findByEmail returns user when exists")
    void findByEmail_found() {
        // GIVEN: persist test data directly via EntityManager
        UserEntity user = new UserEntity();
        user.setName("Alice");
        user.setEmail("alice@test.com");
        entityManager.persistAndFlush(user); // persist + flush to DB

        // WHEN: call the actual repository method
        Optional<UserEntity> found = userRepository.findByEmail("alice@test.com");

        // THEN: assert with AssertJ (more readable than JUnit assertions)
        assertThat(found).isPresent();
        assertThat(found.get().getName()).isEqualTo("Alice");
        assertThat(found.get().getId()).isNotNull();
    }

    @Test
    @DisplayName("findByEmail returns empty when not found")
    void findByEmail_notFound() {
        Optional<UserEntity> found = userRepository.findByEmail("nobody@test.com");
        assertThat(found).isEmpty();
    }

    @Test
    @DisplayName("save persists and auto-generates ID")
    void save_assignsId() {
        UserEntity user = new UserEntity();
        user.setName("Bob");
        user.setEmail("bob@test.com");

        UserEntity saved = userRepository.save(user);

        assertThat(saved.getId()).isNotNull();
        assertThat(saved.getCreatedAt()).isNotNull(); // @PrePersist field

        // Verify it's actually in the database
        entityManager.flush();
        entityManager.clear(); // clear persistence context — force DB read
        UserEntity fromDb = entityManager.find(UserEntity.class, saved.getId());
        assertThat(fromDb.getEmail()).isEqualTo("bob@test.com");
    }

    @Test
    @DisplayName("existsByEmail returns true for existing email")
    void existsByEmail() {
        UserEntity user = new UserEntity();
        user.setEmail("charlie@test.com");
        user.setName("Charlie");
        entityManager.persistAndFlush(user);

        assertThat(userRepository.existsByEmail("charlie@test.com")).isTrue();
        assertThat(userRepository.existsByEmail("nobody@test.com")).isFalse();
    }

    @Test
    @DisplayName("Custom JPQL query returns filtered results")
    void findActiveUsersOrderedByName() {
        // Setup data
        for (String name : new String[]{"Zara", "Alice", "Bob"}) {
            UserEntity u = new UserEntity();
            u.setName(name);
            u.setEmail(name.toLowerCase() + "@test.com");
            u.setActive(true);
            entityManager.persist(u);
        }
        UserEntity inactive = new UserEntity();
        inactive.setName("Dave");
        inactive.setEmail("dave@test.com");
        inactive.setActive(false);
        entityManager.persistAndFlush(inactive);
        entityManager.clear();

        var results = userRepository.findByActiveTrueOrderByNameAsc();

        assertThat(results).hasSize(3);
        assertThat(results).extracting(UserEntity::getName)
            .containsExactly("Alice", "Bob", "Zara"); // sorted alphabetically
    }

    @Test
    @Sql("/sql/seed-users.sql") // run SQL file before this test
    @DisplayName("findAll with SQL seed data")
    void withSqlSeed() {
        assertThat(userRepository.count()).isGreaterThan(0);
    }
}

Step 6 — @SpringBootTest: Full Integration Test

@SpringBootTest loads the full application context. Combined with @AutoConfigureMockMvc or a real TestRestTemplate, it tests the entire request-response cycle through all layers — controller → service → repository → database. Use sparingly because it's slow; prefer @WebMvcTest and @DataJpaTest for targeted tests.

FullIntegrationTest.javajava
import org.junit.jupiter.api.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.transaction.annotation.Transactional;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import static org.assertj.core.api.Assertions.*;

// Full context, RANDOM_PORT starts real server (NONE = no HTTP server, just context)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureMockMvc
@Transactional // roll back database changes after each test
class FullIntegrationTest {

    @Autowired MockMvc mockMvc;
    @Autowired UserJpaRepository userRepository;

    @Test
    @DisplayName("Full create-then-get flow")
    void createAndRetrieveUser() throws Exception {
        // Create user via REST API
        String createJson = "{\"name\":\"Integration Test User\",\"email\":\"inttest@example.com\"}";

        String location = mockMvc.perform(
                post("/api/users")
                    .contentType(MediaType.APPLICATION_JSON)
                    .content(createJson))
            .andExpect(status().isCreated())
            .andReturn()
            .getResponse()
            .getHeader("Location");

        assertThat(location).isNotNull();

        // Extract ID from Location header and GET the created user
        mockMvc.perform(get(location))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.name").value("Integration Test User"))
            .andExpect(jsonPath("$.email").value("inttest@example.com"));

        // Verify directly in database
        assertThat(userRepository.existsByEmail("inttest@example.com")).isTrue();
    }

    @Test
    @DisplayName("Duplicate email returns 409 Conflict")
    void duplicateEmail_returns409() throws Exception {
        // First insert
        UserEntity existing = new UserEntity();
        existing.setName("Existing");
        existing.setEmail("dup@example.com");
        userRepository.saveAndFlush(existing);

        // Try to create again
        String json = "{\"name\":\"New\",\"email\":\"dup@example.com\"}";
        mockMvc.perform(post("/api/users")
                .contentType(MediaType.APPLICATION_JSON)
                .content(json))
            .andExpect(status().isConflict())
            .andExpect(jsonPath("$.message").value(containsString("dup@example.com")));
    }
}

Step 7 — Testcontainers: Real Database in Tests

H2 is convenient but not identical to PostgreSQL or MySQL. Testcontainers starts a real Docker container for each test class and connects your Spring Boot DataSource to it. This catches SQL dialect differences, index behavior, and constraint issues that H2 would silently ignore.

TestcontainersTest.javajava
import org.junit.jupiter.api.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.*;
import org.springframework.test.context.*;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.*;
import static org.assertj.core.api.Assertions.*;

@DataJpaTest
@Testcontainers // enables Testcontainers lifecycle management
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) // use real DB
class TestcontainersTest {

    // @Container: lifecycle tied to the test class
    // Testcontainers starts the container once and reuses it for all tests in the class
    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
        .withDatabaseName("testdb")
        .withUsername("test")
        .withPassword("test");

    // Override Spring's datasource with the Testcontainers container's connection details
    @DynamicPropertySource
    static void configureProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url",      postgres::getJdbcUrl);
        registry.add("spring.datasource.username", postgres::getUsername);
        registry.add("spring.datasource.password", postgres::getPassword);
        registry.add("spring.datasource.driver-class-name",
            () -> "org.postgresql.Driver");
    }

    @Autowired
    UserJpaRepository userRepository;

    @Autowired
    org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager entityManager;

    @Test
    @DisplayName("Uses real PostgreSQL — not H2")
    void realDatabaseTest() {
        // This test runs against actual PostgreSQL 16
        // PostgreSQL-specific features work: JSONB, arrays, pg_trgm, etc.
        UserEntity user = new UserEntity();
        user.setName("Postgres Test User");
        user.setEmail("pg@test.com");
        entityManager.persistAndFlush(user);

        assertThat(userRepository.findByEmail("pg@test.com")).isPresent();
    }

    @Test
    @DisplayName("Unique email constraint enforced at DB level")
    void uniqueConstraint() {
        UserEntity u1 = new UserEntity(); u1.setEmail("dup@pg.com"); u1.setName("A");
        UserEntity u2 = new UserEntity(); u2.setEmail("dup@pg.com"); u2.setName("B");
        entityManager.persistAndFlush(u1);

        // PostgreSQL enforces the unique constraint at DB level
        assertThatThrownBy(() -> {
            entityManager.persistAndFlush(u2);
        }).isInstanceOf(Exception.class); // DataIntegrityViolationException
    }
}

Step 8 — TDD Workflow: Test-Driven Development

TDD follows three steps: Red (write a failing test), Green (write the minimum code to pass it), Refactor (clean up without breaking tests). This cycle keeps code minimal, well-tested, and focused on actual requirements rather than speculation.

PasswordValidatorTDD.javajava
// ============================================================
// TDD Example: PasswordValidator
// We write tests FIRST, then implement the class to make them pass.
// ============================================================

// STEP 1 — RED: Write the test (class doesn't exist yet — won't compile)
// STEP 2 — GREEN: Write minimum code to pass
// STEP 3 — REFACTOR: Clean up

import org.junit.jupiter.api.*;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import static org.junit.jupiter.api.Assertions.*;

// --- Tests (written first) ---
class PasswordValidatorTest {

    private final PasswordValidator validator = new PasswordValidator();

    @ParameterizedTest(name = "'{0}' should be {1}")
    @CsvSource({
        "Short1!,       false",  // too short (< 8 chars)
        "nouppercase1!, false",  // no uppercase
        "NOLOWERCASE1!, false",  // no lowercase
        "NoSpecial123,  false",  // no special char
        "NoDigitABC!!,  false",  // no digit
        "Valid1Pass!,   true",   // meets all criteria
        "A1b!efghij,    true",   // just enough chars
        "Abcdef1!,      true"    // exactly 8 chars
    })
    void validate(String password, boolean expected) {
        assertEquals(expected, validator.isValid(password));
    }

    @Test
    void nullPassword_returnsFalse() {
        assertFalse(validator.isValid(null));
    }

    @Test
    void getViolations_returnsAllViolations() {
        var violations = validator.getViolations("abc");
        assertTrue(violations.contains("at least 8 characters"));
        assertTrue(violations.contains("uppercase letter"));
        assertTrue(violations.contains("digit"));
        assertTrue(violations.contains("special character"));
    }
}

// --- Implementation (written to make tests pass) ---
class PasswordValidator {
    private static final int MIN_LENGTH = 8;
    private static final String SPECIAL_CHARS = "!@#$%^&*()_+-=[]{}|;':,./<>?";

    public boolean isValid(String password) {
        return getViolations(password).isEmpty();
    }

    public java.util.List<String> getViolations(String password) {
        java.util.List<String> violations = new java.util.ArrayList<>();

        if (password == null || password.length() < MIN_LENGTH) {
            violations.add("at least " + MIN_LENGTH + " characters");
        }
        if (password == null || password.chars().noneMatch(Character::isUpperCase)) {
            violations.add("uppercase letter");
        }
        if (password == null || password.chars().noneMatch(Character::isLowerCase)) {
            violations.add("lowercase letter");
        }
        if (password == null || password.chars().noneMatch(Character::isDigit)) {
            violations.add("digit");
        }
        if (password == null || password.chars().noneMatch(c -> SPECIAL_CHARS.indexOf(c) >= 0)) {
            violations.add("special character");
        }

        return violations;
    }
}

Step 9 — AssertJ: Fluent Assertions

AssertJ provides a fluent, readable alternative to JUnit's assertEquals. It has dedicated assertions for collections, strings, exceptions, files, and optionals. It also generates more descriptive failure messages, making test failures self-explanatory.

AssertJExamples.javajava
import org.junit.jupiter.api.Test;
import java.util.*;
import static org.assertj.core.api.Assertions.*;

class AssertJExamples {

    @Test
    void stringAssertions() {
        String result = "  Hello, World!  ";

        assertThat(result)
            .isNotNull()
            .isNotEmpty()
            .containsIgnoringCase("hello")
            .endsWith("!  ")
            .hasSizeGreaterThan(5);

        assertThat(result.trim())
            .startsWith("Hello")
            .doesNotContain("Java");
    }

    @Test
    void collectionAssertions() {
        List<String> names = List.of("Alice", "Bob", "Charlie", "Dave");

        assertThat(names)
            .hasSize(4)
            .contains("Alice", "Bob")             // order doesn't matter
            .containsExactly("Alice", "Bob", "Charlie", "Dave") // exact order
            .doesNotContain("Eve")
            .allMatch(name -> name.length() > 2)  // all pass predicate
            .anyMatch(name -> name.startsWith("C")); // at least one passes

        // Extract a field and assert on it
        List<User> users = List.of(
            new User(1L, "Alice", "alice@test.com"),
            new User(2L, "Bob",   "bob@test.com")
        );
        assertThat(users)
            .extracting(User::name)  // extract the name() method result
            .containsExactlyInAnyOrder("Bob", "Alice");

        assertThat(users)
            .extracting(User::id, User::name)
            .containsExactly(
                tuple(1L, "Alice"),
                tuple(2L, "Bob")
            );
    }

    @Test
    void optionalAssertions() {
        Optional<String> present = Optional.of("value");
        Optional<String> empty   = Optional.empty();

        assertThat(present).isPresent().hasValue("value");
        assertThat(empty).isEmpty();
    }

    @Test
    void exceptionAssertions() {
        // assertThatThrownBy: most flexible
        assertThatThrownBy(() -> Integer.parseInt("bad"))
            .isInstanceOf(NumberFormatException.class)
            .hasMessageContaining("bad");

        // assertThatExceptionOfType: type-safe
        assertThatExceptionOfType(IllegalArgumentException.class)
            .isThrownBy(() -> { throw new IllegalArgumentException("test"); })
            .withMessage("test");

        // assertThatNoException: confirm nothing is thrown
        assertThatNoException().isThrownBy(() -> Integer.parseInt("123"));
    }

    @Test
    void mapAssertions() {
        Map<String, Integer> scores = Map.of("Alice", 95, "Bob", 87);

        assertThat(scores)
            .hasSize(2)
            .containsKey("Alice")
            .containsEntry("Bob", 87)
            .doesNotContainKey("Charlie");
    }

    record User(Long id, String name, String email) {}
}

Step 10 — Test Coverage and Best Practices

Testing Best Practices

  • Follow the testing pyramid: many unit tests (fast, isolated), fewer integration tests (slower, realistic), very few end-to-end tests (slowest, full system). Don't invert this.
  • Name tests clearly: methodName_condition_expectedBehavior. Example: createUser_emailAlreadyExists_throwsIllegalStateException. The name is the documentation.
  • One logical assertion per test. A test that asserts 10 things fails at the first false assertion and tells you nothing about the remaining 9. Use assertAll() when you must group.
  • Test behavior, not implementation. Don't verify that a private method was called. Verify the observable result. Testing internals makes tests brittle — they break on refactoring even when behavior is correct.
  • Avoid logic in tests. No if/else, no loops in test code. If you need to assert on a list, assert on the whole list, not in a for loop. Test logic is itself untested and hides bugs.
  • Use @BeforeEach for setup, not instance initializers. Makes the setup phase explicit and readable in test reports.
  • Don't share mutable state between tests. Each test must be independent. Tests that pass individually but fail when run in sequence indicate shared state corruption.
  • Run tests in CI on every push. A test suite that developers skip is not a test suite. Integrate with Maven (mvn test), Gradle (./gradlew test), or GitHub Actions.
  • Aim for meaningful coverage, not 100%. Line coverage of 80% on critical business logic paths is more valuable than 100% coverage of getters/setters. Focus on edge cases, error paths, and boundary conditions.