Back to blog

Spring Boot Testing: Complete Guide with JUnit & Mockito

javaspring-boottestingjunitmockitobackend
Spring Boot Testing: Complete Guide with JUnit & Mockito

Introduction

Testing is crucial for building reliable, maintainable Spring Boot applications. Comprehensive test coverage ensures your code works correctly, catches bugs early, and makes refactoring safer.

In this comprehensive guide, we'll cover:

  • Testing fundamentals and test pyramid
  • Unit testing with JUnit 5 and Mockito
  • Integration testing with Spring Boot Test
  • Testing REST APIs with MockMvc
  • Database testing with TestContainers
  • Security testing for authenticated endpoints
  • Test-Driven Development (TDD) best practices
  • Code coverage and continuous testing

Prerequisites: This tutorial builds on Spring Boot Security. Ensure you have a working Spring Boot application with JPA and Security configured.

The Testing Pyramid

A well-tested application follows the testing pyramid:

        /\
       /  \  E2E Tests (Few)
      /____\
     /      \
    / Integr \  Integration Tests (Some)
   /__________\
  /            \
 /  Unit Tests  \  Unit Tests (Many)
/________________\

Unit Tests (70%): Fast, isolated tests for individual components
Integration Tests (20%): Test component interactions
End-to-End Tests (10%): Test complete user workflows

Setting Up Dependencies

Update your pom.xml:

<dependencies>
    <!-- Spring Boot Test Starter (includes JUnit 5, Mockito, AssertJ) -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
 
    <!-- Spring Security Test -->
    <dependency>
        <groupId>org.springframework.security</groupId>
        <artifactId>spring-security-test</artifactId>
        <scope>test</scope>
    </dependency>
 
    <!-- TestContainers for database testing -->
    <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>
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>junit-jupiter</artifactId>
        <version>1.19.3</version>
        <scope>test</scope>
    </dependency>
 
    <!-- H2 Database for in-memory testing (alternative to TestContainers) -->
    <dependency>
        <groupId>com.h2database</groupId>
        <artifactId>h2</artifactId>
        <scope>test</scope>
    </dependency>
 
    <!-- Existing dependencies -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
</dependencies>

Unit Testing with JUnit 5

Testing Service Layer

Unit tests should be fast and isolated. Use Mockito to mock dependencies.

src/test/java/com/example/demo/service/UserServiceTest.java:

package com.example.demo.service;
 
import com.example.demo.entity.User;
import com.example.demo.repository.UserRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
 
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
 
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.*;
 
@ExtendWith(MockitoExtension.class)
@DisplayName("UserService Unit Tests")
class UserServiceTest {
 
    @Mock
    private UserRepository userRepository;
 
    @InjectMocks
    private UserService userService;
 
    private User testUser;
 
    @BeforeEach
    void setUp() {
        testUser = new User("johndoe", "john@example.com", "password123");
        testUser.setId(1L);
        testUser.setFirstName("John");
        testUser.setLastName("Doe");
    }
 
    @Test
    @DisplayName("Should create user successfully")
    void testCreateUser_Success() {
        // Given
        when(userRepository.existsByUsername(anyString())).thenReturn(false);
        when(userRepository.existsByEmail(anyString())).thenReturn(false);
        when(userRepository.save(any(User.class))).thenReturn(testUser);
 
        // When
        User createdUser = userService.createUser(testUser);
 
        // Then
        assertThat(createdUser).isNotNull();
        assertThat(createdUser.getUsername()).isEqualTo("johndoe");
        assertThat(createdUser.getEmail()).isEqualTo("john@example.com");
 
        verify(userRepository, times(1)).save(any(User.class));
    }
 
    @Test
    @DisplayName("Should throw exception when username already exists")
    void testCreateUser_UsernameExists() {
        // Given
        when(userRepository.existsByUsername("johndoe")).thenReturn(true);
 
        // When & Then
        assertThatThrownBy(() -> userService.createUser(testUser))
            .isInstanceOf(IllegalArgumentException.class)
            .hasMessage("Username already exists");
 
        verify(userRepository, never()).save(any(User.class));
    }
 
