单元测试详解
本章导读
单元测试是保证代码质量的第一道防线。良好的单元测试能在毫秒级发现代码缺陷,为重构提供安全保障,同时也是一种鲜活的文档。本章将以JUnit 5和Mockito为核心,系统讲解测试编写技巧、参数化测试、Mock模拟等高级用法,帮助你编写高质量、可维护的测试代码。
学习目标:
- 目标1:掌握JUnit 5的核心特性,包括生命周期、断言、参数化测试
- 目标2:熟练使用Mockito进行依赖模拟和行为验证
- 目标3:能够在Spring Boot项目中编写分层测试并生成覆盖率报告
前置知识:Java基础语法、面向对象编程、Maven/Gradle基础
阅读时长:约 25 分钟
一、知识概述
单元测试是软件测试的基础层级,针对程序中最小可测试单元进行验证。良好的单元测试不仅能保证代码质量,还能提高开发效率、降低维护成本。JUnit 5作为Java生态最主流的测试框架,提供了强大的测试能力。
本文将深入分析单元测试的核心概念,包括JUnit 5新特性、Mockito模拟框架、测试覆盖率、参数化测试等内容,并提供实战代码示例。
单元测试的核心价值
- 快速反馈:毫秒级发现代码缺陷
- 重构保障:修改代码时确保功能不变
- 文档作用:测试即文档,展示API用法
- 设计改进:难测试的代码往往设计有问题
二、知识点详细讲解
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
参考资料
- JUnit 5官方文档
- Mockito官方文档
- 《单元测试的艺术》
- Spring Boot Testing文档
六、思考与练习
思考题
- 基础题:JUnit 5中的@BeforeAll、@BeforeEach、@AfterEach、@AfterAll注解分别在什么时候执行?有什么使用场景?
- 进阶题:@Mock和@InjectMocks注解的作用是什么?Mock对象和Spy对象有什么区别?
- 实战题:FIRST原则是什么?如何设计一个易于测试的类?
编程练习
练习:为一个用户管理Service类编写完整的单元测试,包括:正常流程测试、边界条件测试、异常情况测试,使用Mockito模拟Repository层,并生成JaCoCo覆盖率报告。
章节关联
- 前置章节:Maven核心详解、Gradle构建详解
- 后续章节:代码规范详解
- 扩展阅读:《单元测试的艺术》、JUnit 5官方文档、Mockito官方文档
📝 下一章预告
统一的代码规范是团队协作的基础。下一章将详细介绍阿里巴巴Java开发手册的核心要点,以及Checkstyle、SonarQube等工具的集成配置,帮助你建立规范的编码习惯。
本章完