mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
822 字
2 分钟
Java单元测试详解
2026-02-04

单元测试曾经是我最懒得写的代码,觉得有那功夫不如多写几个功能。直到被线上bug教育了几次之后,才老老实实把测试补上。JUnit配合Mockito,写测试也可以很愉快。这篇文章把我的测试经验和最佳实践分享给你。

Java单元测试详解#

为啥要写单元测试?#

以前我总觉得写测试浪费时间,直到有次重构把一个方法改坏了,没有测试兜底,上线直接炸了。从那以后,单元测试成了我的标配。

写测试的好处

  • 重构的时候敢大胆改代码,测试会告诉你改坏了没有
  • 代码的活文档,告诉别人这个方法应该怎么用
  • 减少调试时间,不用每次改完都手动跑一遍

JUnit 5 快速上手#

依赖配置#

<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
<version>5.9.1</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId>
<version>5.9.1</version>
<scope>test</scope>
</dependency>

第一个测试#

import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
public class CalculatorTest {
private Calculator calculator = new Calculator();
@Test
void testAdd() {
assertEquals(5, calculator.add(2, 3));
}
@Test
void testDivideByZero() {
assertThrows(ArithmeticException.class, () -> {
calculator.divide(6, 0);
});
}
}

常用断言#

assertEquals(预期值, 实际值); // 相等
assertNotEquals(值1, 值2); // 不相等
assertTrue(条件); // 为真
assertFalse(条件); // 为假
assertNull(对象); // 为null
assertNotNull(对象); // 不为null
assertThrows(异常类型, 执行体); // 抛出指定异常
assertTimeout(时长, 执行体); // 超时检查
assertAll(() -> 断言1, () -> 断言2); // 多个断言一起验证

测试生命周期#

public class LifecycleTest {
@BeforeAll
static void beforeAll() { /* 所有测试前执行一次,静态方法 */ }
@BeforeEach
void beforeEach() { /* 每个测试前执行 */ }
@Test
void test1() { /* 测试方法 */ }
@Test
void test2() { /* 测试方法 */ }
@AfterEach
void afterEach() { /* 每个测试后执行 */ }
@AfterAll
static void afterAll() { /* 所有测试后执行一次,静态方法 */ }
}

参数化测试#

@ParameterizedTest
@ValueSource(ints = {1, 2, 3, 4, 5})
void testIsPositive(int number) {
assertTrue(number > 0);
}
@ParameterizedTest
@CsvSource({
"1, 2, 3",
"4, 5, 9",
"6, 7, 13"
})
void testAdd(int a, int b, int expected) {
assertEquals(expected, new Calculator().add(a, b));
}
@ParameterizedTest
@MethodSource("provideNumbers")
void testIsEven(int number, boolean expected) {
assertEquals(expected, number % 2 == 0);
}
static Stream<Arguments> provideNumbers() {
return Stream.of(
Arguments.of(1, false),
Arguments.of(2, true)
);
}

嵌套测试#

@Nested
class InnerTest {
@Test
void testInner() {
assertTrue(true);
}
}

Mockito 模拟依赖#

为什么用Mock?#

真实开发中,一个方法可能依赖数据库、外部API、文件系统等。单元测试不应该依赖这些外部组件,用Mock模拟它们的行为。

依赖配置#

<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>4.8.1</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-junit-jupiter</artifactId>
<version>4.8.1</version>
<scope>test</scope>
</dependency>

基本用法#

@Mock
private UserRepository userRepository;
private UserService userService;
@BeforeEach
void setUp() {
MockitoAnnotations.openMocks(this);
userService = new UserService(userRepository);
}
@Test
void testFindUserById() {
// 模拟userRepository.findById的行为
User expectedUser = new User(1, "John");
when(userRepository.findById(1)).thenReturn(expectedUser);
// 调用被测试的方法
User actualUser = userService.findUserById(1);
// 验证结果
assertEquals(expectedUser, actualUser);
// 验证userRepository.findById被调用了恰好1次,参数是1
verify(userRepository, times(1)).findById(1);
}

