搜 索

谈单元测试怎么写(java篇)

  • 279阅读
  • 2022年10月16日
  • 0评论
首页 / 编程 / 正文
本文属于《从入门到放弃》系列,但这次我要劝你别放弃,因为不写单元测试的代码,就像没系安全带的汽车——平时感觉挺自由,出事了直接起飞。

前言:一个没写单元测试的程序员的一天

早上9点,产品经理说:"这个需求很简单,就改一行代码。"

你信了,改了。

下午3点,线上告警疯狂轰炸你的钉钉。

晚上11点,你还在公司排查为什么改了A模块,B模块炸了,C模块也顺便炸了。

你开始怀疑人生:"我明明只改了一行啊?!"

这,就是没有单元测试的代价。

如果你写了单元测试,这一行代码改完,跑一下测试,3秒钟就知道有没有搞砸。而不是等到线上用户帮你测试。

用户测试的成本,那可是真金白银的赔偿啊兄弟!

一、单元测试是什么?不就是写代码测代码吗?

没错,单元测试就是用代码测试代码

但很多人对单元测试有误解,以为就是启动整个Spring容器,然后调一下接口看看返回值对不对。那叫集成测试,不叫单元测试。

来看一张图,理解一下测试金字塔:

graph TB subgraph 测试金字塔 E2E["🔺 E2E测试
数量最少 | 成本最高 | 速度最慢"] INT["🔶 集成测试
数量适中 | 成本中等 | 速度中等"] UNIT["🟢 单元测试
数量最多 | 成本最低 | 速度最快"] end E2E --> INT INT --> UNIT style E2E fill:#ff6b6b,stroke:#333,stroke-width:2px,color:#fff style INT fill:#feca57,stroke:#333,stroke-width:2px,color:#333 style UNIT fill:#1dd1a1,stroke:#333,stroke-width:2px,color:#fff

单元测试的特点:

特性说明
毫秒级执行,跑几百个测试也就几秒钟
独立不依赖数据库、Redis、MQ等外部服务
可重复跑一万次结果都一样
自动化CI/CD流水线自动跑

单元测试测的是"单元",在Java里,通常指的是一个方法,最多是一个类。

二、Spring Boot测试全家桶

Spring Boot贴心地给我们准备了一套测试工具,加个依赖就能用:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>

这一个依赖,包含了以下豪华套餐:

graph LR subgraph spring-boot-starter-test JUnit5["JUnit 5
测试框架本体"] Mockito["Mockito
Mock神器"] AssertJ["AssertJ
断言增强"] Hamcrest["Hamcrest
匹配器"] JSONPath["JSONPath
JSON断言"] Spring["Spring Test
Spring测试支持"] end style JUnit5 fill:#6c5ce7,color:#fff style Mockito fill:#00b894,color:#fff style AssertJ fill:#0984e3,color:#fff style Hamcrest fill:#fdcb6e,color:#333 style JSONPath fill:#e17055,color:#fff style Spring fill:#00cec9,color:#fff

2.1 JUnit 5 基础写法

先来个最简单的例子热热身:

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.DisplayName;
import static org.junit.jupiter.api.Assertions.*;

class CalculatorTest {

    @Test
    @DisplayName("1 + 1 应该等于 2,如果不等于,那数学就完蛋了")
    void testAdd() {
        Calculator calculator = new Calculator();
        int result = calculator.add(1, 1);
        assertEquals(2, result);
    }
    
    @Test
    @DisplayName("除以0应该抛异常,不然宇宙会爆炸")
    void testDivideByZero() {
        Calculator calculator = new Calculator();
        assertThrows(ArithmeticException.class, () -> {
            calculator.divide(10, 0);
        });
    }
}

JUnit 5常用注解速查表:

注解作用使用场景
@Test标记测试方法每个测试方法都要加
@DisplayName给测试起个人话的名字让测试报告更易读
@BeforeEach每个测试前执行初始化测试数据
@AfterEach每个测试后执行清理资源
@BeforeAll所有测试前执行一次重量级初始化
@Disabled禁用测试临时跳过某个测试
@ParameterizedTest参数化测试同一逻辑不同参数

2.2 参数化测试:一次写完,多组数据

假设你要测试一个判断成年的方法,难道要写18个测试?不用,参数化测试安排上:

import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.ValueSource;

class AgeValidatorTest {

    @ParameterizedTest
    @DisplayName("成年人判断")
    @CsvSource({
        "17, false",
        "18, true",
        "19, true",
        "100, true",
        "0, false"
    })
    void testIsAdult(int age, boolean expected) {
        AgeValidator validator = new AgeValidator();
        assertEquals(expected, validator.isAdult(age));
    }
    
    @ParameterizedTest
    @DisplayName("这些年龄都应该是成年人")
    @ValueSource(ints = {18, 25, 30, 65, 100})
    void testAdultAges(int age) {
        AgeValidator validator = new AgeValidator();
        assertTrue(validator.isAdult(age));
    }
}

三、Mock:假装有对象

在真实项目中,你的Service不可能是孤独的,它依赖Repository、依赖其他Service、依赖第三方API...

但单元测试要"隔离",怎么办?

Mock!用假的对象替代真的依赖。

这就像拍电影,男主角要从大楼跳下去,你不能真的让他跳吧?找个替身(Mock)啊!

sequenceDiagram participant Test as 测试代码 participant Service as UserService participant Mock as Mock的Repository participant Real as 真实数据库 Note over Test,Real: 单元测试时 Test->>Service: 调用 getUserById(1) Service->>Mock: 调用 findById(1) Mock-->>Service: 返回预设的假数据 Service-->>Test: 返回结果 Note over Real: 数据库根本没启动!

3.1 Mockito基础用法

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.mockito.Mockito.*;
import static org.junit.jupiter.api.Assertions.*;

@ExtendWith(MockitoExtension.class)  // 启用Mockito
class UserServiceTest {

    @Mock  // 这是个假的!
    private UserRepository userRepository;
    
    @Mock
    private EmailService emailService;
    
    @InjectMocks  // 把上面的假货注入到这里
    private UserService userService;
    
    @Test
    @DisplayName("根据ID查用户 - 用户存在")
    void testGetUserById_Found() {
        // Given: 准备数据,告诉Mock对象该怎么表演
        User fakeUser = new User(1L, "张三", "zhangsan@test.com");
        when(userRepository.findById(1L)).thenReturn(Optional.of(fakeUser));
        
        // When: 执行被测方法
        User result = userService.getUserById(1L);
        
        // Then: 验证结果
        assertNotNull(result);
        assertEquals("张三", result.getName());
        assertEquals("zhangsan@test.com", result.getEmail());
        
        // 验证Mock对象的方法确实被调用了
        verify(userRepository, times(1)).findById(1L);
    }
    
    @Test
    @DisplayName("根据ID查用户 - 用户不存在,应该抛异常")
    void testGetUserById_NotFound() {
        // Given
        when(userRepository.findById(999L)).thenReturn(Optional.empty());
        
        // When & Then
        assertThrows(UserNotFoundException.class, () -> {
            userService.getUserById(999L);
        });
    }
}

3.2 Mock的各种骚操作

@Test
void testMockitoFeatures() {
    // 1. 返回固定值
    when(userRepository.findById(1L)).thenReturn(Optional.of(user));
    
    // 2. 抛异常
    when(userRepository.findById(-1L)).thenThrow(new RuntimeException("ID不能为负数!"));
    
    // 3. 根据参数动态返回(Answer)
    when(userRepository.findById(anyLong())).thenAnswer(invocation -> {
        Long id = invocation.getArgument(0);
        if (id > 0) {
            return Optional.of(new User(id, "用户" + id, "user" + id + "@test.com"));
        }
        return Optional.empty();
    });
    
    // 4. 连续调用返回不同值
    when(userRepository.count())
        .thenReturn(1L)   // 第一次调用返回1
        .thenReturn(2L)   // 第二次调用返回2
        .thenReturn(3L);  // 第三次及以后返回3
    
    // 5. void方法抛异常
    doThrow(new RuntimeException("发送失败"))
        .when(emailService).sendEmail(anyString(), anyString());
    
    // 6. 验证方法调用次数
    verify(userRepository, times(1)).findById(1L);
    verify(userRepository, never()).deleteById(anyLong());
    verify(emailService, atLeast(2)).sendEmail(anyString(), anyString());
    
    // 7. 捕获参数
    ArgumentCaptor<User> captor = ArgumentCaptor.forClass(User.class);
    verify(userRepository).save(captor.capture());
    User savedUser = captor.getValue();
    assertEquals("张三", savedUser.getName());
}

3.3 @MockBean vs @Mock

这俩长得像,但用的地方不一样:

graph TB subgraph 纯单元测试 M["@Mock + @InjectMocks"] M1["不启动Spring容器"] M2["速度快,毫秒级"] M3["测试Service层逻辑"] end subgraph Spring集成测试 MB["@MockBean"] MB1["需要启动Spring容器"] MB2["速度慢,秒级"] MB3["测试Controller或需要Spring特性时"] end M --> M1 --> M2 --> M3 MB --> MB1 --> MB2 --> MB3 style M fill:#1dd1a1,color:#fff style MB fill:#feca57,color:#333

四、Service层测试实战

来个实际的例子,假设我们有个订单服务:

// 被测试的Service
@Service
@RequiredArgsConstructor
public class OrderService {
    
    private final OrderRepository orderRepository;
    private final ProductService productService;
    private final InventoryService inventoryService;
    private final PaymentService paymentService;
    
    @Transactional
    public Order createOrder(CreateOrderRequest request) {
        // 1. 检查商品是否存在
        Product product = productService.getProductById(request.getProductId());
        if (product == null) {
            throw new ProductNotFoundException("商品不存在");
        }
        
        // 2. 检查库存
        if (!inventoryService.checkStock(request.getProductId(), request.getQuantity())) {
            throw new InsufficientStockException("库存不足");
        }
        
        // 3. 计算金额
        BigDecimal totalAmount = product.getPrice()
            .multiply(BigDecimal.valueOf(request.getQuantity()));
        
        // 4. 创建订单
        Order order = Order.builder()
            .userId(request.getUserId())
            .productId(request.getProductId())
            .quantity(request.getQuantity())
            .totalAmount(totalAmount)
            .status(OrderStatus.PENDING)
            .createTime(LocalDateTime.now())
            .build();
        
        return orderRepository.save(order);
    }
}

测试代码:

@ExtendWith(MockitoExtension.class)
class OrderServiceTest {

    @Mock
    private OrderRepository orderRepository;
    
    @Mock
    private ProductService productService;
    
    @Mock
    private InventoryService inventoryService;
    
    @Mock
    private PaymentService paymentService;
    
    @InjectMocks
    private OrderService orderService;
    
    private CreateOrderRequest validRequest;
    private Product testProduct;
    
    @BeforeEach
    void setUp() {
        validRequest = CreateOrderRequest.builder()
            .userId(1L)
            .productId(100L)
            .quantity(2)
            .build();
            
        testProduct = Product.builder()
            .id(100L)
            .name("机械键盘")
            .price(new BigDecimal("299.00"))
            .build();
    }
    
    @Test
    @DisplayName("创建订单 - 正常流程")
    void testCreateOrder_Success() {
        // Given
        when(productService.getProductById(100L)).thenReturn(testProduct);
        when(inventoryService.checkStock(100L, 2)).thenReturn(true);
        when(orderRepository.save(any(Order.class))).thenAnswer(invocation -> {
            Order order = invocation.getArgument(0);
            order.setId(1L);  // 模拟数据库生成ID
            return order;
        });
        
        // When
        Order result = orderService.createOrder(validRequest);
        
        // Then
        assertNotNull(result);
        assertEquals(1L, result.getUserId());
        assertEquals(100L, result.getProductId());
        assertEquals(2, result.getQuantity());
        assertEquals(new BigDecimal("598.00"), result.getTotalAmount());  // 299 * 2
        assertEquals(OrderStatus.PENDING, result.getStatus());
        
        // 验证调用链
        verify(productService).getProductById(100L);
        verify(inventoryService).checkStock(100L, 2);
        verify(orderRepository).save(any(Order.class));
    }
    
    @Test
    @DisplayName("创建订单 - 商品不存在")
    void testCreateOrder_ProductNotFound() {
        // Given
        when(productService.getProductById(100L)).thenReturn(null);
        
        // When & Then
        ProductNotFoundException exception = assertThrows(
            ProductNotFoundException.class,
            () -> orderService.createOrder(validRequest)
        );
        
        assertEquals("商品不存在", exception.getMessage());
        
        // 验证后续方法没有被调用
        verify(inventoryService, never()).checkStock(anyLong(), anyInt());
        verify(orderRepository, never()).save(any());
    }
    
    @Test
    @DisplayName("创建订单 - 库存不足")
    void testCreateOrder_InsufficientStock() {
        // Given
        when(productService.getProductById(100L)).thenReturn(testProduct);
        when(inventoryService.checkStock(100L, 2)).thenReturn(false);
        
        // When & Then
        assertThrows(InsufficientStockException.class, () -> {
            orderService.createOrder(validRequest);
        });
        
        verify(orderRepository, never()).save(any());
    }
    
    @Test
    @DisplayName("创建订单 - 金额计算正确性(多组数据)")
    @ParameterizedTest
    @CsvSource({
        "1, 299.00, 299.00",
        "2, 299.00, 598.00",
        "10, 99.99, 999.90",
        "100, 0.01, 1.00"
    })
    void testCreateOrder_AmountCalculation(int quantity, String price, String expectedTotal) {
        // Given
        testProduct.setPrice(new BigDecimal(price));
        validRequest.setQuantity(quantity);
        
        when(productService.getProductById(100L)).thenReturn(testProduct);
        when(inventoryService.checkStock(anyLong(), anyInt())).thenReturn(true);
        when(orderRepository.save(any(Order.class))).thenAnswer(i -> i.getArgument(0));
        
        // When
        Order result = orderService.createOrder(validRequest);
        
        // Then
        assertEquals(new BigDecimal(expectedTotal), result.getTotalAmount());
    }
}

五、Controller层测试

Controller层测试用MockMvc,可以模拟HTTP请求,但不需要启动真正的服务器:

@WebMvcTest(UserController.class)  // 只加载Controller层
class UserControllerTest {

    @Autowired
    private MockMvc mockMvc;
    
    @MockBean  // 注意这里用@MockBean,因为需要Spring容器
    private UserService userService;
    
    @Autowired
    private ObjectMapper objectMapper;
    
    @Test
    @DisplayName("GET /users/{id} - 查询用户成功")
    void testGetUser_Success() throws Exception {
        // Given
        User user = new User(1L, "张三", "zhangsan@test.com");
        when(userService.getUserById(1L)).thenReturn(user);
        
        // When & Then
        mockMvc.perform(get("/users/1")
                .contentType(MediaType.APPLICATION_JSON))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.id").value(1))
            .andExpect(jsonPath("$.name").value("张三"))
            .andExpect(jsonPath("$.email").value("zhangsan@test.com"));
    }
    
    @Test
    @DisplayName("GET /users/{id} - 用户不存在返回404")
    void testGetUser_NotFound() throws Exception {
        // Given
        when(userService.getUserById(999L))
            .thenThrow(new UserNotFoundException("用户不存在"));
        
        // When & Then
        mockMvc.perform(get("/users/999"))
            .andExpect(status().isNotFound())
            .andExpect(jsonPath("$.message").value("用户不存在"));
    }
    
    @Test
    @DisplayName("POST /users - 创建用户")
    void testCreateUser() throws Exception {
        // Given
        CreateUserRequest request = new CreateUserRequest("李四", "lisi@test.com");
        User createdUser = new User(2L, "李四", "lisi@test.com");
        when(userService.createUser(any(CreateUserRequest.class))).thenReturn(createdUser);
        
        // When & Then
        mockMvc.perform(post("/users")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(request)))
            .andExpect(status().isCreated())
            .andExpect(jsonPath("$.id").value(2))
            .andExpect(jsonPath("$.name").value("李四"));
    }
    
    @Test
    @DisplayName("POST /users - 参数校验失败")
    void testCreateUser_ValidationFailed() throws Exception {
        // Given: 空的name
        CreateUserRequest request = new CreateUserRequest("", "invalid-email");
        
        // When & Then
        mockMvc.perform(post("/users")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(request)))
            .andExpect(status().isBadRequest());
    }
}

