springboot-tdd

SKILL.md

Spring Boot TDD Patterns

Test-driven development workflow for Spring Boot 3.x with JUnit 5.

Test Pyramid

         /  E2E  \          <- Few, slow, high confidence
        / Integration \     <- Moderate, Testcontainers
       /  Slice Tests   \   <- Fast, focused (@WebMvcTest, @DataJpaTest)
      /   Unit Tests     \  <- Many, fastest, isolated

Unit Tests (Service Layer)

@ExtendWith(MockitoExtension.class)
class UserServiceTest {

    @Mock
    private UserRepository userRepository;

    @InjectMocks
    private UserServiceImpl userService;

    @Test
    void findById_existingUser_returnsResponse() {
        // Arrange
        UUID id = UUID.randomUUID();
        User user = User.builder().name("Alice").email("alice@example.com").build();
        when(userRepository.findById(id)).thenReturn(Optional.of(user));

        // Act
        UserResponse result = userService.findById(id);

        // Assert
        assertThat(result.name()).isEqualTo("Alice");
        assertThat(result.email()).isEqualTo("alice@example.com");
        verify(userRepository).findById(id);
    }

    @Test
    void findById_nonExistingUser_throwsException() {
        UUID id = UUID.randomUUID();
        when(userRepository.findById(id)).thenReturn(Optional.empty());

        assertThatThrownBy(() -> userService.findById(id))
            .isInstanceOf(ResourceNotFoundException.class)
            .hasMessageContaining(id.toString());
    }

    @Test
    void create_validRequest_savesAndReturns() {
        UserRequest request = new UserRequest("Alice", "alice@example.com");
        User saved = User.builder().name("Alice").email("alice@example.com").build();
        when(userRepository.save(any(User.class))).thenReturn(saved);

        UserResponse result = userService.create(request);

        assertThat(result.name()).isEqualTo("Alice");
        verify(userRepository).save(argThat(u -> u.getName().equals("Alice")));
    }
}

Controller Slice Tests (@WebMvcTest)

@WebMvcTest(UserController.class)
@AutoConfigureMockMvc(addFilters = false)  // Disable security for unit tests
class UserControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private UserService userService;

    @Autowired
    private ObjectMapper objectMapper;

    @Test
    void listUsers_returnsPageOfUsers() throws Exception {
        Page<UserResponse> page = new PageImpl<>(List.of(
            new UserResponse(UUID.randomUUID(), "Alice", "alice@example.com", true, Instant.now())
        ));
        when(userService.findAll(any(Pageable.class))).thenReturn(page);

        mockMvc.perform(get("/api/v1/users"))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.content", hasSize(1)))
            .andExpect(jsonPath("$.content[0].name").value("Alice"));
    }

    @Test
    void createUser_validRequest_returns201() throws Exception {
        UserRequest request = new UserRequest("Alice", "alice@example.com");
        UserResponse response = new UserResponse(
            UUID.randomUUID(), "Alice", "alice@example.com", true, Instant.now());
        when(userService.create(any(UserRequest.class))).thenReturn(response);

        mockMvc.perform(post("/api/v1/users")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(request)))
            .andExpect(status().isCreated())
            .andExpect(jsonPath("$.name").value("Alice"));
    }

    @Test
    void createUser_invalidEmail_returns400() throws Exception {
        String json = """
            {"name": "Alice", "email": "not-an-email"}
            """;

        mockMvc.perform(post("/api/v1/users")
                .contentType(MediaType.APPLICATION_JSON)
                .content(json))
            .andExpect(status().isBadRequest())
            .andExpect(jsonPath("$.errors.email").exists());
    }

    @Test
    void getUser_notFound_returns404() throws Exception {
        UUID id = UUID.randomUUID();
        when(userService.findById(id)).thenThrow(new ResourceNotFoundException("User", id));

        mockMvc.perform(get("/api/v1/users/{id}", id))
            .andExpect(status().isNotFound());
    }
}

Repository Slice Tests (@DataJpaTest)

@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers
class UserRepositoryTest {

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

