Back to blog

Testing Spring Boot: Hướng Dẫn Toàn Diện với JUnit & Mockito

javaspring-boottestingjunitmockitobackend
Testing Spring Boot: Hướng Dẫn Toàn Diện với JUnit & Mockito

Giới Thiệu

Testing là yếu tố quan trọng để xây dựng ứng dụng Spring Boot đáng tin cậy và dễ bảo trì. Test coverage toàn diện đảm bảo code hoạt động đúng, phát hiện lỗi sớm và giúp refactoring an toàn hơn.

Trong hướng dẫn toàn diện này, chúng ta sẽ tìm hiểu:

  • Các nguyên tắc cơ bản về testing và test pyramid
  • Unit testing với JUnit 5 và Mockito
  • Integration testing với Spring Boot Test
  • Testing REST APIs với MockMvc
  • Database testing với TestContainers
  • Security testing cho các endpoint được xác thực
  • Phương pháp hay nhất cho Test-Driven Development (TDD)
  • Code coverage và continuous testing

Yêu cầu: Bài hướng dẫn này xây dựng dựa trên Spring Boot Security. Đảm bảo bạn đã có ứng dụng Spring Boot hoạt động với JPA và Security.

Test Pyramid

Một ứng dụng được test tốt tuân theo test pyramid:

        /\
       /  \  E2E Tests (Ít)
      /____\
     /      \
    / Integr \  Integration Tests (Vừa)
   /__________\
  /            \
 /  Unit Tests  \  Unit Tests (Nhiều)
/________________\

Unit Tests (70%): Tests nhanh, độc lập cho từng component
Integration Tests (20%): Test tương tác giữa các components
End-to-End Tests (10%): Test toàn bộ luồng người dùng

Thiết Lập Dependencies

Cập nhật pom.xml:

<dependencies>
    <!-- Spring Boot Test Starter (bao gồm 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 cho 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>
 
    <!-- H2 Database cho in-memory testing -->
    <dependency>
        <groupId>com.h2database</groupId>
        <artifactId>h2</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

Unit Testing với JUnit 5

Testing Service Layer

Unit tests nên nhanh và độc lập. Sử dụng Mockito để 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.Optional;
 
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
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", "[email protected]", "password123");
        testUser.setId(1L);
    }
 
    @Test
    @DisplayName("Nên tạo user thành công")
    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");
        verify(userRepository, times(1)).save(any(User.class));
    }
 
    @Test
    @DisplayName("Nên throw exception khi username đã tồn tại")
    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));
    }
}

Các khái niệm chính:

  • @ExtendWith(MockitoExtension.class): Kích hoạt Mockito
  • @Mock: Tạo mock dependencies
  • @InjectMocks: Inject mocks vào service
  • @BeforeEach: Setup trước mỗi test
  • AssertJ: Assertions rõ ràng (assertThat())

Integration Testing

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.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.Optional;
 
import static org.assertj.core.api.Assertions.*;
 
@DataJpaTest
@DisplayName("UserRepository Integration Tests")
class UserRepositoryIntegrationTest {
 
    @Autowired
    private TestEntityManager entityManager;
 
    @Autowired
    private UserRepository userRepository;
 
    @Test
    @DisplayName("Nên lưu và truy xuất user")
    void testSaveAndFindUser() {
        // Given
        User user = new User("testuser", "[email protected]", "pass");
 
        // When
        User savedUser = userRepository.save(user);
        entityManager.flush();
 
        // Then
        assertThat(savedUser.getId()).isNotNull();
 
        Optional<User> found = userRepository.findById(savedUser.getId());
        assertThat(found).isPresent();
        assertThat(found.get().getUsername()).isEqualTo("testuser");
    }
 
    @Test
    @DisplayName("Nên tìm user theo username")
    void testFindByUsername() {
        // Given
        User user = new User("johndoe", "[email protected]", "pass");
        entityManager.persist(user);
        entityManager.flush();
 
        // When
        Optional<User> found = userRepository.findByUsername("johndoe");
 
        // Then
        assertThat(found).isPresent();
        assertThat(found.get().getEmail()).isEqualTo("[email protected]");
    }
}

