引言:什么是单元测试?
单元测试(Unit Testing)是对软件中的最小可测试单元进行检查和验证,确保每个单元都按预期工作。
单元测试类比:汽车生产
汽车生产前对零件进行测试:
发动机测试 -> 测试发动机是否能正常工作
轮胎测试 -> 测试轮胎承压能力
刹车测试 -> 测试刹车响应距离
单元测试就像对这些零件的测试:
- 测试粒度小(一个方法、一个类)
- 测试速度快
- 测试结果准确
- 确保每个零件都没问题
- 最终组装才能正常运行!
为什么需要单元测试?
保证代码质量:及时发现bug,减少线上问题
提高重构信心:修改代码后快速验证是否破坏原有功能
文档作用:测试代码就是最好的接口使用文档
促进设计:写出可测试的代码通常意味着更好的设计
JUnit 5注解
JUnit 5是目前最新的JUnit版本,由三个模块组成:JUnit Platform + JUnit Jupiter + JUnit Vintage。
核心注解
| 注解 | 说明 |
|---|---|
| @Test | 标记测试方法(注意:来自org.junit.jupiter.api) |
| @BeforeEach | 每个测试方法执行前调用 |
| @AfterEach | 每个测试方法执行后调用 |
| @BeforeAll | 所有测试方法执行前调用一次 |
| @AfterAll | 所有测试方法执行后调用一次 |
| @Disabled | 禁用测试方法或类 |
| @DisplayName | 自定义测试显示名称 |
测试生命周期
@BeforeAll -> 执行一次(所有测试之前)
↓
@BeforeEach -> 执行多次(每个测试之前)
↓
@Test -> 测试方法1
↓
@AfterEach -> 执行多次(每个测试之后)
↓
@BeforeEach -> 执行多次(每个测试之前)
↓
@Test -> 测试方法2
↓
@AfterEach -> 执行多次(每个测试之后)
↓
@AfterAll -> 执行一次(所有测试之后)
常用断言
断言(Assertion)用于验证测试结果是否符合预期。
基本断言
import static org.junit.jupiter.api.Assertions.*;
// 相等断言
assertEquals(expected, actual);
assertEquals(expected, actual, "消息");
// 布尔断言
assertTrue(condition);
assertFalse(condition);
// 空值断言
assertNull(object);
assertNotNull(object);
// 数组断言
assertArrayEquals(expectedArray, actualArray);
异常断言
// 方式1:使用assertThrows
assertThrows(ArithmeticException.class, () -> {
int result = 10 / 0;
});
// 方式2:使用assertThrows获取异常并验证
ArithmeticException ex = assertThrows(ArithmeticException.class, () -> {
calculator.divide(1, 0);
});
assertEquals("除数不能为零", ex.getMessage());
// 方式3:assertDoesNotThrow(不抛出异常)
assertDoesNotThrow(() -> calculator.divide(10, 2));
超时断言
import org.junit.jupiter.api.assertTimeout;
import java.time.Duration;
assertTimeout(Duration.ofSeconds(1), () -> {
// 应该在1秒内完成
Thread.sleep(100);
});
组合断言
// allOf:所有断言都通过才算通过
assertAll("用户信息验证",
() -> assertNotNull(user.getName()),
() -> assertTrue(user.getAge() >= 18),
() -> assertEquals("email@test.com", user.getEmail())
);
完整测试示例
import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;
@DisplayName("计算器测试")
class CalculatorTest {
private Calculator calculator;
@BeforeEach
void setUp() {
calculator = new Calculator();
System.out.println("每个测试前执行");
}
@AfterEach
void tearDown() {
System.out.println("每个测试后执行");
}
@BeforeAll
static void beforeAll() {
System.out.println("所有测试前执行一次");
}
@AfterAll
static void afterAll() {
System.out.println("所有测试后执行一次");
}
@Test
@DisplayName("加法测试")
void testAdd() {
int result = calculator.add(2, 3);
assertEquals(5, result, "2+3应该等于5");
}
@Test
@DisplayName("减法测试")
void testSubtract() {
assertEquals(5, calculator.subtract(10, 5));
assertEquals(-1, calculator.subtract(4, 5));
}
@Test
@DisplayName("乘法测试")
void testMultiply() {
assertEquals(6, calculator.multiply(2, 3));
assertEquals(0, calculator.multiply(0, 100));
}
@Test
@DisplayName("除法测试")
void testDivide() {
assertEquals(2, calculator.divide(10, 5));
}
@Test
@DisplayName("除数为零应抛出异常")
void testDivideByZero() {
ArithmeticException ex = assertThrows(ArithmeticException.class, () -> {
calculator.divide(1, 0);
});
assertEquals("除数不能为零", ex.getMessage());
}
@Disabled("暂时禁用此测试")
@Test
@DisplayName("被禁用的测试")
void disabledTest() {
fail("这个测试不应该被执行");
}
}
参数化测试
JUnit 5支持参数化测试,用不同参数多次运行同一测试。
Maven依赖
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-params</artifactId>
<version>5.10.0</version>
<scope>test</scope>
</dependency>
参数化测试示例
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
@ParameterizedTest
@ValueSource(ints = {0, 1, 2, 10, 100})
@DisplayName("验证正整数加0等于自身")
void testAddZero(int number) {
Calculator calc = new Calculator();
assertEquals(number, calc.add(number, 0));
}
@ParameterizedTest
@CsvSource({
"1, 2, 3",
"10, 20, 30",
"100, 200, 300"
})
@DisplayName("加法参数化测试")
void testAddParameterized(int a, int b, int expected) {
Calculator calc = new Calculator();
assertEquals(expected, calc.add(a, b));
}
Mock对象(Mockito)
使用Mockito可以创建Mock对象,模拟依赖的外部服务。
Maven依赖
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-junit-jupiter</artifactId>
<version>5.7.0</version>
<scope>test</scope>
</dependency>
Mockito基本用法
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import static org.mockito.Mockito.*;
class UserServiceTest {
@Test
void testGetUser() {
// 1. 创建Mock对象
UserRepository mockRepo = Mockito.mock(UserRepository.class);
// 2. 设置Mock行为
User expectedUser = new User(1L, "张三");
when(mockRepo.findById(1L)).thenReturn(expectedUser);
// 3. 使用Mock对象
UserService service = new UserService(mockRepo);
User user = service.getUserById(1L);
// 4. 验证结果
assertEquals("张三", user.getName());
// 5. 验证方法调用(可选)
verify(mockRepo, times(1)).findById(1L);
}
@Test
void testSaveUser() {
UserRepository mockRepo = Mockito.mock(UserRepository.class);
UserService service = new UserService(mockRepo);
User newUser = new User(null, "李四");
User savedUser = new User(2L, "李四");
when(mockRepo.save(newUser)).thenReturn(savedUser);
User result = service.createUser(newUser);
assertNotNull(result.getId());
assertEquals("李四", result.getName());
verify(mockRepo, times(1)).save(newUser);
}
}
常用Mockito方法
| 方法 | 说明 |
|---|---|
| mock(Class) | 创建Mock对象 |
| when().thenReturn() | 设置方法返回值 |
| when().thenThrow() | 设置方法抛出异常 |
| verify(mock).method() | 验证方法被调用 |
| times(n) | 验证调用次数 |
| never() | 验证从未调用 |
| any() | 匹配任意参数 |
测试最佳实践
每个测试只验证一个行为:测试应该独立、快速
测试命名清晰:使用@DisplayName或清晰的方法名
Arrange-Act-Assert模式:准备 -> 执行 -> 验证
测试真实的异常情况:边界条件、null值、异常
避免测试逻辑:测试不应该包含复杂的if-else
测试命名建议
// 好命名
@Test
void testAdd_NegativeNumbers_ReturnsNegativeSum() {}
@Test
void testFindById_WhenUserExists_ReturnsUser() {}
@Test
void testWithdraw_WhenBalanceInsufficient_ThrowsException() {}
// 不好命名
@Test
void test1() {}
@Test
void testAdd() {}
@Test
void testFind() {}
小结
- 单元测试验证最小可测试单元的正确性
- @Test标记测试方法,@BeforeEach/AfterEach处理生命周期
- 使用断言验证结果:assertEquals、assertThrows等
- @Disabled可临时禁用测试
- 参数化测试用不同参数多次运行测试
- Mockito用于模拟依赖的外部对象
- 测试应遵循Arrange-Act-Assert模式