SpringBoot单元测试全面解析

SpringBoot单元测试全面解析

1. SpringBoot单元测试是什么?

SpringBoot单元测试是针对SpringBoot应用程序中的单个组件(如类、方法)进行的独立测试。它使用专门的测试框架来验证代码的正确性,通常不依赖外部资源如数据库、网络服务等。

2. 单元测试的作用

  • 验证代码正确性:确保每个单元按预期工作
  • 早期发现问题:在开发阶段发现bug
  • 支持重构:保证重构后功能不变
  • 文档作用:作为代码使用示例
  • 设计改进:促使编写可测试的、松耦合的代码

3. 为什么需要写单元测试?

// 没有单元测试的问题示例
public class Calculator {
    public int divide(int a, int b) {
        return a / b; // 潜在的除零异常
    }
}

// 有单元测试可以提前发现问题
@Test
void testDivide_ByZero_ShouldThrowException() {
    Calculator calculator = new Calculator();
    assertThrows(ArithmeticException.class, () -> calculator.divide(10, 0));
}

4. 单元测试的标准写法

AAA模式(Arrange-Act-Assert)

@Test
void testMethod_Scenario_ExpectedResult() {
    // Arrange - 准备测试数据和环境
    MyService service = new MyService();
    String input = "test";
    
    // Act - 执行被测试的方法
    String result = service.process(input);
    
    // Assert - 验证结果
    assertEquals("PROCESSED_TEST", result);
}

5. 单元测试的目标

  • 快速执行:测试应该快速运行
  • 独立隔离:测试之间不相互依赖
  • 可重复:每次运行结果一致
  • 自验证:测试自动判断通过或失败
  • 及时编写:在开发代码时同步编写

6. 私有方法测试建议

不推荐直接测试私有方法,因为:

  • 私有方法是实现细节,可能经常变化
  • 应该通过公共接口测试私有方法的功能
  • 如果私有方法很复杂,考虑提取到单独的类中

7. 单元测试疑难杂点

  • 外部依赖:使用Mock解决
  • 数据库操作:使用内存数据库或Mock
  • 静态方法:使用PowerMock或重构代码
  • 时间相关:使用固定时间或Clock类
  • 并发问题:需要专门的并发测试

8. 常用单元测试工具

工具用途
JUnit 5测试框架
MockitoMock框架
AssertJ流式断言
Testcontainers容器化测试
@SpringBootTest集成测试

9. 重要概念

  • Test Double:测试替身(Dummy, Fake, Stub, Mock, Spy)
  • Fixture:测试固定装置
  • Test Coverage:测试覆盖率
  • TDD:测试驱动开发
  • BDD:行为驱动开发

10. 综合性单元测试示例

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 org.springframework.boot.test.context.SpringBootTest;

import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.*;

/**
 * 用户服务单元测试示例
 * 演示完整的单元测试编写方法
 */
@ExtendWith(MockitoExtension.class) // 使用Mockito扩展
class UserServiceTest {

    @Mock
    private UserRepository userRepository; // 模拟用户仓库

    @Mock
    private EmailService emailService; // 模拟邮件服务

    @InjectMocks
    private UserService userService; // 被测试的服务,自动注入模拟依赖

    private User testUser;
    private User adminUser;

    /**
     * 每个测试方法执行前的准备工作
     */
    @BeforeEach
    void setUp() {
        // 准备测试数据
        testUser = User.builder()
                .id(1L)
                .username("testuser")
                .email("test@example.com")
                .password("encodedPassword")
                .status(UserStatus.ACTIVE)
                .createdAt(LocalDateTime.now())
                .build();

        adminUser = User.builder()
                .id(2L)
                .username("admin")
                .email("admin@example.com")
                .password("adminPassword")
                .role(UserRole.ADMIN)
                .status(UserStatus.ACTIVE)
                .createdAt(LocalDateTime.now())
                .build();
    }

    /**
     * 测试成功查找用户场景
     */
    @Test
    @DisplayName("根据ID查找用户 - 用户存在时应返回用户")
    void findById_WhenUserExists_ShouldReturnUser() {
        // Arrange - 准备测试数据和行为
        given(userRepository.findById(1L)).willReturn(Optional.of(testUser));

        // Act - 执行被测试的方法
        Optional<User> result = userService.findById(1L);

        // Assert - 验证结果
        assertThat(result).isPresent();
        assertThat(result.get().getUsername()).isEqualTo("testuser");
        assertThat(result.get().getEmail()).isEqualTo("test@example.com");

        // 验证模拟对象的交互
        verify(userRepository, times(1)).findById(1L);
    }

    /**
     * 测试用户不存在场景
     */
    @Test
    @DisplayName("根据ID查找用户 - 用户不存在时应返回空")
    void findById_WhenUserNotExists_ShouldReturnEmpty() {
        // Arrange
        given(userRepository.findById(anyLong())).willReturn(Optional.empty());

        // Act
        Optional<User> result = userService.findById(999L);

        // Assert
        assertThat(result).isEmpty();
        verify(userRepository).findById(999L);
    }