Testing REST Controllers với MockMvc

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 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.security.test.context.support.WithMockUser;
import org.springframework.test.web.servlet.MockMvc;
 
import java.util.Optional;
 
import static org.hamcrest.Matchers.*;
import static org.mockito.Mockito.*;
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;
 
    @MockBean
    private UserService userService;
 
    @Test
    @DisplayName("GET /api/users/{id} - Nên trả về user khi đã xác thực")
    @WithMockUser
    void testGetUser_Success() throws Exception {
        // Given
        User user = new User("johndoe", "[email protected]", "pass");
        user.setId(1L);
        when(userService.getUserById(1L)).thenReturn(Optional.of(user));
 
        // When & Then
        mockMvc.perform(get("/api/users/1"))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.id", is(1)))
            .andExpect(jsonPath("$.username", is("johndoe")));
    }
 
    @Test
    @DisplayName("GET /api/users/{id} - Nên trả về 401 khi chưa xác thực")
    void testGetUser_Unauthorized() throws Exception {
        mockMvc.perform(get("/api/users/1"))
            .andExpect(status().isUnauthorized());
    }
 
    @Test
    @DisplayName("GET /api/users - Nên trả về 403 cho non-admin")
    @WithMockUser(roles = "USER")
    void testGetAllUsers_Forbidden() throws Exception {
        mockMvc.perform(get("/api/users"))
            .andExpect(status().isForbidden());
    }
}

Testing với TestContainers

TestContainers cung cấp database thực cho integration tests.

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

Best Practices

1. Theo Mẫu AAA

@Test
void testExample() {
    // Arrange: Chuẩn bị dữ liệu test
    User user = new User("test", "[email protected]", "pass");
 
    // Act: Thực thi method cần test
    User result = userService.createUser(user);
 
    // Assert: Xác minh kết quả
    assertThat(result).isNotNull();
}

2. Sử Dụng Tên Test Mô Tả

// Tốt
@Test
void shouldReturnUserWhenValidIdProvided() { }
 
// Không tốt
@Test
void test1() { }

3. Test Một Điều Mỗi Test

// Tốt
@Test
void shouldValidateEmail() { }
 
@Test
void shouldValidatePassword() { }

4. Mock External Dependencies

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

Code Coverage

Đo Lường với JaCoCo

Thêm vào 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>

Chạy tests với coverage:

mvn clean test
mvn jacoco:report

Xem báo cáo: target/site/jacoco/index.html

Mục Tiêu Coverage

  • Hướng tới 80%+ overall coverage
  • Critical paths: 90%+ coverage
  • Đừng quá ám ảnh 100%: Tập trung vào tests có ý nghĩa

Kết Luận

Testing toàn diện là yếu tố thiết yếu để xây dựng ứng dụng Spring Boot đáng tin cậy. Các điểm chính:

  • Tuân theo test pyramid: Nhiều unit tests, vừa integration tests, ít E2E tests
  • Sử dụng JUnit 5 và Mockito: Frameworks testing chuẩn cho Java
  • MockMvc cho API testing: Test controllers không cần start server
  • TestContainers cho integration: Database instances thực
  • Thực hành TDD: Viết tests trước, rồi implementation
  • Hướng tới high coverage: Nhưng tập trung vào tests có ý nghĩa

Code được test tốt dễ maintain, refactor và mở rộng hơn.

Tài Liệu Tham Khảo

📬 Đăng ký nhận bản tin

Nhận những bài viết mới nhất qua email mỗi tuần. Không spam, hủy đăng ký bất cứ lúc nào.

Chúng tôi tôn trọng quyền riêng tư của bạn. Hủy đăng ký bất cứ lúc nào.

💬 Comments

Đăng nhập để bình luận

Chúng tôi sẽ không đăng bất cứ điều gì mà không có sự cho phép của bạn.