    @Test
    @DisplayName("Should throw exception when email already exists")
    void testCreateUser_EmailExists() {
        // Given
        when(userRepository.existsByUsername(anyString())).thenReturn(false);
        when(userRepository.existsByEmail("john@example.com")).thenReturn(true);
 
        // When & Then
        assertThatThrownBy(() -> userService.createUser(testUser))
            .isInstanceOf(IllegalArgumentException.class)
            .hasMessage("Email already exists");
 
        verify(userRepository, never()).save(any(User.class));
    }
 
    @Test
    @DisplayName("Should get user by ID successfully")
    void testGetUserById_Success() {
        // Given
        when(userRepository.findById(1L)).thenReturn(Optional.of(testUser));
 
        // When
        Optional<User> foundUser = userService.getUserById(1L);
 
        // Then
        assertThat(foundUser).isPresent();
        assertThat(foundUser.get().getUsername()).isEqualTo("johndoe");
        verify(userRepository, times(1)).findById(1L);
    }
 
    @Test
    @DisplayName("Should return empty when user not found")
    void testGetUserById_NotFound() {
        // Given
        when(userRepository.findById(999L)).thenReturn(Optional.empty());
 
        // When
        Optional<User> foundUser = userService.getUserById(999L);
 
        // Then
        assertThat(foundUser).isEmpty();
        verify(userRepository, times(1)).findById(999L);
    }
 
    @Test
    @DisplayName("Should get all active users")
    void testGetAllActiveUsers() {
        // Given
        User user2 = new User("janedoe", "jane@example.com", "password456");
        user2.setActive(true);
 
        when(userRepository.findByActiveTrue()).thenReturn(Arrays.asList(testUser, user2));
 
        // When
        List<User> activeUsers = userService.getAllActiveUsers();
 
        // Then
        assertThat(activeUsers).hasSize(2);
        assertThat(activeUsers).extracting(User::getUsername)
            .containsExactly("johndoe", "janedoe");
        verify(userRepository, times(1)).findByActiveTrue();
    }
 
    @Test
    @DisplayName("Should update user successfully")
    void testUpdateUser_Success() {
        // Given
        User updatedData = new User();
        updatedData.setFirstName("Johnny");
        updatedData.setLastName("Doe Jr.");
        updatedData.setEmail("johnny@example.com");
 
        when(userRepository.findById(1L)).thenReturn(Optional.of(testUser));
        when(userRepository.save(any(User.class))).thenReturn(testUser);
 
        // When
        User updatedUser = userService.updateUser(1L, updatedData);
 
        // Then
        assertThat(updatedUser.getFirstName()).isEqualTo("Johnny");
        assertThat(updatedUser.getLastName()).isEqualTo("Doe Jr.");
        verify(userRepository, times(1)).save(testUser);
    }
 
    @Test
    @DisplayName("Should throw exception when updating non-existent user")
    void testUpdateUser_NotFound() {
        // Given
        User updatedData = new User();
        when(userRepository.findById(999L)).thenReturn(Optional.empty());
 
        // When & Then
        assertThatThrownBy(() -> userService.updateUser(999L, updatedData))
            .isInstanceOf(IllegalArgumentException.class)
            .hasMessage("User not found");
 
        verify(userRepository, never()).save(any(User.class));
    }
 
    @Test
    @DisplayName("Should deactivate user successfully")
    void testDeactivateUser() {
        // Given
        when(userRepository.findById(1L)).thenReturn(Optional.of(testUser));
        when(userRepository.save(any(User.class))).thenReturn(testUser);
 
        // When
        userService.deactivateUser(1L);
 
        // Then
        assertThat(testUser.getActive()).isFalse();
        verify(userRepository, times(1)).save(testUser);
    }
 