    /**
     * 测试创建用户成功场景
     */
    @Test
    @DisplayName("创建用户 - 有效用户数据时应成功创建并发送欢迎邮件")
    void createUser_WithValidData_ShouldCreateUserAndSendEmail() {
        // Arrange
        UserCreateRequest request = new UserCreateRequest("newuser", "new@example.com", "password123");
        User savedUser = User.builder()
                .id(3L)
                .username("newuser")
                .email("new@example.com")
                .password("encodedPassword")
                .status(UserStatus.ACTIVE)
                .build();

        given(userRepository.existsByUsername("newuser")).willReturn(false);
        given(userRepository.existsByEmail("new@example.com")).willReturn(false);
        given(userRepository.save(any(User.class))).willReturn(savedUser);
        doNothing().when(emailService).sendWelcomeEmail(anyString(), anyString());

        // Act
        User result = userService.createUser(request);

        // Assert
        assertThat(result).isNotNull();
        assertThat(result.getId()).isEqualTo(3L);
        assertThat(result.getUsername()).isEqualTo("newuser");
        assertThat(result.getStatus()).isEqualTo(UserStatus.ACTIVE);

        // 验证正确的交互顺序和次数
        verify(userRepository).existsByUsername("newuser");
        verify(userRepository).existsByEmail("new@example.com");
        verify(userRepository).save(any(User.class));
        verify(emailService).sendWelcomeEmail("new@example.com", "newuser");
    }

    /**
     * 测试创建用户时用户名已存在场景
     */
    @Test
    @DisplayName("创建用户 - 用户名已存在时应抛出异常")
    void createUser_WhenUsernameExists_ShouldThrowException() {
        // Arrange
        UserCreateRequest request = new UserCreateRequest("existinguser", "new@example.com", "password123");
        given(userRepository.existsByUsername("existinguser")).willReturn(true);

        // Act & Assert
        assertThatThrownBy(() -> userService.createUser(request))
                .isInstanceOf(DuplicateUserException.class)
                .hasMessage("用户名已存在: existinguser");

        // 验证保存方法没有被调用
        verify(userRepository, never()).save(any(User.class));
        verify(emailService, never()).sendWelcomeEmail(anyString(), anyString());
    }

    /**
     * 测试批量操作用户
     */
    @Test
    @DisplayName("批量激活用户 - 应成功激活所有用户")
    void activateUsers_WithUserList_ShouldActivateAllUsers() {
        // Arrange
        List<Long> userIds = Arrays.asList(1L, 2L, 3L);
        List<User> users = Arrays.asList(testUser, adminUser);
        
        given(userRepository.findAllById(userIds)).willReturn(users);
        given(userRepository.saveAll(anyList())).willReturn(users);

        // Act
        List<User> result = userService.activateUsers(userIds);

        // Assert
        assertThat(result).hasSize(2);
        assertThat(result).allMatch(user -> user.getStatus() == UserStatus.ACTIVE);
        
        verify(userRepository).findAllById(userIds);
        verify(userRepository).saveAll(anyList());
    }

    /**
     * 测试边界情况 - 空输入
     */
    @Test
    @DisplayName("查找用户 - 空ID时应抛出异常")
    void findById_WithNullId_ShouldThrowException() {
        // Act & Assert
        assertThatThrownBy(() -> userService.findById(null))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessage("用户ID不能为空");
    }

    /**
     * 测试性能相关 - 超时测试
     */
    @Test
    @DisplayName("处理用户数据 - 应在指定时间内完成")
    void processUserData_ShouldCompleteWithinTimeout() {
        // 这个测试会验证方法执行不超过2秒
        assertTimeoutPreemptively(java.time.Duration.ofSeconds(2), () -> {
            userService.processUserData(testUser);
        });
    }

    /**
     * 测试异常处理
     */
    @Test
    @DisplayName("删除用户 - 用户不存在时应抛出异常")
    void deleteUser_WhenUserNotExists_ShouldThrowException() {
        // Arrange
        given(userRepository.findById(999L)).willReturn(Optional.empty());

        // Act & Assert
        assertThatThrownBy(() -> userService.deleteUser(999L))
                .isInstanceOf(UserNotFoundException.class)
                .hasMessage("用户不存在: 999");
    }

    /**
     * 使用BDD风格测试(行为驱动开发)
     */
    @Test
    @DisplayName("BDD风格 - 用户登录成功")
    void givenValidCredentials_whenLogin_thenReturnUser() {
        // Given - 准备阶段
        String username = "testuser";
        String password = "password123";
        given(userRepository.findByUsernameAndPassword(username, password))
                .willReturn(Optional.of(testUser));

        // When - 执行阶段
        Optional<User> result = userService.login(username, password);

        // Then - 验证阶段
        assertThat(result).isPresent();
        assertThat(result.get().getUsername()).isEqualTo(username);
    }
}

// 支持类定义
class User {
    // 用户实体类
    private Long id;
    private String username;
    private String email;
    private String password;
    private UserStatus status;
    private UserRole role;
    private LocalDateTime createdAt;
    
    // builder模式
    public static UserBuilder builder() { return new UserBuilder(); }
    
    // getters and setters
}

class UserBuilder {
    private User user = new User();
    
    public UserBuilder id(Long id) { /* ... */ return this; }
    public UserBuilder username(String username) { /* ... */ return this; }
    public User build() { return user; }
}

enum UserStatus { ACTIVE, INACTIVE, SUSPENDED }
enum UserRole { USER, ADMIN }

class UserCreateRequest {
    private String username;
    private String email;
    private String password;
    // constructor, getters
}

// 自定义异常类
class DuplicateUserException extends RuntimeException {
    public DuplicateUserException(String message) { super(message); }
}

class UserNotFoundException extends RuntimeException {
    public UserNotFoundException(String message) { super(message); }
}

关键要点总结

  1. 测试命名规范:方法名_场景_预期结果
  2. 单一职责:每个测试只测试一个场景
  3. 使用Mock:隔离外部依赖
  4. 全面覆盖:包括正常、异常、边界情况
  5. 及时维护:随着代码变更更新测试
  6. 避免过度测试:关注业务逻辑,而不是实现细节

这个示例展示了SpringBoot单元测试的最佳实践,包括测试结构、Mock使用、异常测试、边界条件测试等,可以作为实际项目中的参考模板。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

龙茶清欢

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值