65-单元测试详解

0 阅读7分钟

单元测试详解

本章导读

单元测试是保证代码质量的第一道防线。良好的单元测试能在毫秒级发现代码缺陷,为重构提供安全保障,同时也是一种鲜活的文档。本章将以JUnit 5和Mockito为核心,系统讲解测试编写技巧、参数化测试、Mock模拟等高级用法,帮助你编写高质量、可维护的测试代码。

学习目标

  • 目标1:掌握JUnit 5的核心特性,包括生命周期、断言、参数化测试
  • 目标2:熟练使用Mockito进行依赖模拟和行为验证
  • 目标3:能够在Spring Boot项目中编写分层测试并生成覆盖率报告

前置知识:Java基础语法、面向对象编程、Maven/Gradle基础

阅读时长:约 25 分钟

一、知识概述

单元测试是软件测试的基础层级,针对程序中最小可测试单元进行验证。良好的单元测试不仅能保证代码质量,还能提高开发效率、降低维护成本。JUnit 5作为Java生态最主流的测试框架,提供了强大的测试能力。

本文将深入分析单元测试的核心概念,包括JUnit 5新特性、Mockito模拟框架、测试覆盖率、参数化测试等内容,并提供实战代码示例。

单元测试的核心价值

  1. 快速反馈:毫秒级发现代码缺陷
  2. 重构保障:修改代码时确保功能不变
  3. 文档作用:测试即文档,展示API用法
  4. 设计改进:难测试的代码往往设计有问题

二、知识点详细讲解

2.1 JUnit 5核心特性

测试生命周期
package com.example.test;

import org.junit.jupiter.api.*;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.junit.jupiter.MockitoExtension;

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

/**
 * JUnit 5测试示例
 */
@DisplayName("计算器测试")
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
@ExtendWith(MockitoExtension.class)
public class CalculatorTest {
    
    private Calculator calculator;
    
    // ==================== 生命周期方法 ====================
    
    @BeforeAll
    static void beforeAll() {
        System.out.println("=== 所有测试开始前执行一次 ===");
    }
    
    @BeforeEach
    void setUp() {
        System.out.println("每个测试方法前执行");
        calculator = new Calculator();
    }
    
    @AfterEach
    void tearDown() {
        System.out.println("每个测试方法后执行");
        calculator = null;
    }
    
    @AfterAll
    static void afterAll() {
        System.out.println("=== 所有测试结束后执行一次 ===");
    }
    
    // ==================== 基础断言 ====================
    
    @Test
    @Order(1)
    @DisplayName("加法测试")
    void testAdd() {
        // 等于断言
        assertEquals(5, calculator.add(2, 3), "2 + 3 应该等于 5");
        
        // 不等于断言
        assertNotEquals(6, calculator.add(2, 3));
        
        // 布尔断言
        assertTrue(calculator.add(2, 3) > 0);
        assertFalse(calculator.add(-2, -3) > 0);
    }
    
    @Test
    @Order(2)
    @DisplayName("除法测试")
    void testDivide() {
        // 浮点数断言(带精度)
        assertEquals(0.333, calculator.divide(1, 3), 0.001);
        
        // 异常断言
        assertThrows(ArithmeticException.class, () -> {
            calculator.divide(10, 0);
        });
    }
    
    // ==================== 分组断言 ====================
    
    @Test
    @DisplayName("组合测试")
    void testCombinedAssertions() {
        assertAll("计算器操作",
            () -> assertEquals(5, calculator.add(2, 3)),
            () -> assertEquals(6, calculator.subtract(9, 3)),
            () -> assertEquals(12, calculator.multiply(3, 4)),
            () -> assertEquals(2, calculator.divide(6, 3))
        );
    }
    
    // ==================== 超时测试 ====================
    
    @Test
    @Timeout(1) // 1秒超时
    @DisplayName("超时测试")
    void testTimeout() {
        // 如果执行超过1秒,测试失败
        assertTimeout(java.time.Duration.ofMillis(500), () -> {
            Thread.sleep(100);
        });
    }
    
    // ==================== 重复测试 ====================
    
    @RepeatedTest(5)
    @DisplayName("重复测试")
    void testRepeated(RepetitionInfo info) {
        int result = calculator.add(1, 1);
        assertEquals(2, result);
        System.out.println("第 " + info.getCurrentRepetition() + " 次执行");
    }
    