    @Test
    @DisplayName("Should count active users")
    void testCountActiveUsers() {
        // Given
        when(userRepository.countByActiveTrue()).thenReturn(5L);
 
        // When
        long count = userService.countActiveUsers();
 
        // Then
        assertThat(count).isEqualTo(5L);
        verify(userRepository, times(1)).countByActiveTrue();
    }
}

Key Testing Concepts:

  • @ExtendWith(MockitoExtension.class): Enable Mockito
  • @Mock: Create mock dependencies
  • @InjectMocks: Inject mocks into the service
  • @BeforeEach: Setup before each test
  • @DisplayName: Readable test names
  • AssertJ: Fluent assertions (assertThat())
  • Mockito: Verify method calls

Integration Testing with Spring Boot Test

Integration tests verify components work together correctly.

Testing Repository Layer

src/test/java/com/example/demo/repository/UserRepositoryIntegrationTest.java:

package com.example.demo.repository;
 
import com.example.demo.entity.User;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager;
 
import java.util.List;
import java.util.Optional;
 
import static org.assertj.core.api.Assertions.*;
 
@DataJpaTest
@DisplayName("UserRepository Integration Tests")
class UserRepositoryIntegrationTest {
 
    @Autowired
    private TestEntityManager entityManager;
 
    @Autowired
    private UserRepository userRepository;
 
    private User testUser;
 
    @BeforeEach
    void setUp() {
        testUser = new User("johndoe", "john@example.com", "password123");
        testUser.setFirstName("John");
        testUser.setLastName("Doe");
    }
 
    @Test
    @DisplayName("Should save and retrieve user")
    void testSaveAndFindUser() {
        // When
        User savedUser = userRepository.save(testUser);
        entityManager.flush();
 
        // Then
        assertThat(savedUser.getId()).isNotNull();
 
        Optional<User> foundUser = userRepository.findById(savedUser.getId());
        assertThat(foundUser).isPresent();
        assertThat(foundUser.get().getUsername()).isEqualTo("johndoe");
    }
 
    @Test
    @DisplayName("Should find user by username")
    void testFindByUsername() {
        // Given
        entityManager.persist(testUser);
        entityManager.flush();
 
        // When
        Optional<User> foundUser = userRepository.findByUsername("johndoe");
 
        // Then
        assertThat(foundUser).isPresent();
        assertThat(foundUser.get().getEmail()).isEqualTo("john@example.com");
    }
 
    @Test
    @DisplayName("Should find user by email")
    void testFindByEmail() {
        // Given
        entityManager.persist(testUser);
        entityManager.flush();
 
        // When
        Optional<User> foundUser = userRepository.findByEmail("john@example.com");
 
        // Then
        assertThat(foundUser).isPresent();
        assertThat(foundUser.get().getUsername()).isEqualTo("johndoe");
    }
 
    @Test
    @DisplayName("Should find all active users")
    void testFindByActiveTrue() {
        // Given
        User activeUser = new User("active", "active@example.com", "pass");
        activeUser.setActive(true);
 
        User inactiveUser = new User("inactive", "inactive@example.com", "pass");
        inactiveUser.setActive(false);
 
        entityManager.persist(activeUser);
        entityManager.persist(inactiveUser);
        entityManager.flush();
 
        // When
        List<User> activeUsers = userRepository.findByActiveTrue();
 
        // Then
        assertThat(activeUsers).hasSize(1);
        assertThat(activeUsers.get(0).getUsername()).isEqualTo("active");
    }
 
    @Test
    @DisplayName("Should check if username exists")
    void testExistsByUsername() {
        // Given
        entityManager.persist(testUser);
        entityManager.flush();
 
        // When & Then
        assertThat(userRepository.existsByUsername("johndoe")).isTrue();
        assertThat(userRepository.existsByUsername("nonexistent")).isFalse();
    }
 
