免费获取学习方案
ARTICLE DETAIL

资讯详情

深耕编程基础知识与建站技术分享的一线实战洞察。

单元测试实战指南:从框架选型到覆盖率提升的完整方案

单元测试实战指南:从框架选型到覆盖率提升的完整方案 unit testing 是软件质量保障里最容易被低估、也最容易写跑偏的一环。很多人一开始觉得它不过是“写几个方法调一下”真正接手一个有历史包袱的项目、把测试覆盖率从 10% 提到及格线、还要让测试在 CI 上稳定跑完不误报时才会明白这里面全是细节。这次我结合自己在前端、Java 后端和嵌入式 C 项目里折腾单元测试的经验把从框架选型、标准制定、项目集成、报错排查到覆盖率工具VectorCAST的使用串成一条线。不论你是刚入行的后端开发还是被 Vue 测试报错折磨的前端亦或是搞嵌入式 C 的老手这篇文章都能给你一套可以直接照做的方案和若干“不写在文档里”的坑。1. 单元测试的整体设计思路与核心价值1.1 为什么要写单元测试而不是只靠集成测试我见过不少团队把“测试”等同于“把服务启动起来打接口”或者前端“把页面点一圈”。这种做法的最大问题是问题暴露太晚定位成本太高。一个接口报错可能是 Controller 层参数问题也可能是 Service 层逻辑缺陷还可能是第三方依赖超时。等联调阶段再抓一个 Bug 的修复成本往往是单元测试阶段的几十倍。单元测试的核心思路是把被测对象与外部依赖隔离让逻辑层在毫秒级内跑完一次只验证一件事。以我自己维护过的支付订单服务为例订单状态的流转逻辑非常复杂待支付、已支付、已取消、退款中、退款成功这五个状态之间的合法迁移路径有十几条。如果只靠集成测试每一条路径都需要构造一个数据库环境里的真实订单而用单元测试把数据库、外部接口都 mock 掉之后几秒钟就能跑完全部状态迁移用例而且断言非常明确调用某个方法后状态到底变成了什么。说得直白一点单元测试不是给用户看的是给下一个接手你代码的工程师看的。它像一套可执行的最小文档读了几个测试用例你就知道这个函数接受什么输入、做什么处理、产出什么输出连边界情况都列得清清楚楚。这样的代码库新人上手成本会低很多。1.2 单元测试标准什么样的测试才算“合格”网上一搜“单元测试标准”会蹦出很多理论我给团队定的标准非常朴素一共五条能同时满足的测试才算合格第一条测试粒度是“单一行为”。一个测试方法里只验证一个业务行为。比如“订单已支付后不可重复支付”这是单一行为“订单金额计算正确且优惠券也生效”这可能是两个行为就该拆成两条用例。否则断言失败时你根本分不清是金额算错了还是优惠券逻辑挂了。第二条测试不能依赖外部环境。不能因为数据库没启动、Redis 没连上、网络不通就挂掉。做法就是把所有 IO 边界抽象成接口测试时注入 mock。如果必须依赖嵌入式数据库做集成性质的测试也要和纯单元测试分开目录单独建 suite 跑。第三条执行速度要足够快。我个人经验是一个模块几百条测试用例应该在 5 秒内跑完。如果出现秒级等待大概率是某个测试真的在走网络或磁盘 IO这就违反了第二条。CI 上大家非常反感跑得很慢的测试慢到一定程度测试就形同虚设了。第四条稳定不能是“假绿”。假绿是所有测试里最坑的状态——测试跑过了、通过标识是绿的但断言其实没有生效。常见情况是测试类里忘了加注解导致方法根本未执行或者断言写在了 catch 块的外面异常被吞掉以后直接走到绿。这些我都会在后面单独讲怎么排查。第五条有明确的失败信息。断言失败时报错信息必须让人一眼知道是哪个场景、哪个预期值没匹配上。比如Assert.assertEquals(订单状态应为 PAY_SUCCESS, actualStatus, expectedStatus)这样的写法比expected true but was false有价值得多。这套标准不是拍脑袋定的是踩过很多坑后总结出来的底线。尤其是“假绿”这个问题它对团队信心的打击远比测试不过还严重——测试不过大家会去修测试永远过但你不敢信它那这套测试存在的意义就彻底没了。2. 后端 Java 项目集成 TestNG 实操2.1 TestNG 和 JUnit 怎么选很多新手会问Java 项目做单元测试到底用 JUnit 还是 TestNG先说结论没有绝对优劣主要看周边生态和团队习惯。Spring Boot 官方文档默认示例以 JUnit 5 为主老一些的遗留项目里 JUnit 4 占了绝大多数。TestNG 则是在接口测试、数据驱动和复杂的测试分组场景里更顺手。我选 TestNG 的几个理由比较明确数据驱动很直观一个DataProvider注解就能实现参数化不需要像 JUnit 5 那样写ParameterizedTest加CsvSource的组合。对写大量边界值用例的接口测试来说TestNG 的可读性更好。分组执行能力强Test(groups {smoke, regression})可以非常方便地做到冒烟测试和全量回归的分离CI 上可以只跑指定分组。依赖测试支持dependsOnMethods可以在某些场景下声明方法之间的先后依赖JUnit 里默认是每个方法独立执行想做顺序依赖还得借助TestMethodOrder别扭不少。不过需要补充的是TestNG 和 JUnit 5 在很多方面都很接近了比如生命周期注解、断言库、Mockito 的配合方式几乎一样。所以如果你的项目已经深度使用 Spring Boot 全家桶继续用 JUnit 5 是更省事的选择。如果是测试框架选型还没有定论的新项目并且你们有大量参数化、分组的需求TestNG 值得认真考虑。2.2 从零搭建 TestNG 测试环境假设你的项目是 Maven 构建那么搭建 TestNG 只需要三步。第一步在 pom.xml 中加入依赖和插件properties testng.version7.10.2/testng.version /properties dependencies dependency groupIdorg.testng/groupId artifactIdtestng/artifactId version${testng.version}/version scopetest/scope /dependency !-- 如果要用 Mockito 做 mock 测试 -- dependency groupIdorg.mockito/groupId artifactIdmockito-core/artifactId version5.11.0/version scopetest/scope /dependency /dependencies build plugins plugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-surefire-plugin/artifactId version3.2.5/version configuration suiteXmlFiles suiteXmlFilesrc/test/resources/testng.xml/suiteXmlFile /suiteXmlFiles /configuration /plugin /plugins /build第二步编写 testng.xml 测试套件!DOCTYPE suite SYSTEM https://testng.org/testng-1.0.dtd suite nameall-tests verbose1 test nameunit-tests preserve-ordertrue groups run include nameunit/ exclude nameintegration/ /run /groups packages package namecom.example.service/ package namecom.example.util/ /packages /test /suite.xml文件的作用是定义测试套件的集合告诉 Maven“这一次要跑哪些包、哪些组”。如果你不想维护 xml也可以靠 Surefire 的自动发现机制它在src/test/java里会自动找名称是Test结尾或Tests结尾的类不过一旦涉及分组和跨模块组合还是推荐用 xml 控制灵活很多。第三步编写一个最简单的测试类验证环境package com.example.util; import org.testng.annotations.Test; import static org.testng.Assert.assertEquals; public class StringUtilTest { Test(groups {unit}) public void testTrimToEmpty() { String input hello ; assertEquals(StringUtil.trimToEmpty(input), hello, 字符串两端的空格应该被去掉); } }然后执行mvn clean test。如果控制台出现 TestNG 的运行摘要说明环境已经通了。这里有个细节TestNG 和 JUnit 的断言方法名撞车问题在 Spring Boot 项目里很常见org.testng.Assert.assertEquals和org.junit.jupiter.api.Assertions.assertEquals混在一起IDE 自动导入时常导入错导致“方法不存在”的编译错误。我建议一个项目里测试注解和断言库就锁死一套不要混用。2.3 用 DataProvider 处理大量边界值单元测试最大的工作量其实在“造测试数据”上。尤其做参数校验、日期解析这类方法随便一写就是几十个边界值空字符串、null、超长字符串、非法日期、闰年 2 月 29 日、格式化错误的字符串等等。用 TestNG 的 DataProvider 能把测试代码量压缩到原来的五分之一。来看一个实际的例子假如有一个DateParser.parse()方法用于把字符串解析成LocalDateDataProvider(name validDateProvider) public Object[][] validDateProvider() { return new Object[][] { {2024-01-01, LocalDate.of(2024, 1, 1)}, {2024-02-29, LocalDate.of(2024, 2, 29)}, // 闰年 {1900-02-28, LocalDate.of(1900, 2, 28)}, // 整百年不闰 {2000-02-29, LocalDate.of(2000, 2, 29)} // 整四百年闰 }; } Test(dataProvider validDateProvider, groups {unit}) public void testParseValidDate(String input, LocalDate expected) { LocalDate actual DateParser.parse(input); assertEquals(actual, expected, 日期解析结果应与预期一致输入为: input); }需要注意 DataProvider 方法的返回值类型必须是Object[][]或IteratorObject[]且 Provider 方法默认要求是static如果是 non-static需要在测试类上加上Test(dataProvider ...)之外还要在 DataProvider 注解里设置indices之类参数反而麻烦。数据量大的时候用IteratorObject[]可以延迟加载避免一次性把几万条测试数据全部载入内存。我试过用这种方式给一个 JSON 解析工具写了两百多条边界用例测试代码本身只有几百行。后期需求变更时只需要往数组里加一行数据不需要新增方法。这种“数据驱动”的方式特别适合那些输入输出映射关系明确的纯函数也是把单元测试成本降下来的关键手段。2.4 Mockito 隔离外部依赖的落地写法单元测试通常需要把目标方法中的 DB、Redis、外部 Http 客户端的调用全部替换成假对象。这套思路听上去简单但实际落地写代码时很容易出现“mock 了又没完全 mock”的状态。最经典的错误是你想测试的是OrderService.cancelOrder()里的业务逻辑前提是orderRepository.findById()返回一个特定订单、paymentGateway.refund()不能真的调用。于是你写Mock OrderRepository orderRepository; Mock PaymentGateway paymentGateway; InjectMocks OrderService orderService;然后在测试方法里Order order new Order(); order.setId(1L); order.setStatus(PAID); when(orderRepository.findById(1L)).thenReturn(Optional.of(order)); orderService.cancelOrder(1L); verify(paymentGateway).refund(1L, new BigDecimal(100.00));这段代码有两个易错点。第一InjectMocks只能做“基于构造器或 Setter 的注入”如果OrderService内部是通过Autowired字段注入且没有构造器那InjectMocks会注入失败运行时orderRepository为 null直接空指针。第二verify(paymentGateway)的语义是这个依赖必须被调用一次。如果被测代码的refund方法是放在一个if (order.canRefund())条件里并且你构造的 order 状态是CANCELLED那这个验证就会失败——它恰恰帮你验证了“已取消订单不会调退款接口”这条业务规则。写测试时默认 verify 是好朋友不要因为它失败就顺手把它删掉要想想业务逻辑是不是真的这么设计的。另外建议在测试方法的AfterMethod里加上Mockito.validateMockitoUsage()不过较新版本的 Mockito 会自动校验确保每个 mock 的用法都是合法的。项目里如果已经用了 Mockito 的ExtendWith(MockitoExtension.class)那框架会在测试结束后自动做校验报错信息比“mock 方法未被调用”直接得多。3. 前端 Vue 项目单元测试实战与报错排查3.1 Vue 项目单元测试的环境搭建路径前端单元测试尤其是 Vue 项目里的组件测试近年变化很大。如果你用的是 Vue 3 Vite 的组合官方主流测试方案有两个Vitest与 Vite 配置天然打通速度极快自带describe/it/expectAPI。Jest生态成熟但要在 Vite 项目里额外配置jest-transform有点重。如果项目是 Vue 2 老项目很多团队还在用 Jest Vue Test Utils v1。但新项目我强烈建议直接上 Vitest少受罪。一个比较稳的初始化方式是使用npm create vuelatest生成项目时勾选 Vitest 选项。要是项目已经建好手工加依赖可以这样做npm install -D vitest vue/test-utils jsdom vitejs/plugin-vue然后在vite.config.js里增加import { defineConfig } from vite import vue from vitejs/plugin-vue export default defineConfig({ plugins: [vue()], test: { environment: jsdom, globals: true, setupFiles: [src/test/setup.js] } })注意 Vitest 的配置是藏在vite.config里的而且它要求test这个字段存在。有人会疑惑为什么放在单独的 vitest.config 里不行——但如果你用的是 monorepo 或者特殊目录结构两个文件同时存在时vite.config.js的优先级更高。最好的做法是统一写在一个文件中或者在vitest.config.js里完整重复需要的内容避免混淆。src/test/setup.js里通常做两件事引入testing-library/jest-dom的断言扩展或者注册全局的公共组件。比如import { config } from vue/test-utils config.global.stubs { transition: false }这个 stub 配置能让测试环境里transition组件不渲染过渡动画减少报错和等待时间。3.2 Vue 单测里最常见的几个报错与解决办法前端单测和纯后端单测不一样它涉及 DOM 渲染、组件生命周期、CSS 样式等多层因素报错信息经常令人摸不着头脑。我把搜索热度最高的几个 Vue 测试报错汇总一下全部是实际项目里出现过的报错一ReferenceError: window is not defined这个八成是运行环境没配成 jsdom。Vitest 默认的 environment 是 node里面没有window/document对象。解决办法就是像上面那样把 environment 配置为jsdom或者在单个文件顶部加上// vitest-environment jsdom。老项目用 Jest 的话则需要在jest.config.js里设置testEnvironment: jsdom。报错二ResizeObserver is not defined或IntersectionObserver is not defined现在的组件库经常内部依赖浏览器原生 API比如 el-table、n-grid 会用到ResizeObserver监听元素尺寸变化。这些 API 在 jsdom 里并不存在。解决办法是在 setup 文件里做一个最小 mockclass ResizeObserverStub { observe() {} unobserve() {} disconnect() {} } global.ResizeObserver global.ResizeObserver || ResizeObserverStub写这段的时候要留意不是所有第三方库都会做兼容性判断有的库只要你没传参进来就直接 new这时必须设置好全局变量。报错三Failed to resolve component: XxxComponent组件里用到了某个全局注册的组件但测试环境在 mount 时并不知道它。两种解法一是测试里显式声明import GasButton from /components/GasButton.vue const wrapper mount(HelloWorld, { global: { components: { GasButton } } })二是在 setup 文件里将常用组件统一注册到模拟全局import { config } from vue/test-utils import GasButton from /components/GasButton.vue config.global.components { GasButton }报错四Invalid prop: type check failed for prop data. Expected Array, got Object这个本质是测试数据没有按组件 prop 的类型定义来构造。比如组件里data: { type: Array }你却传了一个普通对象。解决方向不是去临时改组件 props 约束而是应该把测试数据改成合法类型——这其实是好报错它说明测试真的把组件当“单元”来测了。如果遇到大量与第三方 UI 库的兼容性报错特别是用到 Table、Tree 这类复杂组件时有一个很实用的经验能 mock 掉就 mock 掉。比如 el-tree 在单测中真的很难覆盖非要测试它的节点展开交互成本极高。这时候就直接断言其内部逻辑、数据扁平化函数组件本身交给集成测试去覆盖。与其在一个组件的渲染细节上耗两小时不如跳出来把可单元化的部分拆出来测后面你就会发现这套策略有多香。3.3 一个完整的 Vue 组件单测例子我找一个最常见的业务组件来演示一个带 count 属性和“点击1”事件的计数器按钮。它足够简单但能覆盖“渲染断言、事件触发、emit 验证”三个最基本的单测动作。template button classcounter-btn clickhandleClick 点击次数: {{ count }} /button /template script setup const props defineProps({ count: { type: Number, default: 0 } }) const emit defineEmits([increment]) function handleClick() { emit(increment, props.count 1) } /script对应的测试如下import { describe, it, expect, vi } from vitest import { mount } from vue/test-utils import CounterButton from /components/CounterButton.vue describe(CounterButton, () { it(正确渲染传入的 count 值, () { const wrapper mount(CounterButton, { props: { count: 5 } }) expect(wrapper.text()).toContain(点击次数: 5) }) it(点击按钮后触发 increment 事件并传递新值, async () { const wrapper mount(CounterButton, { props: { count: 5 } }) await wrapper.find(.counter-btn).trigger(click) expect(wrapper.emitted()).toHaveProperty(increment) const emitParams wrapper.emitted(increment)[0] expect(emitParams).toEqual([6]) }) })第二个测试的await很容易被忽略。trigger(click)是一个异步操作它不会立即完成 DOM 更新和事件派发必须等它 resolve 后再做断言。我见过好几个人把这个 await 漏掉然后断言时 emitted 为空开始怀疑框架有 bug——其实只是时序问题。这一段代码里还隐含着一个好习惯不要让测试代码直接去依赖组件内部实现细节。比如我不能断言“点击监听的 handler 名字叫 handleClick”而是断言“点击这个按钮产生了 increment 事件”。这样一来后续即使把 handler 改名为 onClick测试依然能通过它才是真正的行为测试而非实现测试。4. 嵌入式 / C 语言单元测试与 VectorCAST 入门4.1 VectorCAST 到底是干嘛的Vue 和 Java 之外的另一个大头是嵌入式 C 语言单元测试。如果你在汽车电子、航空航天、医疗器械这些行业混过一定听过 VectorCAST 的大名。它是 Vector 公司出的一套面向 C/C/Ada 代码的自动化测试工具最常用于DO-178C / ISO 26262这类有功能安全要求的场景同时也能做单元测试、集成测试和覆盖率分析。它跟 JUnit、TestNG 最大的区别在于它不只是一个断言库而是一个贯穿静态分析、测试用例生成、桩函数管理、目标机部署、覆盖率统计的完整解决方案。在安全等级要求高的项目里你要做的不是写几十个 assert 就够而是要向认证机构证明每一个语句、每一个分支、每一个 MC/DC 都执行过了。VectorCAST 就是帮工程师完成这件事的半自动工具。4.2 VectorCAST 单元测试的基本工作流程虽然 VectorCAST 界面在迭代了很多年之后依然不算美观但它的流程非常固化基本上就是下面这五步第一步加载源码工程。创建一个 VectorCAST 环境把你的 C 源文件加进去。它可以自动分析出函数之间的调用关系生成一个以函数为单位的管理视图。第二步选择被测函数。从环境的函数列表里勾选要测的函数比如你要测int calc_fuel_density(int temperature, int pressure)就把它加进测试范围。第三步生成测试用例。VectorCAST 可以基于代码分析自动生成一组测试用例等于根据函数的输入参数范围和内部条件自动创建“骨架用例”。这是它比较省时的地方但不要指望自动生成的用例能覆盖所有业务语义它通常只覆盖语句和分支具体的边界值和异常组合还是需要自己补充。第四步设置桩函数。如果被测函数调用了硬件相关的 API 比如read_sensor()测试时不可能让真实硬件响应就需要写一个 stub。VectorCAST 会自动生成桩函数框架你要做的就是里面填充一个返回值。第五步运行并要求覆盖率。执行完测试后工具会生成覆盖率报告包括语句覆盖率、分支覆盖率、MC/DC 覆盖率。不符合目标时就需要补充用例直到达标。这整个流程的核心理念是“以覆盖率为证据”驱动测试而不是以“跑几个用例通过就行”为导向。很多人第一次用 VectorCAST 都会觉得“我测了跟没测一样覆盖率怎么这么低”其实就是自动生成的用例太少了需要你手动把每个if/else、每个/||的组合都补上。4.3 覆盖率到底该怎么看这里专门展开说说覆盖率。好多工程师一听到“覆盖率 100%”就兴奋其实覆盖率是分类的而且不同类型对逻辑验证的强度完全不同。覆盖率类型含义举例if (a b)语句覆盖率每行代码是否被执行只要a为 false就无法进入 if 内部语句覆盖率肯定不全分支覆盖率每个真/假分支是否都被走过if为真要走一次为假也要走一次条件覆盖率每个布尔条件是否出现过真和假需要atrue、afalse、btrue、bfalse都出现MC/DC 覆盖率每个条件是否独立影响判定结果标准根基让每个条件独立翻转时判定结果也翻转一次有点绕举个例子。对于if (a b)MC/DC 要求的最小用例组合是(aF,bT)和(aT,bF)因为单独翻转 a 时结果从 F 变 T单独翻转 b 时结果也从 F 变 T。如果用例是(aT,bT)和(aF,bT)a 确实独立影响结果了但 b 从头到尾没有翻转过达不到 MC/DC 标准。日常项目里绝大多数人做到分支覆盖率 100%就足够了。但如果你所在行业需要满足安全认证那 VectorCAST 的价值就体现在它能自动统计并提示 MC/DC 缺失——这靠人工在代码里数基本是数不过来的。4.4 VectorCAST 使用中的两个高频坑坑一把 VectorCAST 当成“万能测试工具”。它擅长的是函数级的白盒测试、覆盖率收集但它不能替你判断业务预期结果。比如calc_fuel_density在温度过高时应当返回一个熔断值这个预期只能由人写在测试用例里。工具再智能业务语义必须人来定。很多团队买了几百万的许可证结果用例写得无比草率覆盖率是够了但真实缺陷照样漏问题不在工具在用例。坑二桩函数里返回了无意义的值。我见过一个同事给read_sensor()的桩函数直接返回 0结果依赖该传感器读数的算法全部走了“正常偏低”分支测完发现分支覆盖率很好但一上真实硬件就崩。正确的做法是每个桩函数的返回值要和被测场景匹配至少保证“让被测函数进入你真正想测的那个分支”。必要时可以声明一组不同的桩函数返回值让不同用例走不同逻辑。5. 常见问题与排查技巧实录5.1 测试跑不起来先查这四个位置单元测试跑不起来的报错千奇百怪但九成以上是下面四个地方的问题第一依赖没拉下来或版本冲突。尤其是 xml 里写了两个不同版本的 TestNG/JUnit或者 Vue 项目里vue与vue/test-utils版本不匹配。建议先锁定大版本Vue 3 配vue/test-utils2Vue 2 配vue/test-utils1。我见过最多的问题是 Vue 2 Test Utils v2一 mount 就报“Cannot read property is of undefined”。第二测试类没有被 Surefire 识别。Maven 的默认后缀是Test.java、Tests.java、TestCase.java。如果你建了一个OrderServiceTest2.java或TestUtil.java它不会自动执行。用mvn test -DtestYourTestClass可以单独执行某个类进行验证。第三IDEA 或命令行执行单元测试时的 working directory 不对。如果测试里用相对路径读取文件比如读src/test/resources/test.json在 IDEA 里跑默认 working directory 是模块目录命令行 mvn 跑也是模块目录但 CI 流水线上有时 baseDir 不同就会报文件找不到。第四Spring 测试类忘了加SpringBootTest或ExtendWith(SpringExtension.class)。如果用到了 Spring 上下文不加载上下文就无法注入 Bean。不过纯单元测试不要动不动引入 Spring Context少点魔法能让测试快得多。5.2 断言“假绿”如何识别测试只是走了个过场这是我认为最值得单独写一个小节的问题。什么叫做“假绿”测试方法里没有assert开头的方法整个方法只是调用了被测函数没验证任何结果。异常被 catch 后吞掉了单元测试走到 catch 外面继续执行最终绿。断言写在了异步回调之外异步都还没执行完测试就结束了。看一个典型的错误代码Test public void testCalculate() { try { int result calc.divide(10, 2); } catch (Exception e) { // 吞掉异常测试照样绿 } }如果divide抛出异常这里 catch 把异常吃了测试照样通过。唯一的感知可能是某次覆盖率报告显示这段代码根本没覆盖。另一个常见场景是异步方法的测试it(should update count after async, () { wrapper.vm.fetchData() // 没有 await没有 flushPromises expect(wrapper.text()).toContain(数据加载完成) })如果fetchData内部是 setTimeout 或 Promise这段断言大概率在状态更新前执行文字还没渲染上去测试却可能因为没匹配上而失败这种还算好更糟的是恰好旧状态里就包含该文字导致误通过。识别假绿的黄金标准是临时把被测代码逻辑故意改错观察测试是否变红。如果改了还是一样绿那这个测试必然是废的。这个“变异测试”的思路其实有工具比如 PIT 就能自动做变异测试但当手头没有工具时手动用一个错误返回值去试错几分钟就能定位出哪些测试在“走过场”。5.3 控制单元测试维护成本的四个经验很多人写着写着就把单元测试写废了原因往往不是技术难度而是维护成本过高。我自己踩过几次坑后总结出四条经验对控制维护成本非常有效。一是测试只测“公共行为和公共输入”不要里头私有方法细节也不要用反射去测私有方法。如果你觉得某个私有方法特别重要想测它那就说明这个私有方法大概率应该抽成独立的公共类或包私有方法比如独立的StringUtil、PriceCalculator。这样既方便测试也提高了代码复用性。二是把测试里用到的准备数据统一收口到工厂或常量类。不要在每个测试里用 new 到处拼对象。项目里很多测试失败时根本不是逻辑错误而是某个构造函数加了参数导致十几个测试文件同时编译错误。如果把对象构造收口到OrderFixture.create()类似的方法改动只需要一处。三是刻意控制“全链路数据”的长度。单元测试里堆一个几百行的 JSON 数据、一个多层的树形对象只会让用例变得又长又难以维护。需要长数据时用工厂生成默认值而不是每次都写全量。四是及时删废测试。如果某个测试你发现自己已经连续三次改需求时都改了它而且每次改只改断言不深入业务那这个测试可能已经没有存在价值了或者它测试的东西早已被别人的测试覆盖。单元测试不是越多越好多一个无意义测试维护成本就高一分。真正优秀的测试是一套按需更新的小型护城河而不是一座臃肿的防御墙。5.4 一套实用的单元测试排查清单天南海北的问题聊了一大堆我给团队整理过一份精简的排查清单每次测试异常直接对照找效率很高现象排查方向快速解决建议测试类没跑类名/方法后缀不符合框架识别规则检查 Maven/Gradle 的 include 配置全部测试都报空指针InjectMocks注入失败给被测类补构造器注入中文乱码文件编码不是 UTF-8统一 IDE 和 Maven 编码异步断言失败未等异步任务完成加await flushPromises()覆盖率不符合预期用例没覆盖条件组合手动补充分支组合用例测试偶尔过偶尔挂依赖了外部环境或共享单例mock 掉外部依赖修改了业务代码后测试不报错测试可能是假绿用手动变异测试验证这张表加上前面写的各类场景覆盖了我这么多年在 Java、Vue、嵌入式 C 项目里遇到的绝大部分单元测试问题。如果你在实操中遇到表里没覆盖的新问题我的建议是先跑最小复现再搜索报错关键词最后看官方文档。不要一上来就怀疑框架有 bug绝大多数疑难杂症最后都证明是自己环境的锅。写到这儿想起早些年带新人时他问我“单元测试的价值到底是什么”。当时我开玩笑说它就像你写完一段代码后旁边坐了一个不知疲倦、不嫌你烦、还永远坚持原则的小助手每天都来把你上次写的函数重新验证一遍。后来项目上线前因为一个边界值改动导致历史功能挂掉、但被老旧的单元测试一把揪出来的时候才觉得这话说得还太浅了——单元测试真正的作用是让人对代码变化不再恐惧。你熬夜改完一个核心算法敢不敢提交、敢不敢打包发布很大程度上取决于你脑海里有没有“那套测试应该会兜住”的底气。希望这篇里的实操方案和踩坑记录也能帮你把这种底气建立起来。
返回列表