拓冰建站拓冰建站
首页 / 资讯中心 / 正文

Java单元测试实战:从JUnit5到Mockito与覆盖率统计

Java单元测试这件事说来也怪越是刚入行的朋友越觉得它浪费时间写业务代码都来不及哪还有功夫去写那些“验证方法能跑”的测试代码但等你在真实项目里折腾过几年踩过那种“改一行代码、挂一片功能”的坑之后就会明白单元测试根本不是可选项而是 Java 工程里的基础设施和 Git、Maven 一个级别的东西。这篇文章不打算跟你谈什么高深理论就从实际干活的角度把单元测试从环境搭建、用例编写、Mock 解耦到覆盖率统计的完整链路捋一遍顺手把那些测试跑不起来、覆盖率上不去、依赖外部服务报错之类的典型问题一并拆掉。不管你是刚学完 Java 基础、准备在简历上写“熟悉单元测试”的求职者还是被历史代码折磨得不敢重构的项目维护者这篇文章都能给你一套可以直接照抄的落地姿势。我会尽量说人话把每一步为什么这么做讲清楚而不是丢一堆概念让你背。1. 单元测试这件事为什么绕不开1.1 它保护的不仅是代码还有你的交付节奏很多人对单元测试的理解就是“验证代码对不对”这个理解没错但格局小了。单元测试真正的价值是让你在改代码的时候敢动手。我给一个很粗浅的类比你手上有一个组装好的乐高城堡单元测试就像是你给每一块积木拍了张照片、记录了它应该待的位置。某天你想把某个塔楼改高一点改完只需要对照照片检查一遍而不是把整个城堡拆了重新拼。放到项目里这个场景太常见了产品经理提了个需求要改动一个核心业务类的某个方法这个类被十几个地方调用。没有单元测试你只能靠“感觉”和“人工回归”有了一套覆盖良好的测试跑一遍mvn test绿了你就敢提交红了你能精确到具体哪个方法出了问题。省下来的时间远比写测试花掉的时间多得多。1.2 什么阶段补测试性价比最高我的个人经验是写单元测试有两个黄金时机。第一个是写业务代码的同时写测试也就是 Test-Driven Development 的思路哪怕你不严格遵循“先红后绿”的节奏至少保证新写的每个方法都有对应测试第二个是在修复 Bug 的时候先写一个能复现 Bug 的测试再修代码修到测试通过为止。这两个时机补测试成本最低、收益最高。反过来最不推荐的做法是等项目全部写完、马上要上线了再组织大家集中补测试。那时候你补出来的测试基本是应付差事的断言一堆没营养的东西覆盖率数字刷上去了实际上什么也没保护住。而且上线前的紧张氛围下你根本没有心思去设计好的测试用例补测试就成了纯粹的面子工程。1.3 单元测试、集成测试、端到端测试的边界在聊怎么“写”之前得先把边界划清楚。单元测试的目标是“单独验证一个类的行为”它默认不依赖数据库、不依赖 Redis、不依赖外部 HTTP 服务所有外部依赖都用 Mock 或内存替身解决。集成测试则是验证多个模块之间的协作比如 Service 层和 Mapper 层真的要连数据库跑端到端测试就更重了通常要起完整的应用环境。单元测试的特点是快、稳定、定位精确。一秒能跑几百上千个用例任何环境的差异都不会影响到它。所以 CI持续集成里最适合的频率就是每次提交都跑单元测试而集成测试可以放到夜间或者指定分支去跑。如果你发现某个“单元测试”跑一次要十几秒还连了数据库那它不是单元测试是伪单元测试这类测试迟早会变成团队的负担。2. 从零搭一套可用的单测环境2.1 JUnit 5 和 TestNG到底怎么选Java 生态里做单元测试绕不开两个框架JUnit 和 TestNG。很多新人一上来就问“哪个更好”其实这俩没有绝对的高下主要看你所在团队的存量情况。JUnit 目前的主流是 JUnit 5它相比 JUnit 4 做了大量重构引入了 Jupiter 引擎支持了更多灵活的扩展机制。Spring Boot 2.2 默认集成的就是 JUnit 5所以如果你用的是 Spring Boot 系列框架没必要折腾直接基于 JUnit 5 写就好了。TestNG 的优势在于数据驱动测试、并发执行、依赖测试这些高级特性做得比较早。早期很多传统企业的 Java 项目用的是 TestNG Spring 的组合这也就是为什么热词里有“项目单元测试集成 testng”这种搜索。如果你接手的是一个老项目里面已经有了 TestNG 的依赖和大量用例那就跟着项目走别贸然替换不然历史用例全得重写代价太大。如果你是完全新起一个项目没有历史包袱我建议你直接用 JUnit 5因为它生态最活跃和 Spring Boot、Mockito、JaCoCo 这些工具的配合最顺滑。下面我以 JUnit 5 为主来讲后面会提到 TestNG 混用时的注意事项。2.2 Maven/Gradle 依赖配置与目录结构我用 Maven 的 pom.xml 举个例子。JUnit 5 最核心的依赖是junit-jupiter它会帮你把 API、引擎和参数化测试的支持都带进来一般推荐配置 scope 为 testdependency groupIdorg.junit.jupiter/groupId artifactIdjunit-jupiter/artifactId version5.10.2/version scopetest/scope /dependency如果是在 Spring Boot 项目里通常只需要引入spring-boot-starter-test它内部已经集成了 JUnit 5、Mockito、AssertJ、Hamcrest 等测试相关组件dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-test/artifactId scopetest/scope /dependencyGradle 用户对应的配置是这样的dependencies { testImplementation org.junit.jupiter:junit-jupiter:5.10.2 } test { useJUnitPlatform() }目录结构方面Maven 的标准约定是src/test/java下面放测试类src/test/resources下面放测试资源。测试类的包名最好和被测类保持一致比如被测类是com.example.service.OrderService测试类就放在com.example.service.OrderServiceTest。这样做的目的有两个一是同包下可以访问包级私有方法二是通过 IDE 的跳转功能可以快速在类和测试之间切换。2.3 Java 环境变量配置与常见坑热词榜里长期霸榜的“java环境变量配置”放在单元测试这个话题下同样成立。很多时候测试跑不起来不是代码问题而是你的 JDK 环境混乱。单元测试对 JDK 版本敏感度其实很高尤其是你用了较高版本的语法特性比如 Java 17 的密封类、Java 21 的虚拟线程如果编译器和运行环境的版本不一致就会出各种奇奇怪怪的问题。我见过很多机器上装了多个 JDKjava -version很新但 Maven 用的JAVA_HOME却指向旧版本。这种情况下 Maven 编译不过测试自然跑不起来。配环境变量的时候关键就是把JAVA_HOME和PATH指到同一个 JDK 上。Windows 上的配置流程大致是先确认 JDK 安装路径然后右键“此电脑 → 属性 → 高级系统设置 → 环境变量”新建JAVA_HOME值填 JDK 的安装目录比如C:\Program Files\Java\jdk-17.0.10注意不要带bin子目录然后在Path变量里新增%JAVA_HOME%\bin最后打开新的命令行窗口执行java -version和javac -version两个版本一致就基本没毛病。Mac 和 Linux 上相对简单通常通过包管理器装好 JDK 后在~/.zshrc或~/.bashrc里加一行export JAVA_HOME/path/to/jdk就行。如果用了 SDKMAN它会帮你处理好这一切。常见的问题有两个。一是JAVA_HOME指向了jre目录而不是jdk目录某些构建插件会出现“tools.jar not found”之类的报错。二是环境变量配置正确但 Maven 的 settings.xml 里强制覆盖了 JDK 编译版本导致编译用的版本和运行用的版本不一致。所以遇到诡异问题的时候第一反应先跑一下mvn -version看看 Maven 到底用的哪个 JDK。2.4 一条命令跑通全部用例环境配好之后跑测试基本就是一条命令的事。Maven 项目执行mvn testGradle 项目执行gradlew test如果只想跑某一个测试类Maven 支持用-Dtest参数过滤mvn test -DtestOrderServiceTest只想跑某一个方法也可以mvn test -DtestOrderServiceTest#testCreateOrder这里注意-Dtest参数里的类名是简单类名不带包名。IDE 里跑测试更简单IntelliJ IDEA 或 Eclipse 里直接右键测试类选择 Run 或 Debug 即可断点调试单元测试也是日常操作。我第一次搭测试环境时犯过傻以为测试类必须写在 src/main/java 下面才能被测到费了好大劲把测试代码塞进主代码目录最后发现测试根本不在 classpath 里编译都过不了。后来才明白 Maven 默认的 test 阶段只会编译src/test/java下的源码主代码和测试代码是分开的。这个坑说出去显得蠢但确实很多人踩过。3. 把第一个测试写好断言、夹具与参数化3.1 命名规范和三阶段结构有了环境接下来就是写用例本身了。我以前看很多新手写的测试类方法名毫无信息量比如test1()、test2()这种命名方式等于没写。测试方法命名最理想的风格是把“被测方法 输入条件 预期结果”三要素塞进去哪怕名字长一点也没关系反正是私有方法不对外暴露。Java 方法名不能带空格常见的做法是用下划线分隔比如createOrder_whenStoackNotEnough_thenThrowException。这种命名方式在测试报告里会呈现出一串可读性很高的自然语言别人看报告的时候根本不需要翻代码就能知道每个用例验证了什么。测试方法内部的结构我习惯遵循 Given-When-Then 三阶段Given 阶段准备输入数据和前置条件When 阶段调用被测方法Then 阶段验证结果。代码写出来层次很清楚Test void createOrder_whenStockNotEnough_thenThrowException() { // Given OrderRequest request new OrderRequest(SKU-001, 100); when(stockService.getStock(SKU-001)).thenReturn(50); // When OrderService orderService new OrderService(stockService); // Then assertThrows(StockNotEnoughException.class, () - orderService.createOrder(request)); }这三个阶段之间可以用空行隔开可视性会好很多。等到后面测试多了、结构复杂了你会体会到这种结构的价值——它逼着你把“输入”“操作”“预期”分开思考很多你写着写着觉得别扭的设计问题在这个阶段就会暴露出来。3.2 断言工具不只有一种JUnit 5 自带了一套断言方法覆盖了assertEquals、assertTrue、assertFalse、assertNull、assertNotNull、assertThrows、assertTimeout这些常用场景。大部分情况下够用但它有个缺点断言失败时输出的信息比较干瘪。举个例子assertEquals失败时只会告诉你 expected 和 actual 的toString()结果。如果两个对象内部字段巨多你很难一眼看出到底哪个字段不一致。这个时候 AssertJ 的价值就出来了它走的是流式断言的风格能输出非常详细的差异信息assertThat(actualOrder) .isNotNull() .extracting(Order::getOrderNo, Order::getTotalAmount) .containsExactly(ORD-20250101-001, new BigDecimal(199.00));如果这个断言失败AssertJ 会列出实际值和期望值的完整字段对比定位问题快得多。Spring Boot 的spring-boot-starter-test默认已经带上了 AssertJ直接用就行。如果你是纯 Maven 项目单独引入assertj-core也就一个依赖的事。Hamcrest 是另一个常见的断言框架风格是 matcher 式的assertThat(actual, is(equalTo(expected)))。它的主要优势在集合和字符串的匹配器很丰富但现在 AssertJ 基本已经把它覆盖了。我的建议是不要在一个项目里混用多种断言风格统一用一种不然团队里每个人写出来都不一样代码评审的时候光争论格式都够呛。3.3 参数化测试与边界值单元测试里最值钱的其实不是“测正常逻辑”而是“测边界”。比如一个方法接收一个折扣率范围是 0 到 1正常情况你传个 0.25 测一下那如果有同事回头把代码改成了if (discount 0)呢这样折扣率传 5 也会被接受但你的测试拦不住。JUnit 5 内置了参数化测试的支持只需要在测试类上加ParameterizedTest注解并提供数据源。最常用的数据源是ValueSource适合传一组基本类型的值ParameterizedTest ValueSource(ints {0, 1, 50, 99, 100}) void calcDiscount_whenBoundaryValue_thenReturnValidResult(int percent) { BigDecimal result priceCalculator.calcDiscount(percent); assertNotNull(result); }需要传对象或更复杂的数据时可以用MethodSource从一个静态方法里读取测试数据ParameterizedTest MethodSource(provideOrders) void createOrder_whenPayTypeInvalid_thenThrowException(OrderRequest request, Class? extends Exception exceptionType) { assertThrows(exceptionType, () - orderService.createOrder(request)); } static StreamArguments provideOrders() { return Stream.of( Arguments.of(new OrderRequest(SKU-001, -1), NegativeQuantityException.class), Arguments.of(new OrderRequest(null, 10), IllegalArgumentExcepton.class) ); }很多团队测边界值时习惯写十来个几乎一模一样的测试方法然后复制粘贴改参数这个习惯其实很消耗维护成本。参数化测试把这堆东西收敛成一个方法、一张数据表后续想加边界场景只是在数据源里加一行的事代码量减少还不容易漏。3.4 用 Hook 管理测试夹具测试数据准备是单元测试里最容易被忽视、却又最影响体验的部分。一般的做法是在BeforeEach里面初始化被测对象和它依赖的 Mock 对象。BeforeEach意味着每个测试方法执行前都会跑一遍这样你的用例和用例之间互不干扰。JUnit 5 里对应的注解分别叫BeforeEach和AfterEach对应于 JUnit 4 时代的Before和After。如果有人给你一份老代码里面写的是Before那说明它用的是 JUnit 4。如果你按照 JUnit 5 的习惯去改会发现注解根本不起作用。这就是 JUnit 4 和 JUnit 5 的一个典型差异点网上搜索“JUnit 4 JUnit 5 注解区别”能找到一堆资料。如果某些初始化操作开销很大而且所有测试方法共用的话可以用BeforeAll也就是只执行一次。但一定注意BeforeAll方法默认要求是静态的因为 JUnit 5 默认每个测试方法都会创建新的测试类实例所以BeforeAll只能放在静态方法里。如果非要用非静态版本的需要加TestInstance(TestInstance.Lifecycle.PER_CLASS)注解把测试实例的生命周期改掉。真实项目里数据准备往往是麻烦最多的。接口签名没变但构造器加了一个字段你所有测试里的对象创建代码都要跟着改。这种情况的解决办法是写一个测试夹具工厂类把常用的测试对象集中生成。比如TestOrders.createValidOrder()、TestOrders.createOrderWithNoItem()这种静态工厂方法后续字段变化只需要改一个地方。4. 遇到外部依赖怎么办Mockito 与解耦实战4.1 为什么要 Mock订单服务示例业务代码很少是孤立的一个类它通常会依赖 Mapper、RedisTemplate、RestTemplate、消息生产者等等外部组件。单元测试的目的是测“这个类的逻辑”而不是测“它的依赖是否正常”所以我们必须把这些依赖替换成假的替身也就是 Mock。最常见的场景是 Service 层调 Mapper 查数据库。如果你的单元测试真的去连数据库速度慢不说还会带来一个致命问题——测试结果受数据库中的数据影响同一个用例今天跑过明天数据一变它就挂了。这种不稳定反馈会让人逐渐失去对测试的信任最终沦为摆设。Mockito 是 Java 世界最流行的 Mock 库在 Spring Boot 项目里它是默认自带的。它的做法很简单创建一个虚假对象然后定义这个虚假对象在某种参数下返回什么结果最后验证这个虚假对象被哪些方法调用过、调用了几次。拿一个创建订单的 Service 来举例。它依赖StockServicecreateOrder方法需要先查库存库存不足就抛异常库存够就扣减库存、计算金额、保存订单public class OrderService { private final StockService stockService; private final OrderRepository orderRepository; public OrderService(StockService stockService, OrderRepository orderRepository) { this.stockService stockService; this.orderRepository orderRepository; } public Order createOrder(OrderRequest request) { int stock stockService.getStock(request.getSkuId()); if (stock request.getQuantity()) { throw new StockNotEnoughException(库存不足); } stockService.deductStock(request.getSkuId(), request.getQuantity()); Order order new Order(request.getSkuId(), request.getQuantity()); return orderRepository.save(order); } }测试时我们不希望它真的连数据库也不希望真的扣库存就可以用 Mock 把依赖都替换掉ExtendWith(MockitoExtension.class) class OrderServiceTest { Mock private StockService stockService; Mock private OrderRepository orderRepository; InjectMocks private OrderService orderService; Test void createOrder_whenStockEnough_thenSaveOrderAndReturn() { when(stockService.getStock(SKU-001)).thenReturn(100); OrderRequest request new OrderRequest(SKU-001, 2); Order savedOrder new Order(ORD-001, SKU-001, 2); when(orderRepository.save(any())).thenReturn(savedOrder); Order result orderService.createOrder(request); assertThat(result).isNotNull(); verify(stockService, times(1)).deductStock(SKU-001, 2); verify(orderRepository, times(1)).save(any(Order.class)); } }这里我顺便解释一个关键点InjectMocks会把标了Mock的依赖通过构造器注入到被测对象里。它背后的原理是优先用构造器注入找不到合适的构造器再尝试 setter 注入和字段注入。所以被测类最好用构造器注入而不是字段注入这既是 Spring 官方推荐的做法也能让单元测试写起来更顺畅。4.3 静态方法、构造器的 Mock 策略Mockito 老版本有个硬伤它 mock 不了静态方法也 mock 不了 final 类和构造器。所以网上很多老教程会告诉你“能用依赖注入就用依赖注入别碰静态方法”。后来 Mockito 出了 inline mock maker静态方法也能 mock 了但用起来还是有一些限制。如果你是在 Spring Boot 2.x Mockito 3.x 的项目里想 mock 一个工具类的静态方法需要这样写try (MockedStaticIdGenerator mocked mockStatic(IdGenerator.class)) { mocked.when(IdGenerator::nextId).thenReturn(MOCK-ID-001); String orderNo orderService.generateOrderNo(); assertThat(orderNo).isEqualTo(MOCK-ID-001); }注意这里必须把mockStatic放在 try-with-resources 里因为MockedStatic实现了AutoCloseable。如果不关掉这个静态 Mock它会一直占用这个类的历史影响后续跑到的其他测试这也是很多测试类之间“互相打架”的隐藏原因之一。我的个人建议是能用构造器注入和接口解耦的就尽量别依赖静态 Mock。静态方法 mock 的 API 比较繁琐执行性能和稳定性也弱于普通 Mock而且如果被测类里大量使用静态方法说明代码本身的设计有坏味道重构的优先级应该高于写测试。4.4 RedisTemplate 自增方法报错排障热词里有一条很具体“java使用redistemplate将redis的数减一”、“increment()报错不是integer or out of range”。这个坑我在项目里也真实遇到过。场景是这样的一个用户积分模块每次用户签到要给某个 key 加一取消签到要减一。加一的时候用redisTemplate.opsForValue().increment(key)很正常但减一的时候如果用原生 Redis 命令就会遇到一个细节问题Redis 的字符串值是有类型约束的如果当前值为 0你再decrement一下理论上应该变成 -1Redis 支持这样操作。但如果你的 key 不存在increment(key, -1)会自动初始化为 0 再减 1也就是得到 -1。问题出在很多业务方其实希望“小于 0 时报错”或者认为“减到 0 就不允许再减”。但 Redis 本身并不关心你的业务规则它只会老老实实地算。所以你在单元测试里 mockRedisTemplate的时候如果 mock 的increment方法没有模拟真实的“类型异常”行为业务代码里的异常分支就永远测不到。实际排障过程一般是这样的先用redis-cli直接执行DECR key确认命令本身没问题然后用TYPE key检查 key 的类型。如果 key 已经存在但是 String 类型且值不是整数DECR就会返回ERR value is not an integer or out of range。比如之前有人用set命令把一个字符串abc塞了进去再执行DECR肯定报错。这个问题的根源根本不是代码逻辑而是数据被污染了。所以你在测试里要覆盖的正是这种“数据异常”的场景而 Mockito 给你提供了thenThrow能力去模拟when(redisTemplate.opsForValue().increment(eq(user:sign:1), eq(-1L))) .thenThrow(new RedisSystemException(ERR value is not an integer or out of range, new RuntimeException()));写这个测试的意义在于让你自己明确RedisTemplate 是可能抛异常的业务代码必须对异常做兜底比如打日志、回滚事务、返回友好提示。很多线上事故都是因为开发默认“Redis 永不失败”结果 Redis 抖动一下整条链路就挂了。5. 测试覆盖率的正确打开方式5.1 覆盖率指标怎么读覆盖率是单元测试绕不开的话题面试也经常问。它本身是个数字但很多人对这个数字的理解有偏差。覆盖率常见的有行覆盖率Line Coverage、分支覆盖率Branch Coverage、方法覆盖率Method Coverage和类覆盖率Class Coverage。行覆盖率是“代码中多少行被执行了”分支覆盖率是“if/else 的分支中有多少分支被执行了”。行覆盖率很容易刷。你写一个测试把某个方法完整调用一遍里面所有行都执行了行覆盖率就是 100%。但如果方法里有if (a 0) { ... } else { ... }你的测试只覆盖了a 0为真的分支另一个分支没走到行覆盖率却可能是 100%——因为else里的语句直接没有出现在行覆盖统计里或者也会统计进去这时候分支覆盖率就会暴露问题。所以我的建议是不要只盯行覆盖率至少同时关注行覆盖率和分支覆盖率。很多公司的硬性指标是行覆盖率达到 80% 以上但真正有追求的项目会要求分支覆盖率达到 70% 以上。覆盖率数字不是越高越好90% 以上的覆盖率维护成本已经开始飙升性价比不一定划算。重点模块加大密度简单 CRUD 类的测试只需保证正常路径即可。5.2 哪些代码必须覆盖全覆盖既不现实也没必要但有几类代码我强烈建议必须覆盖一是工具类。工具类通常都是纯静态方法没有外部依赖写出来又快又稳覆盖了能有效防止后续做所谓“优化”时把边界条件改坏。二是核心领域服务。比如订单金额计算、库存扣减、优惠券核销这些业务逻辑最容易出错也最难排查。它们出错往往意味着钱算错了、库存扣多了出一次事故就要损失大量口碑。这类代码不仅要有测试还必须有边界测试和异常测试。三是复杂条件判断。比如一个业务规则里有五六个if嵌套、或者很长的switch一定要保证每个分支都被走到过。这类代码可读性差改动频率高没有人敢保证改完不会碰坏某条分支。四是自定义异常处理逻辑、定时任务里的任务分发逻辑。这些地方平时不出问题一出问题就是线上故障单元测试能帮你提前验证流程是否跑得通。5.3 接入 JaCoCo 并解读报告Java 生态里做覆盖率统计JaCoCo 是事实标准。它可以和 Maven、Gradle、Jenkins 无缝集成。Maven 项目接入 JaCoCo 很简单在 pom.xml 里加一个 pluginplugin groupIdorg.jacoco/groupId artifactIdjacoco-maven-plugin/artifactId version0.8.11/version executions execution goals goalprepare-agent/goal /goals /execution execution idreport/id phasetest/phase goals goalreport/goal /goals /execution /executions /plugin执行mvn test之后在target/site/jacoco/index.html会生成一份 HTML 报告浏览器打开就能看到包、类、方法级别的覆盖率明细。JaCoCo 还会用不同颜色标注代码行绿色表示已被执行红色表示没执行黄色表示部分执行。这个红黄绿的视图非常直观一眼能看出哪些行是裸奔状态。如果你的 CI 用的是 Jenkins 或 GitLab CI还可以把 JaCoCo 的exec文件和xml报告集成进去在 MR 审核时自动提示本次提交的覆盖率变化。少数核心模块可以加上覆盖率阈值检查比如订单模块的覆盖率低于 80%整个构建直接失败用“硬门槛”逼着开发者补测试。但这个门槛建议分批实施别一上来就对全工程生效不然老代码让你永远合不进去的需求会把人逼疯。6. 常见问题与排查技巧实录6.1 NoClassDefFoundError / ClassNotFoundException热词里有条很典型的报错“uncaught exception java.lang.noclassdeffounderror: java/applet/applet”。这个报错让人看着脑壳疼但说到底就是一个问题运行测试时 classpath 里找不到某个类。排查思路分几步。第一步看是不是 JDK 版本问题比如java.applet.Applet是旧版 JDK 里的模块新版 JDK 直接把这个模块干掉了你的代码如果还在用老 API编译都过不了第二步看是不是依赖缺失比如你把junit-jupiter配在了 compile 范围但没配 test 范围IDE 里可能直接编译报错第三步检查是不是动态代理生成类时的类加载器问题特别是用 Lambda、反射、CGLIB 代理的场景。这类问题最好的预防方式是让团队统一用一个 JDK 版本用.sdkmanrc或者 Maven Toolchain 把版本固化下来。项目里的pom.xml也建议固定maven.compiler.source和maven.compiler.target避免各跑各的。6.2 Lombok 与编译器不兼容另一个高频问题you arent using a compiler supported by lombok, so lombok will not work。这个报错经常出现在 IDE 升级、JDK 升级之后Lombok 旧版本不识别新的编译环境。解决方案通常是升级 Lombok 的版本。比如 JDK 17 环境Lombok 建议用 1.18.30 以上JDK 21 环境建议用 1.18.30 以上的新版本因为 Lombok 的注解处理器需要适配新 JDK 的内部 API。如果升级后还是报错看看 IDE 是否启用了注解处理器。IntelliJ IDEA 里的设置路径是 Settings → Build, Execution, Deployment → Compiler → Annotation Processors勾选 Enable annotation processing。6.3 测试之间互相污染测试类之间互相影响是个很隐蔽的坑。表现是单个测试类跑全绿整个测试套件一跑就挂。常见的污染源有几个一是静态变量或单例对象状态没清理比如一个 Mock 静态工厂的返回结果被上一个测试改了二是时间相关逻辑比如用了System.currentTimeMillis()而测试 A 把系统时间 mock 了没恢复三是数据库或 Redis 里的脏数据不过这种情况通常说明它是个伪单元测试应该优先把外部依赖 mock 掉。根治办法是每个测试方法尽量独立BeforeEach里把所有需要的前置状态准备好AfterEach里把可能影响后续用例的状态清掉。Mockito 的ExtendWith(MockitoExtension.class)默认会在每个测试后重置 mock所以普通 mock 的状态一般不用太操心重点盯静态 Mock 和手动 new 出来的全局对象。6.4 时间、随机数、并发怎么测业务代码里跟时间、随机数、并发沾边的逻辑测起来比较麻烦。比如一个优惠活动在 2025-01-01 00:00:00 开始生效你总不能在测试里真的等到那个时间点。常规做法是抽象一个Clock接口或直接用java.time.Clock测试时注入固定时间的 Clock。Java 8 的java.time.Clock是可以直接替换的Instant.now(clock)这种方式就能接受外部传入的 Clock。如果你的代码到处写着System.currentTimeMillis()那测试这类逻辑的时候只能靠 Mockito 的mockStatic(System.class)去 mock 静态方法但这种方式维护代价高不如一开始就把时间源抽象出来。并发相关的逻辑单元测试的验证能力比较有限。assertTimeout只能让你在指定时间内完成操作但不能证明线程是安全的。真实可靠的并发测试还是要靠压测、集成测试、或者专门的并发测试框架单元测试里能做到的只是验证某个同步组件的基本行为不越界。6.5 TestNG 与 JUnit 混用踩坑如果一个项目里的存量测试用 TestNG新代码用了 JUnit 5并且你在同一个模块里同时跑两套最常见的问题是执行顺序和注解冲突。有些团队习惯把Test注解的导入写错比如 JUnit 5 的org.junit.jupiter.api.Test和 TestNG 的org.testng.annotations.Test混了IDE 不会报错但执行引擎会找不到对应的用例或者报一些匪夷所思的错。我的建议是存量测试框架不动但新代码尽量统一到同一套框架里。迁移的话也不要一把梭可以在一个独立的包或模块里先跑等稳定了再逐步替换。如果你的项目里必须共存两套记得 Maven Surefire Plugin 的useJUnitPlatform参数和 TestNG 的配置可能会互相干扰最好查看一下测试报告确认两类用例是否都被执行。最后再分享一点个人体会写了几年单元测试我的心态经历了“不想写”到“被迫写”再到“主动写”的过程。现在回头看相比测试代码本身更值钱的是写测试过程中逼着你把业务逻辑想清楚的那股劲。一个类如果很难测试通常意味着它耦合太重、职责太多这本身就是代码需要重构的信号。如果你现在正处在一个没有测试的老项目里别想着一步到位把所有代码都补上测试。先挑最核心、最容易出错、改动最频繁的模块下手把核心路径和边界条件锁住其他的慢慢来。哪怕只锁住了几个核心类效果也会很明显——至少下次重构的时候你有了一层安全网。最后留个小技巧如果团队里有人觉得写测试太枯燥可以把覆盖率报告同时发给项目经理看一眼。数字永远是最好沟通的语言覆盖率从 20% 涨到 80%你说“代码质量提升了”对方不一定有感觉但一张红转绿的覆盖率报告摆在那大家都会有直观判断。这也算是测试驱动质量意识的一种“曲线救国”吧。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门