    @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);
    }

    @Autowired
    private UserRepository userRepository;

    @Test
    void findByEmail_existingEmail_returnsUser() {
        User user = User.builder().name("Alice").email("alice@example.com").build();
        userRepository.save(user);

        Optional<User> found = userRepository.findByEmail("alice@example.com");

        assertThat(found).isPresent();
        assertThat(found.get().getName()).isEqualTo("Alice");
    }

    @Test
    void findByEmail_nonExistingEmail_returnsEmpty() {
        Optional<User> found = userRepository.findByEmail("nobody@example.com");
        assertThat(found).isEmpty();
    }

    @Test
    void findAllActive_excludesInactive() {
        userRepository.save(User.builder().name("Active").email("a@test.com").active(true).build());
        userRepository.save(User.builder().name("Inactive").email("b@test.com").active(false).build());

        Page<User> active = userRepository.findAllActive(PageRequest.of(0, 10));

        assertThat(active.getContent()).hasSize(1);
        assertThat(active.getContent().get(0).getName()).isEqualTo("Active");
    }
}

Integration Tests

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
@ActiveProfiles("test")
class UserIntegrationTest {

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

    @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);
    }

    @Autowired
    private TestRestTemplate restTemplate;

    @Autowired
    private UserRepository userRepository;

    @BeforeEach
    void setUp() {
        userRepository.deleteAll();
    }

    @Test
    void fullCrudLifecycle() {
        // Create
        UserRequest request = new UserRequest("Alice", "alice@example.com");
        ResponseEntity<UserResponse> createResponse = restTemplate.postForEntity(
            "/api/v1/users", request, UserResponse.class);
        assertThat(createResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED);
        UUID userId = createResponse.getBody().id();

        // Read
        ResponseEntity<UserResponse> getResponse = restTemplate.getForEntity(
            "/api/v1/users/{id}", UserResponse.class, userId);
        assertThat(getResponse.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(getResponse.getBody().name()).isEqualTo("Alice");

        // Update
        restTemplate.put("/api/v1/users/{id}", new UserRequest("Bob", "alice@example.com"), userId);
        UserResponse updated = restTemplate.getForObject("/api/v1/users/{id}", UserResponse.class, userId);
        assertThat(updated.name()).isEqualTo("Bob");

        // Delete
        restTemplate.delete("/api/v1/users/{id}", userId);
        ResponseEntity<Void> deleteCheck = restTemplate.getForEntity(
            "/api/v1/users/{id}", Void.class, userId);
        assertThat(deleteCheck.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
    }
}

Test Configuration

# src/test/resources/application-test.yml
spring:
  jpa:
    hibernate:
      ddl-auto: create-drop
    show-sql: true
  flyway:
    enabled: false  # Use Hibernate DDL for tests

logging:
  level:
    org.hibernate.SQL: DEBUG
    org.hibernate.orm.jdbc.bind: TRACE

TDD Workflow Commands

# Run all tests
./gradlew test

# Run specific test class
./gradlew test --tests "com.example.app.service.UserServiceTest"

# Run with coverage (JaCoCo)
./gradlew test jacocoTestReport

# Run only unit tests
./gradlew test --tests "*Test"

# Run only integration tests
./gradlew test --tests "*IntegrationTest"

# Continuous test runner
./gradlew test --continuous

JaCoCo Coverage Config

// build.gradle
plugins {
    id 'jacoco'
}

jacocoTestReport {
    reports {
        html.required = true
        xml.required = true
    }
}

jacocoTestCoverageVerification {
    violationRules {
        rule {
            limit {
                minimum = 0.80  // 80% coverage minimum
            }
        }
    }
}

check.dependsOn jacocoTestCoverageVerification

Checklist

  • Unit tests for all service methods with Mockito
  • Controller tests with MockMvc for HTTP behavior
  • Repository tests with Testcontainers (real database)
  • Integration tests covering full request lifecycle
  • Validation error cases tested (400 responses)
  • Not-found cases tested (404 responses)
  • Test profile with separate configuration
  • JaCoCo coverage threshold at 80%
  • Tests isolated with @BeforeEach cleanup
  • Testcontainers for database-dependent tests
Weekly Installs
2
GitHub Stars
1
First Seen
Feb 28, 2026
Installed on
opencode2
gemini-cli2
codebuddy2
github-copilot2
codex2
kimi-cli2