    // ==================== 嵌套测试 ====================
    
    @Nested
    @DisplayName("减法测试组")
    class SubtractTest {
        
        @Test
        @DisplayName("正数减法")
        void testPositiveSubtract() {
            assertEquals(5, calculator.subtract(10, 5));
        }
        
        @Test
        @DisplayName("负数减法")
        void testNegativeSubtract() {
            assertEquals(-5, calculator.subtract(5, 10));
        }
    }
}

/**
 * 计算器类(被测对象)
 */
class Calculator {
    
    public int add(int a, int b) {
        return a + b;
    }
    
    public int subtract(int a, int b) {
        return a - b;
    }
    
    public int multiply(int a, int b) {
        return a * b;
    }
    
    public double divide(int a, int b) {
        if (b == 0) {
            throw new ArithmeticException("除数不能为0");
        }
        return (double) a / b;
    }
}

2.2 参数化测试

package com.example.test;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.*;

import java.util.stream.Stream;

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

/**
 * 参数化测试示例
 */
@DisplayName("参数化测试")
public class ParameterizedTests {
    
    /**
     * 简单参数化测试
     */
    @ParameterizedTest
    @ValueSource(ints = {1, 2, 3, 4, 5})
    @DisplayName("使用@ValueSource")
    void testWithValueSource(int number) {
        assertTrue(number > 0);
    }
    
    /**
     * 多参数测试
     */
    @ParameterizedTest
    @CsvSource({
        "1, 1, 2",
        "2, 3, 5",
        "10, 20, 30"
    })
    @DisplayName("使用@CsvSource")
    void testWithCsvSource(int a, int b, int expected) {
        assertEquals(expected, a + b);
    }
    
    /**
     * CSV文件参数化
     */
    @ParameterizedTest
    @CsvFileSource(resources = "/test-data.csv", numLinesToSkip = 1)
    @DisplayName("使用@CsvFileSource")
    void testWithCsvFileSource(String name, int age) {
        assertNotNull(name);
        assertTrue(age > 0);
    }
    
    /**
     * 方法源参数化
     */
    @ParameterizedTest
    @MethodSource("provideStringsForIsBlank")
    @DisplayName("使用@MethodSource")
    void testIsBlank(String input, boolean expected) {
        assertEquals(expected, input == null || input.trim().isEmpty());
    }
    
    static Stream<Arguments> provideStringsForIsBlank() {
        return Stream.of(
            Arguments.of("", true),
            Arguments.of("  ", true),
            Arguments.of(null, true),
            Arguments.of("abc", false),
            Arguments.of(" abc ", false)
        );
    }
    
    /**
     * 枚举参数化
     */
    @ParameterizedTest
    @EnumSource(value = DayOfWeek.class, names = {"SATURDAY", "SUNDAY"})
    @DisplayName("使用@EnumSource")
    void testWithEnumSource(DayOfWeek day) {
        assertTrue(day == DayOfWeek.SATURDAY || day == DayOfWeek.SUNDAY);
    }
    
    /**
     * 参数转换
     */
    @ParameterizedTest
    @ValueSource(strings = {"2024-01-01", "2024-12-31"})
    @DisplayName("参数转换")
    void testWithImplicitConversion(java.time.LocalDate date) {
        assertEquals(2024, date.getYear());
    }
}

2.3 Mockito用法

package com.example.test;

import org.junit.jupiter.api.*;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.*;
import org.mockito.junit.jupiter.MockitoExtension;

import java.util.List;
import java.util.Optional;

import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;

/**
 * Mockito使用详解
 */
@ExtendWith(MockitoExtension.class)
@DisplayName("Mockito测试")
public class MockitoTests {
    
    @Mock
    private List<String> mockList;
    
    @Spy
    private List<String> spyList = new java.util.ArrayList<>();
    
    @Captor
    private ArgumentCaptor<String> argumentCaptor;
    
    @InjectMocks
    private UserService userService;
    
    @Mock
    private UserRepository userRepository;
    
    // ==================== Mock基础 ====================
    
    @Test
    @DisplayName("Mock对象基础用法")
    void testMockBasics() {
        // 1. 创建Mock对象
        List<String> mock = mock(List.class);
        
        // 2. 设置行为
        when(mock.size()).thenReturn(10);
        when(mock.get(0)).thenReturn("Hello");
        when(mock.get(1)).thenThrow(new IndexOutOfBoundsException());
        
        // 3. 使用Mock
        assertEquals(10, mock.size());
        assertEquals("Hello", mock.get(0));
        
        assertThrows(IndexOutOfBoundsException.class, () -> {
            mock.get(1);
        });
        
        // 4. 默认返回值
        assertNull(mock.get(100)); // 默认返回null
    }
    
