822 字
2 分钟
Java单元测试详解
单元测试曾经是我最懒得写的代码,觉得有那功夫不如多写几个功能。直到被线上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(对象); // 为nullassertNotNull(对象); // 不为nullassertThrows(异常类型, 执行体); // 抛出指定异常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) );}嵌套测试
@Nestedclass 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>基本用法
@Mockprivate UserRepository userRepository;
private UserService userService;
@BeforeEachvoid setUp() { MockitoAnnotations.openMocks(this); userService = new UserService(userRepository);}
@Testvoid 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. 命名要清晰
@Testvoid shouldReturnSumWhenAddingTwoNumbers() { /* 方法名说明了一切 */ }2. 测试要独立
每个测试方法应该独立运行,不依赖其他测试的执行顺序或结果:
// 每个测试方法都自己准备数据@Testvoid testIncrement() { int counter = 0; counter++; assertEquals(1, counter); }
@Testvoid testDecrement() { int counter = 1; counter--; assertEquals(0, counter); }3. 覆盖边界条件
除了正常情况,还要测试边界值和异常情况:
@Testvoid testDivideNormal() { assertEquals(3, calc.divide(6, 2)); }
@Testvoid testDivideByZero() { assertThrows(ArithmeticException.class, () -> calc.divide(6, 0)); }
@Testvoid 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隔离外部依赖
@Testvoid 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替代真实的数据库/网络调用。
实战贴士
- 不要追求100%覆盖率:核心逻辑覆盖到就行,getter/setter不用测
- 测试命名用should格式:
shouldReturnErrorWhenInputIsInvalid一目了然 - 每个测试测一个场景:一个测试方法里只测一件事,出问题了容易定位
- Mockito的when和verify配合使用:when模拟行为,verify验证调用
- 参数化测试减少重复代码:多个输入输出组合用@ParameterizedTest
常见坑
- 追求100%覆盖率:我以前也追求过,后来发现有些getter/setter也要测,纯属浪费时间。核心逻辑覆盖到就行。
- 测试依赖外部系统:测试里连数据库、调外部API,跑一次要几秒钟,没人愿意跑。用Mock隔离。
- 测试实现细节:改了实现方式测试就红了,这很痛苦。只测行为。
- 测试之间互相依赖:测试A跑完的结果影响了测试B,单独跑B就挂。每个测试都应该独立准备数据。
- 测试代码质量差:测试代码也是代码,也要维护。写清楚一点,别自己都看不懂。
分享
如果这篇文章对你有帮助,欢迎分享给更多人!
部分信息可能已经过时
相关文章 智能推荐
1
Java NIO详解
Java NIO的Selector模型让我第一次感受到了单线程处理万级连接的魅力。相比传统IO的阻塞读写,Channel加Buffer的组合简直是为高性能网络编程量身定做。
2
Java注解详解
Java 刚学Java时觉得注解不就是几个@符号嘛,后来用Spring Boot才发现注解能玩出这么多花样——从编译检查到运行时注入,注解远不止看起来那么简单。
3
Java反射机制详解
Java 反射是Java高级特性中最让我着迷的一个——运行时能看见自己。工作中用Spring的依赖注入时总在想它是怎么做到的,后来才明白反射就是那个幕后推手。
4
Java异常处理详解
Java try-catch是Java最基础的异常处理手段,但什么时候该用受检异常、什么时候该抛RuntimeException,这中间有不少门道值得琢磨。
5
Java IO操作详解
Java Java的IO体系虽然庞大,但掌握了装饰器模式再看InputStream和Reader的继承关系,一切都豁然开朗了。






