我见过太多Java项目,业务代码写得飞起,一到单元测试就集体沉默。问就是“没时间”“跑通就行”“等稳定了再补”。但真等线上出了事故,再回头补测试的时候,你基本已经忘了当初这段逻辑里埋了多少坑。Java单元测试这事儿,本质上是给自己留后路,是代码质量的基石。这篇文章不打算写教科书式的概念,而是从实际工程角度,聊聊单元测试怎么设计、框架怎么选、常见坑怎么避,以及我这些年写测试踩过的泥巴路。
这篇文章适合谁看呢?正在学Java基础、准备实习或校招的同学,刚接手老项目不知道怎么补测试的初级开发,以及想把手头模块的测试覆盖率提上去但又不想流于形式的中间层开发。我会尽量用“人话”把JUnit 5、TestNG、Mockito这些工具讲清楚,并且给出一套可以直接拿来用的实操方案。读完你至少能回答三个问题:单元测试到底测什么、怎么设计用例、踩坑之后怎么排查。
1. 先想清楚:单元测试到底在测什么,为什么它能兜住代码质量
很多人对单元测试有个误解,觉得它就是把方法跑一遍,看输出对不对。如果只是这种程度,那确实价值有限。单元测试的威力在于:它能逼着你在写代码的时候就想清楚“输入是什么、输出是什么、依赖怎么剥离”,这本身就是一次设计评审。我在实际项目里感受很深——凡是好测的代码,基本都符合单一职责原则;凡是一测就各种别扭的代码,多半是耦合太重,该重构了。
1.1 单元测试不是在给领导交差,而是在给你自己排雷
我在带团队的时候,最怕听到一句话:“这个太简单了,不用测。”越简单的逻辑,越容易在边界条件上翻车。举个例子,一个看起来只是“金额乘以折扣”的方法,产品经理可能加一句“满1000减50”,再加一句“VIP再打9折”,这时候普通用户、VIP用户、SVIP用户、还有0元订单和负数金额的输入,每个分支都可能是地雷。没有单元测试,你只能靠手工点页面,点一遍两遍还行,改三次需求之后你根本不敢动这段代码。
单元测试的本质是“可执行的设计文档”。它不光告诉你代码“现在能跑”,还告诉你“当初设计这个行为的人是怎么想的”。我接手过很多老项目,最头疼的不是代码难读,而是改一个公共方法,不知道会影响哪些调用方。如果这套方法有完整的单元测试,跑一下就知道哪些行为被我改坏了。说白了,单测做得好,重构时心里就有底;单测缺失,每次改动都像踩钢丝。
1.2 测什么、不测什么:别把单元测试写成“调用演示”
判断一个测试该不该写的标准很简单:这条逻辑是不是我自己写的?如果是,就要测;如果是Spring框架帮你做的、数据库帮你做的、第三方SDK帮你做的,通常不用测。比如你写了一个StringUtils.join的封装,那你要测的是你的封装逻辑,而不是StringUtils本身。再比如排序算法、金额计算、状态流转、权限判断,这些纯逻辑和业务规则,是单元测试的主场。
我踩过的另一个坑是“过度Mock”。有段时间我为了让测试通过,把能Mock的全Mock了,结果测出来一个“自娱自乐”的结果——代码执行路径是对的,但真实环境根本跑不通。所以后来我给自己定了一条规矩:单元测试要测“行为”,不是测“实现细节”。你该关注的是“传入某组参数,返回什么结果、调用了哪些依赖、抛了什么异常”,而不是“这个private方法是不是被调用了”。一上来就想着铺垫全链路、启动整个Spring容器,那叫集成测试,不是单元测试。
2. 框架选型与工程搭建:JUnit 5还是TestNG,30分钟搭好可运行环境
框架选型这事儿,年年有人争论。网络上搜索热度也高,尤其是“单元测试集成TestNG”这类词,说明不少团队还在两套之间纠结。我的结论很直接:新项目优先JUnit 5,老项目在用TestNG也不用急着迁移,两者都能干活。关键是把工程配好、把依赖关系理清楚,别在版本上翻车。
2.1 JUnit 5和TestNG,到底怎么选
先看一张对比表,这基本覆盖了选型时最关心的点:
| 维度 | JUnit 5 | TestNG |
|---|---|---|
| 基础注解 | @Test、@BeforeEach、@AfterEach | @Test、@BeforeMethod、@AfterMethod |
| 参数化测试 | @ParameterizedTest + @ValueSource / @CsvSource | @DataProvider,功能更早更成熟 |
| 断言能力 | Assertions类,功能全面 | Assert类,配合Hamcrest使用 |
| 扩展机制 | JUnit Platform,生态极好 | 相对简单,扩展靠Listener |
| 并行执行 | 较新的版本支持,需要配置 | 官方支持好,XML套件和并行历史成熟 |
| Spring Boot生态 | 默认支持,社区资料多 | 需要额外适配,资料相对少 |
| 适用场景 | 新项目、Spring Boot项目、大规模推广 | 老项目维护、已有TestNG习惯的团队 |
从维护角度来看,Spring Boot新工程默认就是JUnit 5,很多自动配置、Mockito扩展、测试工具类都围绕它来做,遇到问题搜索资料也方便。TestNG在参数化和并行执行上有老牌优势,但近年来JUnit 5已经追得很近了。如果你们团队没有历史包袱,我用JUnit 5能省掉很多“适配”的烦恼。
2.2 Maven工程快速搭建测试环境
前提条件很简单:机器上装好JDK,配好JAVA_HOME和PATH环境变量。这一步别嫌啰嗦,我见过不少新手卡在“java不是内部或外部命令”,结果只是环境变量没配好。用最传统的方式验证一下:命令行执行java -version,能输出版本号,环境就OK。
Maven项目只需要在pom.xml里加依赖。以JUnit 5为例,最简单的方式是引入junit-jupiter聚合依赖,它会把API、引擎和参数化测试的支持都带进来:
<dependencies> <dependency> <groupId>org.junit.jupiter</groupId> <artifactId>junit-jupiter</artifactId> <version>5.10.2</version> <scope>test</scope> </dependency> </dependencies>很多人在这一步就踩坑了:光加依赖没用,mvn test跑不起来。还要在build里配好maven-surefire-plugin,版本最好选2.22.2以上,否则默认surefire版本不认识JUnit 5,会报“No tests were executed”或者干脆找不到测试类。配置示例:
<build> <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-surefire-plugin</artifactId> <version>3.2.5</version> </plugin> </plugins> </build>然后写一个最简单的测试类放在src/test/java下:
import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; public class SimpleTest { @Test void firstTest() { assertEquals(2, 1 + 1); } }执行mvn test,看到Tests run: 1, Failures: 0, Errors: 0, Skipped: 0,说明环境通了。如果你用的是Gradle,核心依赖同理,只是坐标为testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2'。
3. 核心写法拆解:生命周期、断言、Mock与参数化测试的关键细节
框架搭好只是第一步,真正拉开差距的是测试代码的质量。同样一个方法,有人能写出清晰、稳定、覆盖全的测试,有人写出来的测试比被测代码还难维护。这一节我重点讲几个高频细节,都是我实际写代码时反复用到的点。
3.1 生命周期注解到底是干嘛的,别拿它当摆设
JUnit 5的生命周期注解有四个:
| 注解 | 执行时机 | 典型用途 |
|---|---|---|
| @BeforeAll | 当前测试类所有测试执行前,只执行一次 | 创建数据库连接、加载重量级配置 |
| @AfterAll | 当前测试类所有测试执行后,只执行一次 | 关闭连接、清理资源 |
| @BeforeEach | 每个测试方法执行前,都执行一次 | 初始化测试对象、重置Mock状态 |
| @AfterEach | 每个测试方法执行后,都执行一次 | 清理临时数据、释放局部资源 |
我见过很多新手把@BeforeAll当成初始化对象的地方,结果方法没声明static,测试直接启动报错。为什么要求static?因为JUnit默认每个测试方法都会创建新的测试类实例,@BeforeAll执行时实例还没创建,因此只能通过静态方法跑。如果你的初始化逻辑需要访问实例字段,说明你应该用@BeforeEach。实操中我倾向于:只创建一次重量级资源用@BeforeAll,每个用例独立的测试数据和Mock状态用@BeforeEach,这样测试用例之间不会互相污染。
3.2 断言、异常测试和参数化测试:把用例写薄写清晰
断言方面,Assertions类提供了大量方法,核心就那几个:
assertEquals(expected, actual); // 判断相等,注意参数顺序,期望值在前 assertTrue(condition); // 判断条件为真 assertFalse(condition); // 判断条件为假 assertNull(obj); // 判断为空 assertNotNull(obj); // 判断非空 assertThrows(IllegalArgumentException.class, () -> service.calc(-1));我特别想强调assertThrows。很多老代码喜欢用try-catch去捕获异常再判断,写得又臭又长,还容易漏catch。用assertThrows可以一行搞定异常路径的验证,而且还能拿到异常对象进一步校验message,非常干净。
参数化测试是最能提升效率的工具,没有之一。如果你发现自己写了三个几乎一模一样的测试方法,只是入参不同,那就应该改成参数化。JUnit 5里用@ParameterizedTest配合@ValueSource、@CsvSource就能做:
@ParameterizedTest @CsvSource({ "100, 0, 100", "100, 0.9, 90", "1000, 0.8, 750" }) void testCalcPayAmount(double original, double discount, double expected) { OrderService service = new OrderService(); assertEquals(expected, service.calcPayAmount(original, discount)); }这里第二个用例算的是满减叠加后的结果,注意设计用例时要把规则边界算清楚,而不是随便给几个数字。我自己写用例的习惯是:一个正常值、一个边界值、一个非法值,边界值往往就是Bug藏身处。
3.3 测试隔离与Mock:别让你的测试依赖数据库和Redis
单元测试最重要的一条原则是“快且隔离”。如果一个测试要连MySql、连Redis才能跑,那它就不再是单元测试了,别人没法在你机器上快速复现,CI也容易飘。解决办法是引入Mock,把外部依赖替换成可控的“演员”。
Mockito是目前Java生态最流行的Mock框架,和JUnit 5配合很顺。基本用法模板如下:
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import static org.mockito.Mockito.*; import static org.junit.jupiter.api.Assertions.*; @ExtendWith(MockitoExtension.class) class OrderServiceTest { @Mock StockService stockService; @InjectMocks OrderService orderService; @Test void testCreateOrderWhenStockNotEnough() { when(stockService.getStock(1001)).thenReturn(0); assertThrows(IllegalStateException.class, () -> orderService.createOrder(1001, 1)); verify(stockService, never()).deduct(anyInt(), anyInt()); } }注意几个要点:@ExtendWith(MockitoExtension.class)是JUnit 5接入Mockito的入口;@Mock创建假的依赖对象;@InjectMocks把Mock注入到被测对象里。when(...).thenReturn(...)定义假行为,verify(...)用于验证某个依赖到底有没有被调用、调了几次。Mockito不是让你把所有东西都Mock掉,而是把“跟被测逻辑无关但联调很重的依赖”挡在门外。判断标准很简单:这个依赖是别人写的、容易变、环境敏感,就用Mock;这个依赖是你自己写的核心逻辑,最好用真实实现。
回到热词里提到的“RedisTemplate的increment()报错”这类问题,多半就是把真实Redis牵扯进了测试。测试里Redis根本不该存在,用Mock把RedisTemplate替掉最省心,断言“调用了多少次”“返回值是什么”就行。绝对不要在单元测试里连一个真Redis,那属于自找麻烦。
4. 完整实操实录:给订单金额计算模块写一组高质量单元测试
理论讲太多容易飘,直接上一个我最近在项目中整理过的案例。场景:订单服务需要计算最终支付金额,规则有三个——会员折扣、满减优惠、库存扣减。我用一个简化版代码说明,重点看测试怎么设计。
4.1 被测代码长什么样
public class OrderService { private final DiscountPolicy discountPolicy; private final StockService stockService; public OrderService(DiscountPolicy discountPolicy, StockService stockService) { this.discountPolicy = discountPolicy; this.stockService = stockService; } /** * 计算订单实付金额。 * 规则: * 1. 先按会员等级打折,普通用户不打折,VIP 9折,SVIP 8折; * 2. 折后满1000元再减50; * 3. 下单前必须扣减库存,库存不足直接抛异常。 */ public double calcPayAmount(String userLevel, double originalAmount, int skuId, int count) { if (originalAmount < 0) { throw new IllegalArgumentException("originalAmount must not be negative"); } if (count <= 0) { throw new IllegalArgumentException("count must be positive"); } double discount = discountPolicy.getDiscount(userLevel); double payAmount = originalAmount * discount; if (payAmount >= 1000) { payAmount -= 50; } if (stockService.getStock(skuId) < count) { throw new IllegalStateException("stock not enough"); } stockService.deduct(skuId, count); return payAmount; } } public interface DiscountPolicy { double getDiscount(String userLevel); } public interface StockService { int getStock(int skuId); void deduct(int skuId, int count); }这里我把DiscountPolicy和StockService设计成接口,就是为了测试时好替换。如果被测代码里直接new了一个具体类,那几乎没法测试。这也是我前面说的:单元测试倒逼你写出可测试的代码,可测试的代码通常也更好维护。
4.2 测试用例怎么设计:正常、边界、异常一个都不能少
设计用例前,先把规则列出来:打折逻辑、满减逻辑、库存校验逻辑,这三块是核心,每一块都要覆盖“正常路径”和“边界路径”。
折扣逻辑的等价类:普通用户(折扣1.0)、VIP(0.9)、SVIP(0.8)、未知等级(按普通处理还是抛异常?这里我按普通用户处理)。满减逻辑的边界:折后金额999(不满减)、1000(刚好触发满减)、1001(触发满减),这三个值必须都测。库存逻辑:库存充足、库存刚好等于购买数量、库存不足。
基于这套思路,完整测试类可以这样写:
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) class OrderServiceTest { @Mock DiscountPolicy discountPolicy; @Mock StockService stockService; @InjectMocks OrderService orderService; @BeforeEach void setUp() { // 默认库存充足,每个用例可单独覆盖 when(stockService.getStock(anyInt())).thenReturn(100); } @Test void testNormalUserNoDiscount() { when(discountPolicy.getDiscount("NORMAL")).thenReturn(1.0); double result = orderService.calcPayAmount("NORMAL", 500, 1001, 1); assertEquals(500.0, result); verify(stockService).deduct(1001, 1); } @Test void testVipDiscount() { when(discountPolicy.getDiscount("VIP")).thenReturn(0.9); double result = orderService.calcPayAmount("VIP", 500, 1001, 1); assertEquals(450.0, result); } @Test void testFullReductionOnThreshold() { when(discountPolicy.getDiscount("VIP")).thenReturn(0.9); // 折后 900 元,未达满减线 double notReach = orderService.calcPayAmount("VIP", 1000, 1001, 1); // 折后 1100 元,触发满减 double reach = orderService.calcPayAmount("SVIP", 1375, 1001, 1); assertEquals(900.0, notReach); assertEquals(1050.0, reach); // 1375 * 0.8 = 1100,1100 - 50 = 1050 } @Test void testStockNotEnoughThrowsException() { when(discountPolicy.getDiscount("NORMAL")).thenReturn(1.0); when(stockService.getStock(1001)).thenReturn(0); assertThrows(IllegalStateException.class, () -> orderService.calcPayAmount("NORMAL", 100, 1001, 1)); verify(stockService, never()).deduct(anyInt(), anyInt()); } @ParameterizedTest @CsvSource({ "NORMAL, -1, 1001, 1", "NORMAL, 100, 1001, 0", "NORMAL, 100, 1001, -5" }) void testInvalidArguments(String level, double amount, int skuId, int count) { when(discountPolicy.getDiscount(level)).thenReturn(1.0); assertThrows(IllegalArgumentException.class, () -> orderService.calcPayAmount(level, amount, skuId, count)); } }这里有几个细节值得说。注意@BeforeEach里的默认库存设置,每个用例如果有特殊需求就用when(stockService.getStock(1001)).thenReturn(0)单独覆盖,没有特殊需求的用例直接被默认值保护,代码更干净。参数化测试把非法入参全塞到一起,不仅看得明白,以后加规则也只用加一行。如果以后产品经理说“折后满1000减80”,你只需要改对应用例的期望值,肉眼就能发现规则变化对行为的影响。
4.3 覆盖率与持续集成:别盲目追100%,但要盯住核心逻辑
用例写完之后,下一步是了解自己测到多少代码。Java圈最常用的覆盖率工具是JaCoCo,用Maven的话加一个插件即可:
<plugin> <groupId>org.jacoco</groupId> <artifactId>jacoco-maven-plugin</artifactId> <version>0.8.12</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>执行mvn clean test jacoco:report,然后打开target/site/jacoco/index.html,就能看到行覆盖率、分支覆盖率和圈复杂度。我个人的经验是:核心业务模块的覆盖率要拉到80%以上,框架代码、DTO、工具类可以放低要求。千万别为了100%去写那种“测试跑了一遍什么都没验证”的假测试,那样不如不写。如果团队有条件,可以把覆盖率阈值接入CI,低于阈值直接构建失败,这样能防止有人偷偷不写测试。
5. 我踩过的坑:环境、编译期与运行期的常见问题排查
写单元测试这几年,我撞过的墙比很多人想象的都多。有些是环境问题,有些是框架版本问题,有些是测试设计问题。下面我把高频坑整理出来,按症状、原因、解法列出,方便你直接对照排查。
5.1 环境与编译期的坑:JDK版本、Lombok、NoClassDefFoundError
先说一个我帮同事排查了很久的问题:一个模块跑测试,启动直接报Uncaught exception java.lang.NoClassDefFoundError: java/applet/Applet in thread ...。看到Applet就要反应过来,这是JDK版本和依赖不匹配的老问题。Applet类在JDK 9之后被移除了,但某些老版本库或构建插件还在引它,换新JDK跑老项目时特别容易触发。解决思路是升级相关依赖版本,或者把项目运行在它原本适配的JDK版本上,别急着用最新版JDK去跑老项目,除非你确定所有依赖都兼容。
第二个高频报错是java: You aren't using a compiler supported by lombok, so lombok will not work。基本原因就是Lombok版本和当前JDK太新不匹配,Lombok还没跟上。解法很简单:升级Lombok插件依赖到最新版,同时确保Maven插件和IDE里的Lombok插件版本一致。旧项目升级JDK时,这块是重灾区,我的建议是先在评审阶段就把依赖兼容性列清楚,再动升级。
5.2 运行期的坑:RedisTemplate、测试顺序依赖、数据污染
热词里有大量关于RedisTemplate.increment()报错的搜索,核心报错通常是“value is not an integer or out of range”。原因大多是Redis里存的值不是数字,或者用了increment但存的字符串带空格、带引号。单测里如果要测这种逻辑,最稳的做法是Mock掉RedisTemplate,不要真连Redis。如果非要写集成测试,得先确认Redis里对应key的值类型是数字,测试结束还要记得清理key,否则全局污染会害死下一个跑测试的人。
还有一个非常隐蔽的坑:测试方法之间存在顺序依赖。JUnit默认的执行顺序是确定的但无序的,你写了A、B两个用例,A往静态Map里塞了数据,B依赖A留下的残留数据,那么单独跑B会失败,一起跑又可能因为顺序变化偶发失败。解决方法是让每个用例都自己准备数据、自己清理数据,不依赖其他用例的产生物。静态字段和Spring的单例Bean是最容易隐藏状态的地方,测试之间互相污染往往就出在这。
时间相关逻辑也常导致不稳定。比如测试“订单超过30分钟未支付自动取消”,如果你的方法里直接new Date(),那测试结果会受真实时间影响。正确做法是用Clock接口或LocalDateTime.now(clock),测试里传入一个固定时间,就能稳定测试任何时间分支。
5.3 常见问题速查表
| 症状 | 可能原因 | 解决思路 |
|---|---|---|
| mvn test 提示 No tests were executed | surefire版本过低或没配JUnit Platform | 升级maven-surefire-plugin到3.x |
| 测试报 NoClassDefFoundError: java/applet/Applet | JDK 9+运行了旧依赖库 | 升级依赖或将项目运行在适配JDK版本 |
| lombok编译报错 | Lombok版本不支持当前JDK | 升级Lombok到新版,IDE插件同步升级 |
| RedisTemplate.increment()报类型错误 | Redis里值不是数字或被污染 | 单元测试用Mock;集成测试先清key再跑 |
| 测试单独跑通过,全部跑失败 | 用例之间存在隐式依赖或共享状态 | 每个用例独立准备数据、清理数据 |
| 偶发失败,今天过明天挂 | 涉及时间、随机数、外部服务 | 注入Clock或Mock掉随机源,隔离外部依赖 |
| 覆盖率插件不生成报告 | JaCoCo版本和JDK不兼容 | 升级jacoco-maven-plugin到0.8.8+ |
排查问题的时候,我习惯先跑单个测试类定位,再用mvn test -Dtest=类名#方法名缩小范围。如果怀疑依赖问题,就加-X参数看完整堆栈,别瞎猜。很多东西看似玄学,最后查下来都是环境或状态污染,测试代码本身的问题反而相对少。
5.4 关于单元测试的一个心态建议
写了这么多年测试,我最大的体会是:单元测试不是“额外负担”,它更像是一个安全网。你在上面花的时间,都会在改需求、重构、排查线上问题时加倍赚回来。而且它逼着你把代码拆小、拆清晰,这个收益比覆盖率数字本身重要得多。如果把写测试当作纯任务去应付,那确实痛苦;但如果把它当成“先跟自己的代码较一遍劲”的过程,你会发现它能帮你揪出一堆当时没意识到的边界漏洞。我现在新写的代码,几乎都会同步补上单测,尤其遇到复杂if-else分支,写测试的过程就是在审视这段逻辑是否合理。
最后说个实操技巧:如果你已经有一批没有测试的老代码,别想着一次性全补,那是体力活且风险极高。我建议从改动最频繁、出过线上Bug的模块开始补,先把高风险区域罩住,等重构到一定程度再逐步扩大。单元测试这口饭,要一口一口吃,只要方向对了,代码质量一定会往好的方向走。