六、Repository层测试

Repository层用@DataJpaTest,它会自动配置内存数据库(H2):

@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) // 如果要用真实数据库配置
class UserRepositoryTest {

    @Autowired
    private UserRepository userRepository;
    
    @Autowired
    private TestEntityManager entityManager;
    
    @Test
    @DisplayName("测试自定义查询方法")
    void testFindByEmail() {
        // Given
        User user = User.builder()
            .name("测试用户")
            .email("test@example.com")
            .build();
        entityManager.persistAndFlush(user);
        
        // When
        Optional<User> found = userRepository.findByEmail("test@example.com");
        
        // Then
        assertTrue(found.isPresent());
        assertEquals("测试用户", found.get().getName());
    }
    
    @Test
    @DisplayName("测试复杂查询")
    void testFindActiveUsersByNameContaining() {
        // Given
        entityManager.persist(new User("张三丰", "zsf@test.com", true));
        entityManager.persist(new User("张三", "zs@test.com", true));
        entityManager.persist(new User("张三疯", "zsf2@test.com", false)); // 非活跃
        entityManager.persist(new User("李四", "ls@test.com", true));
        entityManager.flush();
        
        // When
        List<User> users = userRepository.findByNameContainingAndActiveTrue("张三");
        
        // Then
        assertEquals(2, users.size());
        assertTrue(users.stream().allMatch(User::isActive));
    }
}

