Java单元测试实战:JUnit 5与Mockito关键实践
发布时间:2026/10/1 12:53:11来源:尧图网络
Java项目里写不写单元测试很多时候不是技术问题是习惯问题。我见过不少团队代码规范、Code Review、灰度发布样样齐全唯独 test 目录里躺着三个祖传的测试类其中一个还标着 Ignore注释写着临时关闭回头修。结果就是每次改核心逻辑只能靠人肉回归改一个金额计算得把下单、退款、对账三条链路手动跑一遍半小时起步。单元测试要解决的就是这件事把我以为它是对的变成有证据证明它是对的而且是能在本地一秒钟跑完的那种证据。这篇文章面向的是有一定Java基础、但测试写得不多的人也适合那些写了测试却总觉得没什么用、维护成本还高的同学。我会从JUnit 5和Mockito的实际搭配讲起把注解、断言、Mock、参数化、覆盖率、CI集成都过一遍重点放在那些文档里不写、但实操中一定会踩的坑上。读完你应该能独立给自己的Service层写出结构清晰、可维护、跑得快的单元测试。1. 单元测试到底在测什么先把边界划清楚写Java单元测试第一步走偏的人特别多——把测试当成一个整体概念结果写出来的东西既不像单元测试也不像集成测试跑一次要三十秒改一行代码挂五个用例。要避免这种局面得先把边界划出来知道自己现在写的是哪一层它应该依赖什么、不应该依赖什么。1.1 单元测试、集成测试、端到端测试的三层分工我习惯用切蛋糕的方式理解这三层。单元测试切的是最小的一块——通常是一个类里的一个public方法被测对象之外的一切数据库、缓存、HTTP客户端、消息队列、系统时间全部用测试替身顶掉。它的特征是毫秒级、无网络、无IO、可重复、结果只取决于输入。集成测试切的是几块蛋糕怎么拼在一起比如MyBatis的SQL能不能跑通、序列化反序列化是否对得上、Spring容器里的Bean能不能正确注入。端到端测试则是把整条链路从入口打到出口验证的是业务闭环。这三层的成本曲线完全相反。单元测试写起来最便宜、跑起来最快、出问题时定位最准但它证明不了系统真的能跑。端到端测试恰好相反。所以健康的比例大致是单元测试占了绝大多数集成测试做关键路径的抽查端到端只留少数几条主干。很多团队反过来做全靠端到端兜底那就必然慢、必然脆。判断一个测试是不是单元级别有个特别简单的检验方式把网线拔了把数据库停了把Redis关了测试还能不能全绿能就是单元测试。不能那就是集成测试别把它塞进单元测试的目录里也别指望它跑得快。1.2 Java生态为什么把单元测试做得这么顺手Java的测试生态在主流语言里算是非常成熟的这不是偶然。JVM有完整的反射能力能在运行时读取注解、替换字段、生成代理对象字节码可以在类加载阶段被改写这才让Mockito能对接口之外的类做增强再加上Maven、Gradle把依赖管理和构建生命周期标准化了测试才变成了加个依赖、敲个命令这么简单的事。具体到工具层面JUnit负责定义测试的结构和生命周期AssertJ这类断言库负责让校验语句读起来像人话Mockito负责造替身Testcontainers负责在集成测试里拉起真实的中间件容器JaCoCo负责统计覆盖率。它们各司其职组合起来才是一套完整的方案。新手常见的问题是只会用JUnit自带的assertEquals写出来的断言全是expected true but was false挂了之后完全不知道业务上哪一步错了。这个问题后面第三节会专门讲怎么解决。2. 工具链怎么选JUnit 5 Mockito AssertJ 的实战组合工具选型这件事我踩过最深的坑是版本对不上。JUnit 5的早期版本和Mockito的老版本放一起ExtendWith能识别但Mock不注入报错信息还特别含糊查半天才发现是mockito-junit-jupiter这个桥接包没引。所以这一节先把选型和依赖理清楚后面写代码才不会莫名其妙卡住。2.1 JUnit 4 还是 JUnit 5迁移的决策点新项目没有任何悬念直接用JUnit 5。老项目要不要迁我的判断标准是看三件事测试类数量是否超过两百个、是否有大量依赖RunWith的自定义Runner、团队是否近期有大的重构计划。如果测试规模不大迁移成本其实很低因为JUnit 5提供了junit-vintage-engine可以让新旧测试共存不用一次性改完。JUnit 5相比4的实质提升有几个。一是注解语义更清楚BeforeEach/AfterEach替代了Before/After不用再纠结Before是类级还是方法级。二是原生支持参数化测试和动态测试ParameterizedTest配合MethodSource写起来非常顺。三是扩展机制换成Extension可以组合多个扩展不像Runner那样只能指定一个。四是断言支持lambda延迟求值失败时的性能开销更小。需要注意的是Spring Boot从2.4开始spring-boot-starter-test默认带的就是JUnit 5同时把vintage引擎也带上了。如果你发现自己的Test怎么都不生效先确认导包导的是org.junit.jupiter.api.Test还是org.junit.Test这两个导错了不会报错只会静默不执行这个坑我见过太多次。2.2 一份可以直接抄的依赖清单非Spring项目用Maven的话pom.xml里加这么几段就够了。版本号建议用dependencyManagement统一管别散落在各处。properties junit.version5.10.2/junit.version mockito.version5.11.0/mockito.version assertj.version3.25.3/assertj.version /properties dependencies !-- 测试框架本体包含 api、params、engine 三个模块 -- dependency groupIdorg.junit.jupiter/groupId artifactIdjunit-jupiter/artifactId version${junit.version}/version scopetest/scope /dependency !-- Mockito 核心 -- dependency groupIdorg.mockito/groupId artifactIdmockito-core/artifactId version${mockito.version}/version scopetest/scope /dependency !-- Mockito 与 JUnit 5 的桥接提供 ExtendWith(MockitoExtension.class) 支持 -- dependency groupIdorg.mockito/groupId artifactIdmockito-junit-jupiter/artifactId version${mockito.version}/version scopetest/scope /dependency !-- 流式断言让校验语句具备可读性 -- dependency groupIdorg.assertj/groupId artifactIdassertj-core/artifactId version${assertj.version}/version scopetest/scope /dependency /dependencies如果是Spring Boot项目直接引spring-boot-starter-test就好它把JUnit Jupiter、Mockito、AssertJ、Hamcrest、JSONassert、Spring Test全都打包了省事。Gradle项目对应的是testImplementation写法差别不大这里不重复。提一句mockito-inline。从Mockito 5开始inline mock maker已经是默认实现所以mock静态方法、final类、final方法都不需要额外换依赖了。如果你还在用Mockito 3.x或4.x想mock静态方法就得把mockito-core换成mockito-inline这个细节在升级时容易被忽略。2.3 Mockito、AssertJ、Testcontainers 各自的适用边界这三个东西经常被混着用其实边界很清楚。Mockito解决的是依赖不可控的问题。当被测类依赖了远程接口、数据库、时间、随机数你就需要造替身。它的核心概念只有两个stub和verify。stub是当调用这个方法时返回什么verify是确认这个方法被调用了几次、带了什么参数。剩下的注解、ArgumentCaptor、Answer都是围绕这两个概念的语法糖。AssertJ解决的是断言说不清楚的问题。JUnit自带的断言在集合、异常、对象字段比较上很笨拙。AssertJ的流式API能写出assertThat(order.getItems()).hasSize(3).extracting(OrderItem::getSku).containsExactly(A001, B002)这种一眼看懂意图的语句失败时的报错信息也详细得多。Testcontainers解决的是集成测试需要真实中间件的问题。它能在测试启动时拉起一个Docker容器跑MySQL、Redis、Kafka测试结束自动销毁。注意它属于集成测试的范畴别用它去替代单元测试里对Repository的mock否则一个测试类跑起来要五秒以上回归时你会想砸键盘。3. 从零写一个能长期活下去的测试用例前面铺垫完了这一节直接上代码。我准备用一个订单创建的Service做例子涉及金额计算、会员折扣、ID生成、持久化保存基本覆盖了日常业务代码里会遇到的典型依赖类型。先看被测类public class OrderService { private static final BigDecimal VIP_DISCOUNT new BigDecimal(0.9); private static final BigDecimal FREE_SHIPPING_THRESHOLD new BigDecimal(199); private final OrderRepository orderRepository; private final MemberService memberService; private final IdGenerator idGenerator; public OrderService(OrderRepository orderRepository, MemberService memberService, IdGenerator idGenerator) { this.orderRepository orderRepository; this.memberService memberService; this.idGenerator idGenerator; } public Order createOrder(Long memberId, ListOrderItem items) { if (items null || items.isEmpty()) { throw new IllegalArgumentException(订单明细不能为空); } BigDecimal total items.stream() .map(i - i.getPrice().multiply(BigDecimal.valueOf(i.getQuantity()))) .reduce(BigDecimal.ZERO, BigDecimal::add); if (memberService.isVip(memberId)) { total total.multiply(VIP_DISCOUNT).setScale(2, RoundingMode.HALF_UP); } boolean freeShipping total.compareTo(FREE_SHIPPING_THRESHOLD) 0; Order order new Order(idGenerator.next(), memberId, total, freeShipping); return orderRepository.save(order); } }构造函数注入是这里的关键设计。如果OrderService用的是字段注入或者直接在方法里new一个new OrderRepositoryImpl()那就很难做单元测试。所以代码可测性这件事一半的功夫其实花在设计阶段而不是测试阶段。3.1 命名与 Given-When-Then 结构测试方法名我推荐两种写法二选一但团队要统一。一种是下划线式用should_xxx_when_yyy读起来像一句英文另一种是中文DisplayName配上简短的方法名。我个人的习惯是两者都上方法名保持英文简洁DisplayName写中文业务语义这样在IDE的测试报告里看中文在代码里看英文各取所需。结构上坚持Given-When-Then三段。given段准备数据和桩when段只放一行被测方法的调用then段只放断言。这个约束看起来死板但价值巨大——当某个测试挂了你能立刻定位到是数据准备错了、调用参数错了还是结果不符合预期。我见过太多把断言穿插在准备过程里的测试挂了之后要从头读一遍才知道哪出问题。ExtendWith(MockitoExtension.class) DisplayName(OrderService 创建订单) class OrderServiceTest { Mock private OrderRepository orderRepository; Mock private MemberService memberService; Mock private IdGenerator idGenerator; InjectMocks private OrderService orderService; Test DisplayName(普通会员下单总价等于各明细金额之和) void should_sum_item_amount_for_normal_member() { // given ListOrderItem items List.of( new OrderItem(A001, new BigDecimal(10.00), 2), new OrderItem(B002, new BigDecimal(5.50), 1) ); when(memberService.isVip(1001L)).thenReturn(false); when(idGenerator.next()).thenReturn(ORD-20240601-0001); when(orderRepository.save(any(Order.class))).thenAnswer(inv - inv.getArgument(0)); // when Order order orderService.createOrder(1001L, items); // then assertThat(order.getTotalAmount()).isEqualByComparingTo(25.50); assertThat(order.isFreeShipping()).isFalse(); assertThat(order.getOrderNo()).isEqualTo(ORD-20240601-0001); } }注意金额断言用的是isEqualByComparingTo而不是isEqualTo。BigDecimal的equals方法会连scale一起比new BigDecimal(25.50)和new BigDecimal(25.5)用equals是不相等的这个坑几乎每个Java程序员都踩过一次。用isEqualByComparingTo等价于compareTo 0只比数值大小稳得多。3.2 被低估的生命周期注解BeforeEach和BeforeAll的区别大家都知道但实际操作中有两个细节值得说。BeforeAll修饰的方法必须是static的除非测试类标注了TestInstance(Lifecycle.PER_CLASS)。如果你在BeforeAll里初始化了一个数据库连接或者启动了某个资源AfterAll里必须对称地关掉否则在多模块构建时会留下悬挂进程。还有一个容易忽略的点测试类的实例化时机。JUnit 5默认每个测试方法都会new一个新的测试类实例也就是说成员变量在方法之间是不共享的。这个设计是为了保证测试的独立性但也意味着你不能在某个测试方法里改字段、指望下一个方法能读到。要共享状态就得用static或者BeforeAll。理解这一点为什么我的Mock字段在第二个方法里是null这种问题就不会再出现了——它其实不是null只是每个方法拿到的是不同的实例桩重新配了一遍而已。3.3 Mock 的正确姿势Mock、InjectMocks、CaptorMock创建替身InjectMocks把替身按类型或名字注入到被测对象里。InjectMocks的注入顺序是先构造函数、再setter、最后字段所以推荐被测类用构造函数注入这样注入最可靠也避免反射改字段带来的副作用。Captor的用途是捕获实际传入的参数做更细的校验。比如你要验证保存到数据库的Order里订单号格式是否正确、会员ID有没有传对这时候就得把参数截下来看。Captor private ArgumentCaptorOrder orderCaptor; Test DisplayName(VIP会员下单按九折计算并把订单号写入持久化对象) void should_apply_vip_discount() { // given ListOrderItem items List.of(new OrderItem(A001, new BigDecimal(100.00), 2)); when(memberService.isVip(2002L)).thenReturn(true); when(idGenerator.next()).thenReturn(ORD-20240601-0002); when(orderRepository.save(any(Order.class))).thenAnswer(inv - inv.getArgument(0)); // when orderService.createOrder(2002L, items); // then verify(orderRepository).save(orderCaptor.capture()); Order saved orderCaptor.getValue(); assertThat(saved.getTotalAmount()).isEqualByComparingTo(180.00); assertThat(saved.getMemberId()).isEqualTo(2002L); assertThat(saved.isFreeShipping()).isFalse(); assertThat(saved.getOrderNo()).isEqualTo(ORD-20240601-0002); }这里有个必须说的取舍能不用verify就不用。verify断言的是实现细节一旦你把save方法重构成batchSave测试就挂了哪怕业务行为完全没变。优先校验返回值返回值不足以表达意图时再用ArgumentCaptor。至于verify(orderRepository, times(2)).save(...)这种更是脆弱测试的重灾区写之前先问问自己业务上是否真的关心被调用了两次。3.4 参数化测试与边界值设计金额计算这类逻辑最容易出问题的不是正常值是边界值。参数化测试能把一堆相似用例压缩成一份数据表改起来非常舒服。ParameterizedTest(name 明细合计 {0} 元VIP 折扣后应为 {1} 元) CsvSource({ 0.00, 0.00, 0.01, 0.01, 198.99, 179.09, 199.00, 179.10, 1000.00, 900.00 }) DisplayName(VIP 折扣与包邮门槛的边界验证) void should_calculate_vip_discount_on_boundary(String rawAmount, String expected) { BigDecimal price new BigDecimal(rawAmount); ListOrderItem items List.of(new OrderItem(A001, price, 1)); when(memberService.isVip(3003L)).thenReturn(true); when(idGenerator.next()).thenReturn(ORD-TEST); when(orderRepository.save(any(Order.class))).thenAnswer(inv - inv.getArgument(0)); Order order orderService.createOrder(3003L, items); assertThat(order.getTotalAmount()).isEqualByComparingTo(expected); }注意这里我特意把折扣后的金额算准198.99 × 0.9 179.091setScale(2, HALF_UP)之后是179.09199.00 × 0.9 179.10。这两个值刚好卡在包邮门槛的两侧是典型的边界用例。写参数化数据时把期望值当成手算结果认真推一遍别直接跑一遍看输出是多少就填多少那样测试就变成了记录当前行为而不是验证正确行为这两者的价值差得很远。4. 覆盖率、TDD 与测试替身的取舍写完测试之后团队里一定会有人问覆盖率多少算够这个问题没有统一答案但有些判断标准可以分享能帮你避开几个典型的思维误区。4.1 覆盖率数字的三个陷阱第一行覆盖率高不等于逻辑覆盖全。一个if-else只要每个分支里至少有一行被执行行覆盖就是100%但完全可能只测了true分支false分支靠的是恰好没抛异常。真正有参考价值的是分支覆盖JaCoCo里对应的是BRANCH计数器。第二覆盖率统计的是被测试执行过的代码不是被断言验证过的代码。我见过测试方法里调用了一堆逻辑最后只assertNotNull一下覆盖率照样100%。这种测试提供了虚假的安全感比没有测试更危险因为它会让人放松警惕。第三全局覆盖率指标往往会掩盖问题。一个项目整体80%但核心的金额计算模块只有30%剩下的都是Getter、Setter、DTO转换撑起来的。合理的做法是给关键包设单独的阈值用JaCoCo的check goal按包配置。plugin groupIdorg.jacoco/groupId artifactIdjacoco-maven-plugin/artifactId version0.8.12/version executions execution idcheck-core/id goals goalcheck/goal /goals configuration rules rule elementPACKAGE/element includes includecom.example.order.core.*/include /includes limits limit counterBRANCH/counter valueCOVEREDRATIO/value minimum0.75/minimum /limit /limits /rule /rules /configuration /execution /executions /plugin4.2 哪些代码不值得写单元测试这条经验是我被维护成本教育出来的不是所有代码都配得上一份单元测试。纯粹的DTO、VO、枚举、常量类、只做字段透传的Getter和Setter测它们等于给自己增加工作量。IDE生成的equals和hashCode也一样除非你对它做了手写改造。真正值得投入的是这几类金额、税率、积分、库存这类计算逻辑状态机流转和分支复杂的条件判断对外部输入做校验和清洗的部分历史上出过线上问题的代码。最后一类特别重要每次线上故障修完之后补一个测试几个月下来你会发现核心模块的测试网自然就密起来了。还有一类是千万别测的框架自身的行为。比如测Spring的依赖注入能不能成功、测MyBatis能不能把结果映射到对象这些属于验证框架不是验证你的代码出了问题也是配置问题不该由单元测试来发现。4.3 遗留代码补测试的两种切入口接手一堆没有测试的老代码想补测试但构造函数里全是new、静态方法调用、单例怎么办直接重构风险太大我的做法是用两个模式渐进式推进。第一个是Sprout Method。不改动原有逻辑先写一个新的方法把你想验证的新逻辑放进去给新方法写测试然后再把老代码里的调用点替换过来。这样新逻辑永远在测试保护之下老逻辑保持不变风险可控。第二个是寻找接缝Seam。如果老代码里直接调用了某个静态工具类你没法mock那就在调用点附近引入一个可注入的接口把静态调用包一层。一开始接口的实现就是直接委托给原来的静态方法行为完全不变之后你就能在测试里注入一个假实现。这个过程可以一个方法一个方法地做不需要一次性大重构。5. 报错与排查实录那些让人抓头的异常测试写多了遇到的问题会高度集中在几类上。我把它们整理成表配上排查思路遇到时对着看就行。报错信息关键字常见成因排查与解决UnnecessaryStubbingException配了桩但该次测试没用到确认桩是否真的被调用共用桩改用 lenient() 或移到 BeforeEach 里按需配置PotentialStubbingProblem桩的参数与实际调用参数不匹配检查 equals 实现注意包装类型和自动拆箱必要时用 any() 匹配NullPointerException 出现在 InjectMocks 对象里注入失败字段仍是 null确认有 ExtendWith(MockitoExtension.class)字段类型是否匹配构造函数参数名是否可推断InvalidUseOfMatchersException参数里混用了具体值和 matcher要么全部用具体值要么全部用 any()/eq() 这类 matcherMissingMethodInvocationExceptionwhen() 里的方法不是 mock 对象的方法检查是不是调用了真实对象或者对象没被 Mock 标注ClassNotFoundException: org.junit.Test导包导错混用了 JUnit 4 和 5统一改为 org.junit.jupiter.api.Test5.1 Mockito 的几类经典异常UnnecessaryStubbingException是最烦人但也是最有用的一类。Mockito 2之后默认开启严格模式只要你在某个测试方法里配了桩但没用到就报错。很多人第一反应是加lenient()关掉但我的建议是先想清楚为什么会配了用不到通常是因为把多个测试方法共用的桩全塞进了BeforeEach而每个方法只用到其中一部分。这种情况下把桩下沉到各自的方法里既解决报错也让每个测试的意图更清晰。PotentialStubbingProblem更隐蔽。比如你写了when(repo.findById(1L)).thenReturn(x)但实际调用时传进来的Long是另一个对象——虽然值相等如果equals没实现好就不匹配。还有一种常见情况是参数类型是long你传了1int自动装箱后变成Long.valueOf(1)但如果方法签名是Integer就匹配不上。遇到这类问题先用ArgumentCaptor把真实参数打出来看一眼比对着文档猜快十倍。5.2 静态方法、final类与私有方法Mockito 5默认的inline mock maker支持mock静态方法写法是Mockito.mockStatictry (MockedStaticClock mocked Mockito.mockStatic(Clock.class)) { mocked.when(() - Clock.systemDefaultZone()).thenReturn(Clock.fixed(instant, zone)); // 这里的被测代码会拿到固定的时间 }不过我更推荐的方案是不要mock静态而是把时间抽象成Clock注入到被测类里。java.time包里已经提供了Clock类它的设计初衷就是给测试用的。注入Clock的写法测试里传Clock.fixed(...)生产代码里传Clock.systemDefaultZone()干净得多也不用担心mockStatic的作用域问题。私有方法不要测也不要用反射硬测。私有方法是实现细节它应该通过public方法的测试被间接覆盖。如果你发现某个private方法逻辑很复杂、不单独测不放心那说明它应该被提取成一个独立的类然后通过正常方式测试。至于final类Mockito 5能处理但能不mock就不mock优先考虑面向接口编程。5.3 环境与构建层面的坑有几个坑和测试逻辑无关但排查起来特别耗时。一个是时区。LocalDateTime.now()在不同时区、不同机器上结果不同测试里如果对时间做断言一定要注入固定的Clock或者用断言范围而不是精确值。另一个是字符编码。Maven的surefire插件默认编码如果不配在Windows机器上可能就是GBK读含中文的测试资源文件会乱码建议在pom里显式配置project.build.sourceEncoding为UTF-8。还有Lombok的问题。如果你的实体用了Data而IDE或者编译插件版本不匹配可能出现找不到getter方法的编译错误。这时候先检查IDEA是否开启了Annotation Processing再确认lombok版本和JDK版本是否兼容——新版本JDK上老版本Lombok会直接罢工。另外测试并行执行时如果Lombok生成的懒加载逻辑不是线程安全的也可能出现偶发失败这个要靠重复运行来复现。6. 工程化落地让测试在CI里跑得又快又稳单个测试写得好不难难的是几百个测试类放在一起还能在几十秒内跑完并且结果稳定。这一节讲的是工程层面的配置和团队习惯。6.1 Surefire 与 Failsafe 的分工Maven里有两个测试插件分工很清楚surefire在test阶段执行负责跑单元测试failsafe在verify阶段执行负责跑集成测试。命名的约定是单元测试类以Test结尾如OrderServiceTest集成测试类以IT结尾如OrderRepositoryIT。这个约定不是强制的但遵守它能让命令行的操作变得很自然mvn test只跑单元测试mvn verify跑全量。CI流水线上把这两步分开单元测试失败直接拦截合并集成测试失败可以标记为需要人工介入节奏就很好控制了。plugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-surefire-plugin/artifactId version3.2.5/version configuration includes include**/*Test.java/include include**/*Tests.java/include /includes excludes exclude**/*IT.java/exclude /excludes /configuration /plugin6.2 并行执行与执行时间预算JUnit 5支持并行执行需要在src/test/resources下放一个junit-platform.propertiesjunit.jupiter.execution.parallel.enabledtrue junit.jupiter.execution.parallel.mode.defaultconcurrent junit.jupiter.execution.parallel.config.strategyfixed junit.jupiter.execution.parallel.config.fixed.parallelism4开启并行之前必须先确认测试之间没有共享状态。判断方法很简单连续跑三次全量测试如果有任何一次结果不同那就是有状态污染。常见的污染源包括静态变量、单例里的缓存、依赖当前时间的断言、写同一个临时文件。并行度不要设成CPU核数的两倍以上否则上下文切换反而拖慢速度实践中4到8是个比较稳的区间。给测试定一个时间预算是很有用的习惯。我一般要求单元测试全量在30秒内跑完超过5秒的单个测试类要单独拎出来看原因——通常是有人在单元测试里连了真实数据库。集成测试可以放宽到5分钟超过的话就要考虑是不是用例设计得太厚了。6.3 团队推进时我踩过的几个坑第一个坑是覆盖率指标挂帅。有段时间我们把覆盖率纳入考核结果就是测试数量暴涨、质量暴跌出现了大量只调用不断言的形式测试。后来改成只对核心包设分支覆盖阈值并且要求新增代码的测试必须能通过变异测试Mutation Testing的抽查情况才好起来。变异测试的思路是自动修改你的生产代码看测试能不能发现能发现说明测试是有效的这个思路比看覆盖率数字靠谱得多。第二个坑是测试写得比业务代码还长。一个方法名三十个字符、准备数据五十行、assertEquals二十个改业务逻辑的时候光改测试就要半天。解决办法是抽出测试数据构建器Test Data Builder和自定义断言方法把重复准备逻辑收敛起来。这不是为了代码好看是为了让后续的修改成本降下来。第三个坑是CI里跑测试用的数据库和本地不一致。解决方案要么是用Testcontainers保证环境一致要么是在CI里把集成测试和单元测试彻底分开跑。混合在一起的后果就是本地绿、CI红然后大家开始习惯性重跑久而久之就没人看失败的测试了这是最坏的结果。我在实际推进过程中最大的体会是单元测试的价值不在于数量而在于改代码时敢不敢动。当你重构一个核心方法改完之后本地跑一下几秒钟绿了心里那块石头就落地了这种确定性是任何文档和评审都替代不了的。另外分享一个小技巧把最常出问题的那几个类的测试方法名前面加个统一前缀比如calc_然后在IDE里按前缀过滤运行改金额相关的逻辑时只跑这一批两三秒出结果体验非常顺。至于测试写完之后的下一步我通常会把它们接到提交前的本地钩子上只跑受影响的模块全量交给CI这样既不拖慢日常开发也不会把问题留到合并之后。
网站建设高端定制企业官网