Spring Boot Testing Comprehensive Guide

Testing philosophy: A well-tested application gives you confidence to refactor, deploy, and make changes without fear of breaking existing functionality.

The Test Pyramid

           /\
          /  \
         / E2E \         Few end-to-end tests
        /______\        - Slow
       /        \       - Test entire application
      / Integrat\       - Full Spring context
     /___________\      
    /            \      Many integration tests
   / Unit Tests  \     - Medium speed
  /_______________\    - Test Spring components together
                      - @SpringBootTest, @DataJpaTest
  
Lots of unit tests     - Fast
- Mock dependencies    - Test single class in isolation
- No Spring context    - Use Mockito
- Test business logic

Unit Testing: Test a Single Class

Testing a Service with Mocked Dependencies

// The service to test
@Service
public class UserService {
    private final UserRepository repository;
    private final EmailService emailService;
    
    public UserService(UserRepository repository, EmailService emailService) {
        this.repository = repository;
        this.emailService = emailService;
    }
    
    public UserResponse createUser(CreateUserRequest request) {
        User user = new User();
        user.setName(request.name());
        user.setEmail(request.email());
        
        User saved = repository.save(user);
        emailService.sendWelcomeEmail(saved.getEmail());
        
        return new UserResponse(saved.getId(), saved.getName(), saved.getEmail());
    }
}

// Unit test
@ExtendWith(MockitoExtension.class)
public class UserServiceTest {
    
    @Mock
    private UserRepository repository;
    
    @Mock
    private EmailService emailService;
    
    @InjectMocks
    private UserService userService;
    
    @Test
    public void testCreateUserSuccessfully() {
        // Arrange (Given)
        CreateUserRequest request = new CreateUserRequest("John", "john@example.com");
        User savedUser = new User(1L, "John", "john@example.com");
        when(repository.save(any(User.class))).thenReturn(savedUser);
        
        // Act (When)
        UserResponse response = userService.createUser(request);
        
        // Assert (Then)
        assertEquals("John", response.name());
        assertEquals("john@example.com", response.email());
        
        // Verify interactions
        verify(repository).save(any(User.class));
        verify(emailService).sendWelcomeEmail("john@example.com");
    }
    
    @Test
    public void testCreateUserEmailServiceFailure() {
        // Test what happens when email sending fails
        CreateUserRequest request = new CreateUserRequest("John", "john@example.com");
        when(repository.save(any(User.class))).thenReturn(new User(1L, "John", "john@example.com"));
        doThrow(new EmailException("SMTP unavailable"))
            .when(emailService).sendWelcomeEmail(any());
        
        // The service should still return user, not crash
        assertThrows(EmailException.class, () -> userService.createUser(request));
    }
}

Integration Testing: Test Components Together

Testing the Database Layer with @DataJpaTest

@DataJpaTest
public class UserRepositoryTest {
    
    @Autowired
    private UserRepository repository;
    
    @Autowired
    private TestEntityManager entityManager;
    
    @Test
    public void testFindByEmail() {
        // Arrange
        User user = new User();
        user.setName("John");
        user.setEmail("john@example.com");
        entityManager.persistAndFlush(user);
        
        // Act
        Optional<User> found = repository.findByEmail("john@example.com");
        
        // Assert
        assertTrue(found.isPresent());
        assertEquals("John", found.get().getName());
    }
    
    @Test
    public void testFindByEmailNotFound() {
        // Act
        Optional<User> found = repository.findByEmail("nonexistent@example.com");
        
        // Assert
        assertFalse(found.isPresent());
    }
    
    @Test
    public void testFindByNameContainingIgnoreCase() {
        // Arrange
        User user1 = new User(null, "John Doe", "john@example.com");
        User user2 = new User(null, "Jane Smith", "jane@example.com");
        entityManager.persistAndFlush(user1);
        entityManager.persistAndFlush(user2);
        
        // Act
        List<User> results = repository.findByNameContainingIgnoreCase("john");
        
        // Assert
        assertEquals(1, results.size());
        assertEquals("John Doe", results.get(0).getName());
    }
}

Testing REST Controllers with MockMvc

@WebMvcTest(UserController.class)
public class UserControllerTest {
    
    @Autowired
    private MockMvc mockMvc;
    
    @MockBean
    private UserService userService;
    