七、测试覆盖率:别让你的代码裸奔

测试写了多少?够不够?用覆盖率说话。

推荐使用JaCoCo:

<plugin>
    <groupId>org.jacoco</groupId>
    <artifactId>jacoco-maven-plugin</artifactId>
    <version>0.8.10</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>

运行mvn test后,在target/site/jacoco/index.html查看报告。

pie showData title 理想的测试覆盖率分布 "行覆盖率 > 80%" : 80 "分支覆盖率 > 70%" : 70 "方法覆盖率 > 90%" : 90

覆盖率的真相

  • 80%覆盖率是个不错的目标
  • 100%覆盖率是个美丽的谎言(有些代码真的不用测)
  • 覆盖率高不代表测试质量高(你可以写一堆不带断言的测试)

八、测试最佳实践

8.1 命名规范

// ❌ 不好的命名
@Test
void test1() { }

@Test
void testUser() { }

// ✅ 好的命名:被测方法_场景_期望结果
@Test
void getUserById_WhenUserExists_ShouldReturnUser() { }

@Test
void createOrder_WhenStockInsufficient_ShouldThrowException() { }

// ✅ 或者用中文@DisplayName
@Test
@DisplayName("创建订单 - 库存不足时应抛出异常")
void testCreateOrderWithInsufficientStock() { }