    // ==================== 参数匹配 ====================
    
    @Test
    @DisplayName("参数匹配器")
    void testArgumentMatchers() {
        when(mockList.add(anyString())).thenReturn(true);
        when(mockList.get(anyInt())).thenReturn("Element");
        when(mockList.contains(argThat(s -> s.length() > 5))).thenReturn(true);
        
        assertTrue(mockList.add("test"));
        assertEquals("Element", mockList.get(0));
        assertTrue(mockList.contains("123456"));
    }
    
    // ==================== 验证行为 ====================
    
    @Test
    @DisplayName("验证调用")
    void testVerify() {
        // 调用方法
        mockList.add("one");
        mockList.add("two");
        mockList.add("three");
        mockList.clear();
        
        // 验证调用次数
        verify(mockList).add("one");
        verify(mockList, times(1)).add("one");
        verify(mockList, times(2)).add(anyString());
        verify(mockList, never()).add("four");
        verify(mockList, atLeast(2)).add(anyString());
        verify(mockList, atMost(3)).add(anyString());
        
        // 验证调用顺序
        InOrder inOrder = inOrder(mockList);
        inOrder.verify(mockList).add("one");
        inOrder.verify(mockList).add("two");
        
        // 验证没有更多交互
        verifyNoMoreInteractions(mockList);
    }
    
    // ==================== Spy(部分Mock) ====================
    
    @Test
    @DisplayName("Spy对象用法")
    void testSpy() {
        // Spy使用真实对象,可以调用真实方法
        spyList.add("one");
        spyList.add("two");
        
        assertEquals(2, spyList.size()); // 真实方法
        
        // 覆盖部分行为
        when(spyList.size()).thenReturn(100);
        assertEquals(100, spyList.size());
        
        // 注意:使用doReturn避免副作用
        doReturn("mocked").when(spyList).get(0);
        assertEquals("mocked", spyList.get(0));
    }
    
    // ==================== 异常模拟 ====================
    
    @Test
    @DisplayName("模拟异常")
    void testExceptionThrowing() {
        when(mockList.get(anyInt())).thenThrow(new RuntimeException("Error"));
        
        assertThrows(RuntimeException.class, () -> {
            mockList.get(0);
        });
    }
    
    // ==================== 参数捕获 ====================
    
    @Test
    @DisplayName("参数捕获器")
    void testArgumentCaptor() {
        mockList.add("captured");
        
        // 捕获参数
        verify(mockList).add(argumentCaptor.capture());
        
        assertEquals("captured", argumentCaptor.getValue());
    }
    
    // ==================== 回答(Answer) ====================
    
    @Test
    @DisplayName("自定义Answer")
    void testAnswer() {
        when(mockList.get(anyInt())).thenAnswer(invocation -> {
            int index = invocation.getArgument(0);
            return "Item " + index;
        });
        
        assertEquals("Item 0", mockList.get(0));
        assertEquals("Item 1", mockList.get(1));
    }
    
    // ==================== 综合示例 ====================
    
    @Test
    @DisplayName("UserService测试")
    void testUserService() {
        // 准备数据
        User user = new User(1L, "Alice", "alice@example.com");
        
        // 设置Mock行为
        when(userRepository.findById(1L)).thenReturn(Optional.of(user));
        when(userRepository.save(any(User.class))).thenReturn(user);
        
        // 调用方法
        User found = userService.findById(1L).orElse(null);
        assertNotNull(found);
        assertEquals("Alice", found.getName());
        
        // 验证调用
        verify(userRepository).findById(1L);
    }
}

// ==================== 实体类 ====================
@lombok.Data
@lombok.AllArgsConstructor
@lombok.NoArgsConstructor
class User {
    private Long id;
    private String name;
    private String email;
}

// ==================== Repository接口 ====================
interface UserRepository {
    Optional<User> findById(Long id);
    User save(User user);
    List<User> findAll();
}

// ==================== Service类 ====================
class UserService {
    
    private final UserRepository userRepository;
    