    @Test
    public void testGetUserSuccess() throws Exception {
        // Arrange
        UserResponse response = new UserResponse(1L, "John", "john@example.com");
        when(userService.findById(1L)).thenReturn(Optional.of(response));
        
        // Act & Assert
        mockMvc.perform(get("/api/v1/users/1")
            .contentType(MediaType.APPLICATION_JSON))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.id").value(1))
            .andExpect(jsonPath("$.name").value("John"))
            .andExpect(jsonPath("$.email").value("john@example.com"));
    }
    
    @Test
    public void testGetUserNotFound() throws Exception {
        // Arrange
        when(userService.findById(999L)).thenReturn(Optional.empty());
        
        // Act & Assert
        mockMvc.perform(get("/api/v1/users/999"))
            .andExpect(status().isNotFound());
    }
    
    @Test
    public void testCreateUserSuccess() throws Exception {
        // Arrange
        CreateUserRequest request = new CreateUserRequest("John", "john@example.com");
        UserResponse response = new UserResponse(1L, "John", "john@example.com");
        when(userService.create(any())).thenReturn(response);
        
        // Act & Assert
        mockMvc.perform(post("/api/v1/users")
            .contentType(MediaType.APPLICATION_JSON)
            .content("""
                {"name": "John", "email": "john@example.com"}
                """))
            .andExpect(status().isCreated())
            .andExpect(header().exists("Location"))
            .andExpect(jsonPath("$.id").value(1));
    }
    
    @Test
    public void testCreateUserValidationError() throws Exception {
        // Invalid input: missing name
        mockMvc.perform(post("/api/v1/users")
            .contentType(MediaType.APPLICATION_JSON)
            .content("""
                {"email": "john@example.com"}
                """))
            .andExpect(status().isBadRequest());
    }
}

Full Integration Testing with @SpringBootTest

@SpringBootTest
@AutoConfigureMockMvc
public class UserIntegrationTest {
    
    @Autowired
    private MockMvc mockMvc;
    
    @Autowired
    private UserRepository userRepository;
    
    @BeforeEach
    public void cleanup() {
        userRepository.deleteAll();
    }
    
    @Test
    public void testCreateAndRetrieveUser() throws Exception {
        // Create a user via API
        mockMvc.perform(post("/api/v1/users")
            .contentType(MediaType.APPLICATION_JSON)
            .content("""
                {"name": "John Doe", "email": "john@example.com"}
                """))
            .andExpect(status().isCreated());
        
        // Retrieve the user
        mockMvc.perform(get("/api/v1/users")
            .contentType(MediaType.APPLICATION_JSON))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.content[0].name").value("John Doe"));
        
        // Verify in database
        Optional<User> user = userRepository.findByEmail("john@example.com");
        assertTrue(user.isPresent());
        assertEquals("John Doe", user.get().getName());
    }
}

Common Testing Annotations

Annotation Purpose Use when
@ExtendWith(MockitoExtension.class) Enable Mockito for unit tests Testing a single class with mocks
@Mock Create a mock object Need to replace a dependency
@InjectMocks Inject mocks into class under test Auto-inject mocks into constructor
@SpringBootTest Load full application context Full integration testing
@WebMvcTest Test only REST controllers Controller unit testing
@DataJpaTest Test only repository/database layer Repository unit testing
@MockBean Mock a Spring bean Replace bean in Spring context
@BeforeEach Run before each test Setup/cleanup per test
@BeforeAll Run once before all tests Heavy setup (once per class)
@DisplayName Human-readable test name Better test reports

Testing Best Practices

  • ✅ One assertion per test (when possible)
  • ✅ Use descriptive test names: testCreateUserWithValidInput()
  • ✅ Use Given-When-Then pattern for clarity
  • ✅ Test both success and failure cases
  • ✅ Use @BeforeEach to setup each test independently
  • ✅ Mock external dependencies (database, API calls)
  • ✅ Test edge cases and boundaries
  • ✅ Keep tests fast (use unit tests, not integration tests for everything)
  • ✅ Test at the right level (pyramid principle)
  • ❌ Don't test framework behavior (Spring knows it works)
  • ❌ Don't test library code (trust JUnit, Mockito)
  • ❌ Don't make tests interdependent
  • ❌ Don't test too much in one test

Measuring Test Coverage

Use JaCoCo to measure how much of your code is tested:

// pom.xml
<plugin>
    <groupId>org.jacoco</groupId>
    <artifactId>jacoco-maven-plugin</artifactId>
    <version>0.8.8</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: ./mvnw clean test
// View report: target/site/jacoco/index.html

Coverage goals: 80%+ for business logic, 50%+ overall is reasonable

See Basic Testing Guide →

Continue learning