8.2 测试结构:Given-When-Then

@Test
void testTransferMoney() {
    // Given: 准备测试数据和Mock行为
    Account from = new Account(1L, new BigDecimal("1000"));
    Account to = new Account(2L, new BigDecimal("500"));
    when(accountRepository.findById(1L)).thenReturn(Optional.of(from));
    when(accountRepository.findById(2L)).thenReturn(Optional.of(to));
    
    // When: 执行被测试的方法
    accountService.transfer(1L, 2L, new BigDecimal("300"));
    
    // Then: 验证结果
    assertEquals(new BigDecimal("700"), from.getBalance());
    assertEquals(new BigDecimal("800"), to.getBalance());
    verify(accountRepository, times(2)).save(any(Account.class));
}

8.3 一个测试只测一件事

// ❌ 测太多东西
@Test
void testUserService() {
    // 测创建
    User created = userService.create(request);
    assertNotNull(created);
    
    // 测查询
    User found = userService.getById(created.getId());
    assertEquals(created, found);
    
    // 测更新
    userService.update(created.getId(), updateRequest);
    // ...
    
    // 测删除
    userService.delete(created.getId());
    // ...
}

// ✅ 每个测试专注一个场景
@Test void create_ValidRequest_Success() { }
@Test void create_DuplicateEmail_ThrowException() { }
@Test void getById_Exists_ReturnUser() { }
@Test void getById_NotExists_ThrowException() { }