    @Test
    @DisplayName("Should check if email exists")
    void testExistsByEmail() {
        // Given
        entityManager.persist(testUser);
        entityManager.flush();
 
        // When & Then
        assertThat(userRepository.existsByEmail("john@example.com")).isTrue();
        assertThat(userRepository.existsByEmail("nonexistent@example.com")).isFalse();
    }
 
    @Test
    @DisplayName("Should count active users")
    void testCountByActiveTrue() {
        // Given
        User user1 = new User("user1", "user1@example.com", "pass");
        User user2 = new User("user2", "user2@example.com", "pass");
        User inactiveUser = new User("inactive", "inactive@example.com", "pass");
        inactiveUser.setActive(false);
 
        entityManager.persist(user1);
        entityManager.persist(user2);
        entityManager.persist(inactiveUser);
        entityManager.flush();
 
        // When
        long count = userRepository.countByActiveTrue();
 
        // Then
        assertThat(count).isEqualTo(2);
    }
 
    @Test
    @DisplayName("Should search users by first name")
    void testFindByFirstNameContainingIgnoreCase() {
        // Given
        User john = new User("john1", "john1@example.com", "pass");
        john.setFirstName("Johnny");
 
        User jane = new User("jane1", "jane1@example.com", "pass");
        jane.setFirstName("Jane");
 
        entityManager.persist(john);
        entityManager.persist(jane);
        entityManager.flush();
 
        // When
        List<User> results = userRepository.findByFirstNameContainingIgnoreCase("joh");
 
        // Then
        assertThat(results).hasSize(1);
        assertThat(results.get(0).getFirstName()).isEqualTo("Johnny");
    }
}

Key Concepts:

  • @DataJpaTest: Configures in-memory database for repository tests
  • TestEntityManager: Allows direct database manipulation
  • Tests run in transactions (auto-rollback)
  • Uses H2 or TestContainers for database

Testing REST Controllers with MockMvc

MockMvc allows testing controllers without starting a full server.

src/test/java/com/example/demo/controller/UserControllerTest.java:

package com.example.demo.controller;
 
import com.example.demo.entity.User;
import com.example.demo.service.UserService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
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.security.test.context.support.WithMockUser;
import org.springframework.test.web.servlet.MockMvc;
 
import java.util.Arrays;
import java.util.Optional;
 
import static org.hamcrest.Matchers.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.*;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
 
@WebMvcTest(UserController.class)
@DisplayName("UserController REST API Tests")
class UserControllerTest {
 
    @Autowired
    private MockMvc mockMvc;
 
    @Autowired
    private ObjectMapper objectMapper;
 
    @MockBean
    private UserService userService;
 
    private User testUser;
 
    @BeforeEach
    void setUp() {
        testUser = new User("johndoe", "john@example.com", "password123");
        testUser.setId(1L);
        testUser.setFirstName("John");
        testUser.setLastName("Doe");
    }
 
    @Test
    @DisplayName("GET /api/users/{id} - Should return user when authenticated")
    @WithMockUser
    void testGetUser_Success() throws Exception {
        // Given
        when(userService.getUserById(1L)).thenReturn(Optional.of(testUser));
 
        // When & Then
        mockMvc.perform(get("/api/users/1"))
            .andExpect(status().isOk())
            .andExpect(content().contentType(MediaType.APPLICATION_JSON))
            .andExpect(jsonPath("$.id", is(1)))
            .andExpect(jsonPath("$.username", is("johndoe")))
            .andExpect(jsonPath("$.email", is("john@example.com")))
            .andExpect(jsonPath("$.firstName", is("John")))
            .andExpect(jsonPath("$.lastName", is("Doe")));
 
        verify(userService, times(1)).getUserById(1L);
    }
 