常用Mock操作#

// 模拟返回值
when(mock.method()).thenReturn(value);
// 模拟抛出异常
when(mock.method()).thenThrow(new RuntimeException());
// 模拟不同调用返回不同值
when(mock.method()).thenReturn(value1).thenReturn(value2);
// 验证调用次数
verify(mock, times(2)).method();
verify(mock, atLeastOnce()).method();
verify(mock, never()).method();
// 参数匹配
when(mock.method(anyString())).thenReturn(value);
when(mock.method(eq(100))).thenReturn(value);
when(mock.method(argThat(x -> x > 0))).thenReturn(value);

测试最佳实践#

1. 命名要清晰#

@Test
void shouldReturnSumWhenAddingTwoNumbers() { /* 方法名说明了一切 */ }

2. 测试要独立#

每个测试方法应该独立运行,不依赖其他测试的执行顺序或结果:

// 每个测试方法都自己准备数据
@Test
void testIncrement() { int counter = 0; counter++; assertEquals(1, counter); }
@Test
void testDecrement() { int counter = 1; counter--; assertEquals(0, counter); }

3. 覆盖边界条件#

除了正常情况,还要测试边界值和异常情况:

@Test
void testDivideNormal() { assertEquals(3, calc.divide(6, 2)); }
@Test
void testDivideByZero() { assertThrows(ArithmeticException.class, () -> calc.divide(6, 0)); }
@Test
void testDivideNegative() { assertEquals(-3, calc.divide(-6, 2)); }

4. 测试行为,不测试实现#

只测方法”做什么”,不测”怎么做”。改了实现方式,测试应该还是绿的:

// 好:只测行为
ShoppingCart cart = new ShoppingCart();
cart.addItem(new Item("Apple", 10));
assertEquals(15, cart.getTotal());
// 不好:测了内部实现
assertEquals(2, cart.getItems().size()); // 如果改成了Map存储,这个测试就挂了

5. 用Mock隔离外部依赖#

@Test
void testProcessOrder() {
OrderRepository orderRepo = Mockito.mock(OrderRepository.class);
PaymentService paymentService = Mockito.mock(PaymentService.class);
when(paymentService.processPayment(any())).thenReturn(true);
OrderService orderService = new OrderService(orderRepo, paymentService);
Order order = new Order(1, 100);
boolean result = orderService.processOrder(order);
assertTrue(result);
verify(orderRepo).save(order);
verify(paymentService).processPayment(order);
}

6. 测试要快#

跑得慢的测试没人愿意跑,尽量用Mock替代真实的数据库/网络调用。

实战贴士#

  1. 不要追求100%覆盖率:核心逻辑覆盖到就行,getter/setter不用测
  2. 测试命名用should格式shouldReturnErrorWhenInputIsInvalid 一目了然
  3. 每个测试测一个场景:一个测试方法里只测一件事,出问题了容易定位
  4. Mockito的when和verify配合使用:when模拟行为,verify验证调用
  5. 参数化测试减少重复代码:多个输入输出组合用@ParameterizedTest

常见坑#

  • 追求100%覆盖率:我以前也追求过,后来发现有些getter/setter也要测,纯属浪费时间。核心逻辑覆盖到就行。
  • 测试依赖外部系统:测试里连数据库、调外部API,跑一次要几秒钟,没人愿意跑。用Mock隔离。
  • 测试实现细节:改了实现方式测试就红了,这很痛苦。只测行为。
  • 测试之间互相依赖:测试A跑完的结果影响了测试B,单独跑B就挂。每个测试都应该独立准备数据。
  • 测试代码质量差:测试代码也是代码,也要维护。写清楚一点,别自己都看不懂。
分享

如果这篇文章对你有帮助,欢迎分享给更多人!

部分信息可能已经过时

目录