    public UserService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }
    
    public Optional<User> findById(Long id) {
        return userRepository.findById(id);
    }
    
    public User save(User user) {
        return userRepository.save(user);
    }
    
    public List<User> findAll() {
        return userRepository.findAll();
    }
}

三、可运行Java代码示例

Spring Boot测试

package com.example.test;

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.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;

import java.util.Optional;

import static org.mockito.Mockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

/**
 * Spring Boot Web层测试
 */
@SpringBootTest
@AutoConfigureMockMvc
@DisplayName("用户API测试")
public class UserApiTests {
    
    @Autowired
    private MockMvc mockMvc;
    
    @MockBean
    private UserService userService;
    
    @Test
    @DisplayName("获取用户 - 成功")
    void testGetUser() throws Exception {
        // 准备数据
        User user = new User(1L, "Alice", "alice@example.com");
        when(userService.findById(1L)).thenReturn(Optional.of(user));
        
        // 执行请求
        mockMvc.perform(get("/api/users/1")
                .contentType(MediaType.APPLICATION_JSON))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.name").value("Alice"));
        
        // 验证
        verify(userService).findById(1L);
    }
    
    @Test
    @DisplayName("获取用户 - 不存在")
    void testGetUserNotFound() throws Exception {
        when(userService.findById(999L)).thenReturn(Optional.empty());
        
        mockMvc.perform(get("/api/users/999"))
            .andExpect(status().isNotFound());
    }
    
    @Test
    @DisplayName("创建用户")
    void testCreateUser() throws Exception {
        User user = new User(null, "Bob", "bob@example.com");
        User saved = new User(2L, "Bob", "bob@example.com");
        
        when(userService.save(any())).thenReturn(saved);
        
        mockMvc.perform(post("/api/users")
                .contentType(MediaType.APPLICATION_JSON)
                .content("{\"name\":\"Bob\",\"email\":\"bob@example.com\"}"))
            .andExpect(status().isCreated())
            .andExpect(jsonPath("$.id").value(2));
    }
}

/**
 * Service层测试
 */
@SpringBootTest
@DisplayName("用户Service测试")
public class UserServiceTests {
    
    @Autowired
    private UserService userService;
    
    @MockBean
    private UserRepository userRepository;
    
    @Test
    @DisplayName("查找所有用户")
    void testFindAll() {
        when(userRepository.findAll())
            .thenReturn(List.of(
                new User(1L, "Alice", "alice@example.com"),
                new User(2L, "Bob", "bob@example.com")
            ));
        
        List<User> users = userService.findAll();
        
        assertEquals(2, users.size());
        assertEquals("Alice", users.get(0).getName());
    }
}

四、总结与最佳实践

4.1 测试原则(FIRST)

  • Fast:测试要快速执行
  • Independent:测试之间相互独立
  • Repeatable:测试可重复执行
  • Self-validating:测试自动验证结果
  • Timely:及时编写测试

4.2 测试覆盖率

<!-- JaCoCo插件 -->
<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>

4.3 常用命令

# 运行所有测试
mvn test

# 运行指定测试类
mvn test -Dtest=UserApiTests

# 跳过测试
mvn package -DskipTests

# 生成覆盖率报告
mvn test jacoco:report

参考资料

  1. JUnit 5官方文档
  2. Mockito官方文档
  3. 《单元测试的艺术》
  4. Spring Boot Testing文档

六、思考与练习

思考题

  1. 基础题:JUnit 5中的@BeforeAll、@BeforeEach、@AfterEach、@AfterAll注解分别在什么时候执行?有什么使用场景?
  2. 进阶题:@Mock和@InjectMocks注解的作用是什么?Mock对象和Spy对象有什么区别?
  3. 实战题:FIRST原则是什么?如何设计一个易于测试的类?

编程练习

练习:为一个用户管理Service类编写完整的单元测试,包括:正常流程测试、边界条件测试、异常情况测试,使用Mockito模拟Repository层,并生成JaCoCo覆盖率报告。

章节关联

  • 前置章节:Maven核心详解、Gradle构建详解
  • 后续章节:代码规范详解
  • 扩展阅读:《单元测试的艺术》、JUnit 5官方文档、Mockito官方文档

📝 下一章预告

统一的代码规范是团队协作的基础。下一章将详细介绍阿里巴巴Java开发手册的核心要点,以及Checkstyle、SonarQube等工具的集成配置,帮助你建立规范的编码习惯。


本章完