    @Test
    @DisplayName("GET /api/users/{id} - Should return 404 when user not found")
    @WithMockUser
    void testGetUser_NotFound() throws Exception {
        // Given
        when(userService.getUserById(999L)).thenReturn(Optional.empty());
 
        // When & Then
        mockMvc.perform(get("/api/users/999"))
            .andExpect(status().isNotFound());
    }
 
    @Test
    @DisplayName("GET /api/users/{id} - Should return 401 when not authenticated")
    void testGetUser_Unauthorized() throws Exception {
        // When & Then
        mockMvc.perform(get("/api/users/1"))
            .andExpect(status().isUnauthorized());
    }
 
    @Test
    @DisplayName("GET /api/users - Should return all active users for admin")
    @WithMockUser(roles = "ADMIN")
    void testGetAllUsers_Success() throws Exception {
        // Given
        User user2 = new User("janedoe", "jane@example.com", "password456");
        user2.setId(2L);
 
        when(userService.getAllActiveUsers()).thenReturn(Arrays.asList(testUser, user2));
 
        // When & Then
        mockMvc.perform(get("/api/users"))
            .andExpect(status().isOk())
            .andExpect(content().contentType(MediaType.APPLICATION_JSON))
            .andExpect(jsonPath("$", hasSize(2)))
            .andExpect(jsonPath("$[0].username", is("johndoe")))
            .andExpect(jsonPath("$[1].username", is("janedoe")));
    }
 
    @Test
    @DisplayName("GET /api/users - Should return 403 for non-admin users")
    @WithMockUser(roles = "USER")
    void testGetAllUsers_Forbidden() throws Exception {
        // When & Then
        mockMvc.perform(get("/api/users"))
            .andExpect(status().isForbidden());
    }
 
    @Test
    @DisplayName("PUT /api/users/{id} - Should update user successfully")
    @WithMockUser
    void testUpdateUser_Success() throws Exception {
        // Given
        User updatedUser = new User("johndoe", "john.updated@example.com", "password123");
        updatedUser.setId(1L);
        updatedUser.setFirstName("Johnny");
        updatedUser.setLastName("Updated");
 
        when(userService.updateUser(anyLong(), any(User.class))).thenReturn(updatedUser);
 
        // When & Then
        mockMvc.perform(put("/api/users/1")
                .with(csrf())
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(updatedUser)))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.firstName", is("Johnny")))
            .andExpect(jsonPath("$.lastName", is("Updated")))
            .andExpect(jsonPath("$.email", is("john.updated@example.com")));
 
        verify(userService, times(1)).updateUser(anyLong(), any(User.class));
    }
 
    @Test
    @DisplayName("DELETE /api/users/{id} - Should delete user for admin")
    @WithMockUser(roles = "ADMIN")
    void testDeleteUser_Success() throws Exception {
        // Given
        doNothing().when(userService).deleteUser(1L);
 
        // When & Then
        mockMvc.perform(delete("/api/users/1").with(csrf()))
            .andExpect(status().isNoContent());
 
        verify(userService, times(1)).deleteUser(1L);
    }
 
    @Test
    @DisplayName("DELETE /api/users/{id} - Should return 403 for non-admin")
    @WithMockUser(roles = "USER")
    void testDeleteUser_Forbidden() throws Exception {
        // When & Then
        mockMvc.perform(delete("/api/users/1").with(csrf()))
            .andExpect(status().isForbidden());
 
        verify(userService, never()).deleteUser(anyLong());
    }
 
    @Test
    @DisplayName("GET /api/users/count - Should return active user count")
    @WithMockUser
    void testCountActiveUsers() throws Exception {
        // Given
        when(userService.countActiveUsers()).thenReturn(10L);
 
        // When & Then
        mockMvc.perform(get("/api/users/count"))
            .andExpect(status().isOk())
            .andExpect(content().contentType(MediaType.APPLICATION_JSON))
            .andExpect(content().string("10"));
    }
 
    @Test
    @DisplayName("GET /api/users/search - Should search users by name")
    @WithMockUser
    void testSearchUsers() throws Exception {
        // Given
        when(userService.searchUsers("John")).thenReturn(Arrays.asList(testUser));
 
        // When & Then
        mockMvc.perform(get("/api/users/search")
                .param("name", "John"))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$", hasSize(1)))
            .andExpect(jsonPath("$[0].firstName", is("John")));
    }
}