8.4 测试数据构建器模式

当测试数据复杂时,用Builder模式:

public class UserTestDataBuilder {
    private Long id = 1L;
    private String name = "默认用户";
    private String email = "default@test.com";
    private boolean active = true;
    
    public static UserTestDataBuilder aUser() {
        return new UserTestDataBuilder();
    }
    
    public UserTestDataBuilder withId(Long id) {
        this.id = id;
        return this;
    }
    
    public UserTestDataBuilder withName(String name) {
        this.name = name;
        return this;
    }
    
    public UserTestDataBuilder inactive() {
        this.active = false;
        return this;
    }
    
    public User build() {
        return new User(id, name, email, active);
    }
}

// 使用
User activeUser = aUser().withName("张三").build();
User inactiveUser = aUser().withName("李四").inactive().build();

九、常见问题QA

Q1: 测试代码也需要维护吗?

是的!测试代码是一等公民。烂测试比没测试更可怕,因为它会给你虚假的安全感。

Q2: 什么代码不需要测试?

  • getter/setter(除非有特殊逻辑)
  • 简单的委托方法
  • 配置类
  • 纯粹的数据传输对象(DTO)

Q3: Mock太多是不是有问题?

如果一个类需要Mock 5个以上依赖,可能说明这个类职责太多了,考虑重构。

Q4: 测试要不要测私有方法?

不要。测试应该通过公有方法来覆盖私有方法。如果私有方法很难被覆盖,可能需要重构。

十、总结

graph LR A[写代码] --> B{有测试吗?} B -->|没有| C[裸奔中...] B -->|有| D[安心改代码] C --> E[改代码就心慌] D --> F[CI自动验证] E --> G[线上出Bug] F --> H[早点下班] G --> I[半夜改Bug] style C fill:#ff6b6b,color:#fff style D fill:#1dd1a1,color:#fff style G fill:#ff6b6b,color:#fff style H fill:#1dd1a1,color:#fff style I fill:#ff6b6b,color:#fff

写单元测试确实会让你多花一些时间,但这是投资,不是成本

它会在未来某个深夜,当你需要重构一段祖传代码时,默默地保护你。

它会在CI流水线上,帮你拦住那些"改了一行就爆炸"的提交。

它会让你成为一个更好的程序员,因为可测试的代码,往往也是设计良好的代码。

所以,从今天开始,让我们一起告别"能跑就行",拥抱"敢改就改"!

评论区
暂无评论
avatar