Key Concepts:

  • @WebMvcTest: Load only web layer (controllers)
  • @MockBean: Mock service dependencies
  • @WithMockUser: Simulate authenticated user
  • MockMvc: Perform HTTP requests
  • jsonPath(): Assert JSON response structure
  • .with(csrf()): Include CSRF token for mutating operations

Testing with TestContainers

TestContainers provides real database instances for integration tests.

TestContainers Configuration

src/test/java/com/example/demo/AbstractIntegrationTest.java:

package com.example.demo;
 
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
 
@SpringBootTest
@Testcontainers
public abstract class AbstractIntegrationTest {
 
    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15-alpine")
        .withDatabaseName("testdb")
        .withUsername("test")
        .withPassword("test");
 
    @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);
    }
}

Full Integration Test

src/test/java/com/example/demo/UserIntegrationTest.java:

package com.example.demo;
 
import com.example.demo.entity.User;
import com.example.demo.repository.UserRepository;
import com.example.demo.service.UserService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.transaction.annotation.Transactional;
 
import java.util.List;
import java.util.Optional;
 
import static org.assertj.core.api.Assertions.*;
 
@DisplayName("User Full Integration Tests")
@Transactional
class UserIntegrationTest extends AbstractIntegrationTest {
 
    @Autowired
    private UserService userService;
 
    @Autowired
    private UserRepository userRepository;
 
    @BeforeEach
    void setUp() {
        userRepository.deleteAll();
    }
 
    @Test
    @DisplayName("Should create user end-to-end")
    void testCreateUserEndToEnd() {
        // Given
        User newUser = new User("testuser", "test@example.com", "password123");
        newUser.setFirstName("Test");
        newUser.setLastName("User");
 
        // When
        User createdUser = userService.createUser(newUser);
 
        // Then
        assertThat(createdUser.getId()).isNotNull();
 
        Optional<User> foundUser = userRepository.findById(createdUser.getId());
        assertThat(foundUser).isPresent();
        assertThat(foundUser.get().getUsername()).isEqualTo("testuser");
    }
 
    @Test
    @DisplayName("Should find active users only")
    void testFindActiveUsersOnly() {
        // Given
        User activeUser = new User("active", "active@example.com", "pass");
        activeUser.setActive(true);
        userRepository.save(activeUser);
 
        User inactiveUser = new User("inactive", "inactive@example.com", "pass");
        inactiveUser.setActive(false);
        userRepository.save(inactiveUser);
 
        // When
        List<User> activeUsers = userService.getAllActiveUsers();
 
        // Then
        assertThat(activeUsers).hasSize(1);
        assertThat(activeUsers.get(0).getUsername()).isEqualTo("active");
    }
}

Testing Authentication Endpoints

src/test/java/com/example/demo/controller/AuthControllerTest.java:

package com.example.demo.controller;
 
import com.example.demo.dto.LoginRequest;
import com.example.demo.dto.RegisterRequest;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
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.hamcrest.Matchers.*;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
 
@SpringBootTest
@AutoConfigureMockMvc
@Transactional
@DisplayName("Authentication API Tests")
class AuthControllerIntegrationTest {
 
    @Autowired
    private MockMvc mockMvc;
 
    @Autowired
    private ObjectMapper objectMapper;
 
    @Test
    @DisplayName("POST /api/auth/register - Should register new user")
    void testRegister_Success() throws Exception {
        // Given
        RegisterRequest request = new RegisterRequest();
        request.setUsername("newuser");
        request.setEmail("new@example.com");
        request.setPassword("password123");
        request.setFirstName("New");
        request.setLastName("User");
 
        // When & Then
        mockMvc.perform(post("/api/auth/register")
                .with(csrf())
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(request)))
            .andExpect(status().isCreated())
            .andExpect(content().string(containsString("registered successfully")));
    }
 
    @Test
    @DisplayName("POST /api/auth/register - Should reject duplicate username")
    void testRegister_DuplicateUsername() throws Exception {
        // Given - First registration
        RegisterRequest request1 = new RegisterRequest();
        request1.setUsername("duplicate");
        request1.setEmail("email1@example.com");
        request1.setPassword("password123");
 
        mockMvc.perform(post("/api/auth/register")
                .with(csrf())
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(request1)))
            .andExpect(status().isCreated());
 
        // When & Then - Second registration with same username
        RegisterRequest request2 = new RegisterRequest();
        request2.setUsername("duplicate");
        request2.setEmail("email2@example.com");
        request2.setPassword("password456");
 
        mockMvc.perform(post("/api/auth/register")
                .with(csrf())
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(request2)))
            .andExpect(status().isBadRequest())
            .andExpect(content().string(containsString("Username")));
    }
 
    @Test
    @DisplayName("POST /api/auth/login - Should login with valid credentials")
    void testLogin_Success() throws Exception {
        // Given - Register user first
        RegisterRequest registerRequest = new RegisterRequest();
        registerRequest.setUsername("logintest");
        registerRequest.setEmail("login@example.com");
        registerRequest.setPassword("password123");
 
        mockMvc.perform(post("/api/auth/register")
                .with(csrf())
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(registerRequest)))
            .andExpect(status().isCreated());
 
        // When & Then - Login
        LoginRequest loginRequest = new LoginRequest("logintest", "password123");
 
        mockMvc.perform(post("/api/auth/login")
                .with(csrf())
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(loginRequest)))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.token", notNullValue()))
            .andExpect(jsonPath("$.type", is("Bearer")))
            .andExpect(jsonPath("$.username", is("logintest")));
    }
 
    @Test
    @DisplayName("POST /api/auth/login - Should reject invalid credentials")
    void testLogin_InvalidCredentials() throws Exception {
        // Given
        LoginRequest loginRequest = new LoginRequest("nonexistent", "wrongpassword");
 
        // When & Then
        mockMvc.perform(post("/api/auth/login")
                .with(csrf())
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(loginRequest)))
            .andExpect(status().isUnauthorized());
    }
}

Test-Driven Development (TDD)

TDD follows the Red-Green-Refactor cycle:

  1. Red: Write a failing test
  2. Green: Write minimal code to pass
  3. Refactor: Improve code while keeping tests green

TDD Example

// Step 1: Write failing test
@Test
void testCalculateDiscount() {
    OrderService service = new OrderService();
    BigDecimal price = new BigDecimal("100.00");
    BigDecimal discount = service.calculateDiscount(price, 10);
    assertThat(discount).isEqualByComparingTo(new BigDecimal("10.00"));
}
 
// Step 2: Implement minimal code
public BigDecimal calculateDiscount(BigDecimal price, int percentage) {
    return price.multiply(new BigDecimal(percentage))
                .divide(new BigDecimal(100));
}
 
// Step 3: Refactor (add validation, edge cases, etc.)

Best Practices

1. Follow AAA Pattern

@Test
void testExample() {
    // Arrange: Setup test data
    User user = new User("test", "test@example.com", "pass");
 
    // Act: Execute the method under test
    User result = userService.createUser(user);
 
    // Assert: Verify the outcome
    assertThat(result).isNotNull();
}

2. Use Descriptive Test Names

// Good
@Test
void shouldReturnUserWhenValidIdProvided() { }
 
// Bad
@Test
void test1() { }

3. Test One Thing Per Test

// Good
@Test
void shouldValidateEmail() { }
 
@Test
void shouldValidatePassword() { }
 
// Bad
@Test
void shouldValidateUserInput() {
    // Tests both email and password
}

4. Use Test Fixtures

@BeforeEach
void setUp() {
    testUser = new User("test", "test@example.com", "pass");
}

5. Mock External Dependencies

@Mock
private EmailService emailService;
 
@Test
void shouldSendWelcomeEmail() {
    userService.createUser(testUser);
    verify(emailService).sendWelcomeEmail(testUser.getEmail());
}

6. Use TestContainers for Real Dependencies

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

7. Organize Tests by Feature

src/test/java/
├── unit/
│   ├── service/
│   └── util/
├── integration/
│   ├── repository/
│   └── controller/
└── e2e/
    └── scenarios/

Code Coverage

Measure with JaCoCo

Add to pom.xml:

<plugin>
    <groupId>org.jacoco</groupId>
    <artifactId>jacoco-maven-plugin</artifactId>
    <version>0.8.11</version>
    <executions>
        <execution>
            <goals>
                <goal>prepare-agent</goal>
            </goals>
        </execution>
        <execution>
            <id>report</id>
            <phase>test</phase>
            <goals>
                <goal>report</goal>
            </goals>
        </execution>
    </executions>
</plugin>

Run tests with coverage:

mvn clean test
mvn jacoco:report

View report: target/site/jacoco/index.html

Coverage Goals

  • Aim for 80%+ overall coverage
  • Critical paths: 90%+ coverage
  • Don't obsess over 100%: Focus on meaningful tests

Continuous Testing

Run Tests on Every Commit

# Pre-commit hook
#!/bin/bash
mvn test
if [ $? -ne 0 ]; then
    echo "Tests failed. Commit aborted."
    exit 1
fi

CI/CD Integration

GitHub Actions example:

name: Tests
 
on: [push, pull_request]
 
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - name: Set up JDK 21
        uses: actions/setup-java@v3
        with:
          java-version: '21'
      - name: Run tests
        run: mvn test
      - name: Generate coverage report
        run: mvn jacoco:report

Common Testing Pitfalls

1. Testing Implementation Details

// Bad: Tests internal implementation
@Test
void shouldCallRepositorySaveMethod() {
    userService.createUser(user);
    verify(userRepository).save(any());
}
 
// Good: Tests behavior
@Test
void shouldPersistUserSuccessfully() {
    User created = userService.createUser(user);
    assertThat(created.getId()).isNotNull();
}

2. Slow Tests

// Bad: Thread.sleep in tests
@Test
void testAsync() throws InterruptedException {
    asyncService.process();
    Thread.sleep(5000); // Don't do this!
}
 
// Good: Use Awaitility
@Test
void testAsync() {
    asyncService.process();
    await().atMost(5, SECONDS)
        .until(() -> asyncService.isComplete());
}

3. Flaky Tests

// Bad: Depends on execution order
@Test
void test1() { database.insert(user); }
 
@Test
void test2() { assertThat(database.count()).isEqualTo(1); }
 
// Good: Each test is independent
@BeforeEach
void setUp() { database.clear(); }

Conclusion

Comprehensive testing is essential for building reliable Spring Boot applications. Key takeaways:

  • Follow the test pyramid: Many unit tests, some integration tests, few E2E tests
  • Use JUnit 5 and Mockito: Standard testing frameworks for Java
  • MockMvc for API testing: Test controllers without starting server
  • TestContainers for integration: Real database instances
  • Practice TDD: Write tests first, then implementation
  • Aim for high coverage: But focus on meaningful tests
  • Automate testing: CI/CD pipeline integration

Well-tested code is easier to maintain, refactor, and extend.

Resources

📬 Subscribe to Newsletter

Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.

We respect your privacy. Unsubscribe at any time.

💬 Comments

Sign in to leave a comment

We'll never post without your permission.