干这一行十多年,我见过太多项目死在“单元测试”这四个字上。不是没人写,是写不明白,写出来的测试要么跑一次要五分钟、要么随便改个字段就崩、要么覆盖率报表好看但代码上线照样出事故。我自己也踩过这些坑,所以今天想把这些年做单元测试的经验一次性讲透。
这篇文章只谈一个事:怎么把单元测试写出价值。我会用后端、前端、嵌入式三个方向的真实案例来拆解,覆盖 Spring Boot、Vue、Unity/Testbed/VectorCAST 这些常见工具链,也聊聊测试驱动开发(TDD)、覆盖率、变异测试这些绕不开的话题。不管你是刚入行的新人,还是带团队的技术负责人,这篇文章都能给你一些可以直接抄作业的思路。
1. 单元测试到底在测什么
1.1 “单元”指的是什么
很多人写单元测试的第一道坎,是搞不清“单元”的边界。有人觉得单元等于一个类,有人觉得是一个文件,还有人干脆把启动整个 Spring Boot 容器拉起来测接口,然后管这个叫单元测试。
我的理解很简单:一个单元,是最小可验证的行为片段。在 Java 里通常是一个方法,在 C 里通常是一个函数,在前端可能是一个组件的行为。关键不在于类或文件的大小,而在于它有没有“不可再拆”的行为边界。
举个例子。假设有个OrderService.checkout()方法,它里面做了三件事:校验库存、计算价格、生成订单。如果你写一个测试把这三件事全跑一遍,那不是单元测试,那是一个小小的集成测试。真正的单元测试应该是分别测“库存不足时是否抛出异常”、“价格计算是否正确”、“订单是否生成成功”,每个用例只关心一个行为。
这个认知决定了后面所有工具选型和代码结构的设计。你去搜 Vue 单元测试为什么老是报错,搜 Spring Boot 的@SpringBootTest怎么这么慢,十有八九都是因为单元边界没划清,把一堆外部依赖都拉着一起跑了。
1.2 单元测试的核心价值不是“跑一遍”
我见过太多团队写单元测试的目的就是“为了覆盖率指标达标”。这种测试没有灵魂,也起不到保护作用,纯粹是给领导和 QA 看的。
单元测试真正的价值是可回归性。它是你代码的保险丝,不是跑一遍给别人看的演示。
拿我自己经历的一个例子说。之前重构一个交易系统里用了五年的老模块,核心逻辑几百行,没有一行测试。我改一个判断逻辑,自认为改得没问题,结果上线当晚线上就出了资损问题。后来我花了两天把那个模块的单元测试补齐,再重构时,测试跑红的瞬间就能定位到问题所在,整个心里踏实非常多。
这就是单元测试的意义:它让你在修改代码时,能立刻知道自己有没有改坏东西。没有测试的重构叫“裸奔”,有测试的重构才叫“重构”。
1.3 判断一个单元测试写得好不好
很多团队写测试写得很多,但质量堪忧。我判断一个单元测试好不好,只看四件事:
- 跑得快不快:真正的单元测试应该在毫秒级执行。如果你的测试要启动数据库、要连 Redis、要拉起整个 Spring 容器,那它不是单元测试,你只是把它叫单元测试。
- 依赖是否可控:测试环境里不应该有不可控的因素。比如系统时间、随机数、外部接口响应,这些都要能被 mock 或注入。
- 失败时能否一秒定位:好的测试失败信息应该是明确的,比如“期望库存不足时抛出异常,但没有抛出”,而不是“NullPointerException at line 123”。
- 是否只测一个行为:一个用例只验证一个结果。如果一个测试方法里塞了十几个断言,失败了你得一个个排查是哪个断言出了问题。
这四条标准,我在后面讲各类框架的具体写法时,会反复用到。
2. 测试框架与工具选型盘点
2.1 Java 生态:JUnit 5 + Mockito + AssertJ
Java 后端目前最主流的单测组合,我推荐 JUnit 5 + Mockito + AssertJ,也带火过 Spring Boot 的最佳实践。JUnit 5 提供了测试生命周期管理和参数化测试;Mockito 负责 mock 外部依赖;AssertJ 提供流畅的断言 API,比 JUnit 自带的断言更读得懂。
在 Spring Boot 项目里,你只需要引入spring-boot-starter-test一个依赖,它就帮你把 JUnit、Mockito、AssertJ、JsonPath 这些全打包了,确实挺省心。
2.2 前端生态:Vitest + Vue Test Utils / React Testing Library
前端测试这些年变化很快。早年是 Jest 一家独大,现在 Vue 3 项目里用 Vitest 的越来越多。Vitest 原生支持 ESM 和 TypeScript,速度比 Jest 快不少,配置也简单。组件测试用 Vue Test Utils 来挂载组件、触发事件、断言 DOM;React 项目对应的则是 React Testing Library。
前端开发者在网上搜“前端怎么写单元测试”,搜到的内容很杂,很多是三四年前的老文章还在推荐 Jest + jsdom 那套配置。我的建议是:新项目直接用 Vitest,没必要再折腾 Jest 的 ESM 兼容问题了。
2.3 嵌入式 C/C++ 生态:Unity / Ceedling、Testbed、VectorCAST
嵌入式领域的单元测试和普通应用开发差异比较大。开源方案我常用 Unity + Ceedling,Unity 是一套极轻量的 C 语言 xUnit 测试框架,Ceedling 是配套的构建管理工具,能做到类似 CMake 的自动化。商用方案则是 LDRA Testbed、VectorCAST 这些,它们能做需求覆盖、MC/DC 覆盖率分析,适合做航空航天、汽车电子这类高安全等级认证。
很多做嵌入式的同事一听说单元测试就觉得“我们这行没法测,都是硬件”,其实早就不是这么回事了。现在开发板上跑 Linux 的太多了,交叉编译出来在 PC 上做宿主测试是通行做法。
2.4 选型核心准则:团队能坚持
工具选型这件事,我的一条原则是:别选最强的,选团队最愿意用的。再强的测试框架,如果团队成员觉得难用、看不懂,最后一定会被废弃。我见过好几个项目,引入了一套非常好用但很复杂的测试框架,结果只有一个人会写,测试越写越少,最后全删了。
测试框架选型就是一个长期主义的问题,选一个文档全、社区活跃、大家用的顺手的就好。
3. Spring Boot 单元测试最佳实战拆解
3.1 依赖与工程结构
先搭一个最小可运行的 Spring Boot 单测工程。在pom.xml里加上:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency>工程结构按 Maven 标准来,测试代码放在src/test/java下,包名跟src/main/java保持一致。这样有个好处,就是测试类可以直接访问被测试类的包级私有成员,虽然一般用不上,但必要的时候能省事。
3.2 一个真实的 Service 测试示例
假设有一个OrderService,它依赖InventoryClient和PriceCalculator:
@Service public class OrderService { private final InventoryClient inventoryClient; private final PriceCalculator priceCalculator; public OrderService(InventoryClient inventoryClient, PriceCalculator priceCalculator) { this.inventoryClient = inventoryClient; this.priceCalculator = priceCalculator; } public Order checkout(OrderRequest request) { if (!inventoryClient.isAvailable(request.getSku(), request.getQuantity())) { throw new ItemNotAvailableException(request.getSku()); } BigDecimal totalPrice = priceCalculator.calculate(request.getItems()); return new Order(request.getSku(), request.getQuantity(), totalPrice); } }这是非常典型的场景,OrderService本身不碰数据库,它只调别人的方法来组织业务。我们在测试时把两个外部依赖都 mock 掉:
@ExtendWith(MockitoExtension.class) class OrderServiceTest { @Mock private InventoryClient inventoryClient; @Mock private PriceCalculator priceCalculator; @InjectMocks private OrderService orderService; @Test void shouldThrowExceptionWhenInventoryNotAvailable() { when(inventoryClient.isAvailable("SKU-123", 10)).thenReturn(false); assertThatThrownBy(() -> orderService.checkout(createRequest("SKU-123", 10))) .isInstanceOf(ItemNotAvailableException.class); verify(inventoryClient, times(1)).isAvailable("SKU-123", 10); verify(priceCalculator, never()).calculate(any()); } @Test void shouldCalculateTotalPriceWhenInventoryAvailable() { when(inventoryClient.isAvailable("SKU-123", 10)).thenReturn(true); when(priceCalculator.calculate(anyList())).thenReturn(new BigDecimal("99.90")); Order order = orderService.checkout(createRequest("SKU-123", 10)); assertThat(order.getTotalPrice()).isEqualByComparingTo("99.90"); verify(inventoryClient, times(1)).isAvailable("SKU-123", 10); verify(priceCalculator, times(1)).calculate(anyList()); } }这个例子里有几个细节值得说。第一,@ExtendWith(MockitoExtension.class)是 Mockito 整合 JUnit 5 的入口,它负责初始化@Mock和@InjectMocks。第二,第一个测试不仅断言了“抛出异常”,还断言了priceCalculator压根没被调用,这说明库存校验和价格计算之间的调用关系符合预期。第三,你在平时开发时,单测写起来觉得别扭,那八成是代码设计有问题,比如把太多逻辑堆在一个方法里、依赖没抽象好,这时就该回头改生产代码了。
3.3 测试 Service 层时,别硬拉 Spring 上下文
很多 Spring Boot 开发者最大的毛病,就是一写测试就上@SpringBootTest。这个注解会把整个应用上下文启动起来,还要连数据库、连 Redis,一个测试跑下来少说几十秒。
真正的单元测试原则是:被测对象自己 new,外部依赖全部 mock。在 Service 层测试中,用@ExtendWith(MockitoExtension.class)完全够用,不用启动 Spring 容器。
那 Controller 层怎么测?推荐用@WebMvcTest只加载 Web 层配置,然后用@MockBean把 Service 层 mock 掉。它比@SpringBootTest轻量很多,性能差距非常明显。
@WebMvcTest(OrderController.class) class OrderControllerTest { @Autowired private MockMvc mockMvc; @MockBean private OrderService orderService; @Test void shouldReturnOrderWhenCheckout() throws Exception { when(orderService.checkout(any(OrderRequest.class))) .thenReturn(new Order("SKU-123", 10, new BigDecimal("99.90"))); mockMvc.perform(post("/api/orders") .contentType(MediaType.APPLICATION_JSON) .content("{\"sku\":\"SKU-123\",\"quantity\":10}")) .andExpect(status().isOk()) .andExpect(jsonPath("$.totalPrice").value(99.90)); } }如果你用了@WebMvcTest还是嫌慢,可以换成MockMvcBuilders.standaloneSetup(new OrderController(orderService))这种方式,连 Web 层配置都不加载,速度又能快一级。当然这要牺牲一些配置上的真实性,比如全局异常处理器、拦截器这些都不会生效,需要你额外手动装配。
3.4 Spring Boot 单测避坑记录
这几条都是实战踩出来的坑:
- mock 没打桩导致 NullPointerException:
when(xxx.method()).thenReturn(yyy)没写就调用,Mockito 默认返回 null,业务代码里一用就 NPE。排查办法是看测试日志里的 “Unnecessary stubbing” 或 “PotentialStubbingProblem”,这类警告其实就是告诉你 mock 和实际调用不匹配。 - 不清理上下文导致内存膨胀:
@SpringBootTest会缓存上下文,测试类一多、每个都加载不同配置,内存直接告警。解决方案是少用@SpringBootTest,尽量拆小测试粒度。 - 测试数据互相污染:如果测试类里用了真实的 DB,增删改查的顺序可能导致其他测试失败。要么启动事务回滚(
@Transactional),要么干脆别连真数据库,全用 mock。 - 静态方法不好 mock:Mockito 自带 mockito-inline 可以 mock 静态方法,但这是治标。更推荐把静态方法改成实例方法,通过依赖注入来解决,代码可测性会好很多。
4. 先写单元测试还是先写功能代码:TDD 辨析
4.1 TDD 的完整闭环
这是业界争论最多的问题之一。我自己的体感是:TDD 不是银弹,但它的核心思想非常值得学。
TDD 的循环是三个步骤:红灯、绿灯、重构。先在写功能代码之前写一个会失败的测试,然后写刚刚好能让这个测试通过的代码,最后在测试的保护下安全地重构生产代码。循环往复,每一步都很小。
网上很多人觉得“先写测试”纯粹浪费时间,那是误解了 TDD 的精髓。TDD 的真正价值不在于“先写”这两个字,而在于它强迫你先想清楚“这段代码到底要干什么”,再想“怎么实现”。
4.2 为什么先写测试能提升设计质量
我先写测试时,思考方式会变成:这个服务的调用方想要什么样的接口?参数是什么?返回值是什么?边界情况有哪些?这些问题在“先实现后测试”的模式下,往往被忽略。
举个例子,你想实现一个“计算订单折扣”的方法。如果直接写实现,你很可能写一个double getDiscount(String userLevel),因为这样写起来省事。但如果你先写测试,你会发现要断言各种 userLevel 对应的折扣率,这个 API 一测就知道设计不够好——应该接收User对象而不是String,返回值也应该是BigDecimal而不是double。测试驱动设计,说的就是这个。
4.3 TDD 的适用边界
TDD 不是所有场景都适合。我个人的经验是:
- 适合 TDD:业务规则复杂的 Service 层、算法工具类、解析类逻辑、状态机。
- 不太适合 TDD:UI 界面、临时脚本、算法原型探索。UI 的断言成本高、变化频繁,TDD 容易写出脆弱的测试;算法探索时需求本身就模糊,先写测试反而限制思路。
如果你是一个团队的技术负责人,我建议你不要强推全员 TDD,成本会非常高。更好的是把 TDD 用在你认为最核心、最容易出错的那几个模块上。
4.4 团队落地的折中路线
我自己带的团队,落地方式是分三步走:
第一步,所有新增的核心 Service 方法必须补单测,允许后补,不强制先写。第二部,单测覆盖率纳入 review 门槛,核心模块覆盖率要求不低于 80%。第三步,挑一两个重要模块试点 TDD,等大家尝到甜头了,再慢慢推广。
节奏要循序渐进,先让团队习惯有测试,再让团队养成测试先行的习惯。这一步一步来,比一次到位靠谱得多。
5. 前端单元测试怎么写:以 Vue 为例
5.1 环境搭建
前端单元测试的环境搭建,是很多新手的第一道坎。以 Vue 3 + Vite 项目为例,装测试依赖:
npm install -D vitest @vue/test-utils happy-dom然后配置vite.config.ts:
/// <reference types="vitest" /> import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' export default defineConfig({ plugins: [vue()], test: { environment: 'happy-dom', globals: true } })environment是测试运行时的 DOM 模拟环境。happy-dom比jsdom轻量不少,跑起来更快。globals: true让你在测试文件里直接用describe、it、expect而不用手动 import。
为了兼容 Vue 单文件组件里的@别名,还需要加一段 resolve 配置,否则测试文件里import xxx from '@/xxx'会直接报 “Cannot find module” 错误。
5.2 一个最简单的组件测试
搞个 Counter 组件来演示:
<template> <div> <button @click="count++">{{ count }}</button> </div> </template> <script setup> import { ref } from 'vue' const count = ref(0) </script>对应测试:
import { mount } from '@vue/test-utils' import Counter from './Counter.vue' describe('Counter', () => { it('初始渲染为 0,点击按钮后变为 1', async () => { const wrapper = mount(Counter) expect(wrapper.text()).toContain('0') await wrapper.find('button').trigger('click') expect(wrapper.text()).toContain('1') }) })注意那个await。Vue 组件内部状态更新后,DOM 的更新是异步的,所以触发事件后必须await一下,让更新 flush 完成再断言。如果漏掉await,这个测试会随机失败,尤其在跑全套测试时特别容易暴露问题,因为机器负载高的时候,异步队列的时序更加不稳定。
5.3 组件里有异步请求怎么办
真实组件基本都会发 HTTP 请求。推荐的方式是用vi.mock把请求模块整个 mock 掉:
import { mount, flushPromises } from '@vue/test-utils' import UserProfile from './UserProfile.vue' import { fetchUser } from '@/api/user' vi.mock('@/api/user', () => ({ fetchUser: vi.fn() })) describe('UserProfile', () => { it('加载用户信息并展示', async () => { fetchUser.mockResolvedValue({ name: '张三', age: 30 }) const wrapper = mount(UserProfile) await flushPromises() expect(wrapper.text()).toContain('张三') }) it('接口报错时展示错误提示', async () => { fetchUser.mockRejectedValue(new Error('network error')) const wrapper = mount(UserProfile) await flushPromises() expect(wrapper.text()).toContain('加载失败') }) })flushPromises是一个辅助函数,会把 Promise 队列清空,让 async 方法里的后续逻辑执行完。这是 Vue Test Utils 测试异步逻辑最常用的手段,比setTimeout等方案稳定得多。
5.4 Vue + 单元测试常见报错与解决
- “Cannot find module './xxx.vue'”:TypeScript 不认识
.vue文件,需要加declare module '*.vue'的声明文件,或在vite.config.ts的test配置里指定dts。 - “document is not defined”:测试环境没配 DOM 模拟。检查
environment是否设成了happy-dom或jsdom。 - “wrapper.find(...) is not defined”:选择器写错了,或者元素是条件渲染(
v-if)且当前状态不满足渲染条件。先在wrapper.html()里看下真实渲染出来的 DOM。 - “TestingLibraryElementError: Unable to find an element”:同样大概率是元素条件渲染问题,让 mock 的接口先返回数据再找元素。
- 组件里用了第三方 UI 库(如 Element Plus):建议在测试里用
global.plugins挂载对应插件。有些库还需要额外处理,比如 Element Plus 的图标组件,可以全局 stub 掉,只测业务逻辑,不测 UI 库内部。
6. 嵌入式 C 语言单元测试的落地方式
6.1 嵌入式单元测试的特殊性
嵌入式领域的单元测试,理解起来会比普通应用开发更有挑战性。它的特殊性主要在三点:
第一,交叉编译。目标代码要在 PC 上编译跑,编译器和链接器都要专门配置,还要处理__attribute__、位域对齐等问题。第二,硬件依赖。很多函数直接操作寄存器、访问外设地址,这些在 PC 上跑不了,得写桩函数或者用模拟器。第三,内存受限。嵌入式系统的堆栈很小,测试代码本身不能太吃资源。
所以嵌入式单元测试的通行做法是“宿主编译 + 目标板验证”。开发者在 PC 上写好单元测试,用本机编译器跑一套逻辑测试,把硬件相关的部分全部 stub 掉;等逻辑验证完了,再烧到板子上做集成验证。
6.2 开源方案:Unity + Ceedling 快速上手
Unity 是 ThrowTheSwitch 组织开源的 C 语言测试框架,和那个游戏引擎 Unity 不是一回事,别搞混了。它非常轻量,核心就一个unity.c和unity.h,提供TEST_ASSERT_EQUAL_INT、TEST_ASSERT_EQUAL_STRING这类宏断言。
配合 Ceedling 使用体验会好很多。Ceedling 是一个基于 Ruby 的构建工具,它会自动扫描src/和test/目录,帮你生成测试运行器和 mock 文件。写一个测试文件:
#include "unity.h" #include "math_utils.h" void setUp(void) {} void tearDown(void) {} void test_add_returnsCorrectSum(void) { TEST_ASSERT_EQUAL_INT(5, add(2, 3)); } void test_add_handlesNegativeNumbers(void) { TEST_ASSERT_EQUAL_INT(-1, add(2, -3)); }然后在命令行跑ceedling test:all,Ceedling 会自动编译src/math_utils.c、test/test_math_utils.c和 Unity 框架,汇总执行结果。这个流程非常像 Java 世界的 Maven 测试目录约定。
6.3 LDRA Testbed 的测试用例映射
Testbed 是 LDRA 公司的商用工具,它不只是单元测试工具,更是一个静态分析加动态测试平台,常用于汽车电子、航天等需要功能安全认证的行业,比如 ISO 26262、DO-178C。
Testbed 里有个概念叫“测试用例映射”,我理解是把需求、代码和测试用例串成一条链。比如某个需求是“当车速超过 120km/h 时点亮警告灯”,那你要在工具里建一个需求条目,关联到对应代码函数,再关联到对应的测试用例。测试通过后,工具能自动生成一份“需求-用例-代码”的覆盖报告,证明你的代码确实验证了需求,这是安全认证必需的证据链。
做 Testbed 映射时有个经验:需求和用例的颗粒度要对齐。一个需求对应多个用例是正常的,但不要把一堆需求揉在一个用例里,否则评审时根本说不清哪个需求覆盖了。颗粒度太粗,认证不通过;颗粒度太细,测试用例数量爆炸,维护成本扛不住。
6.4 VectorCAST 的自动化测试
VectorCAST 是另一套商用测试平台,支持 C/C++ 的单元测试和集成测试。它的核心亮点是自动化程度很高,能自动为被测函数生成桩函数和驱动代码,还能自动生成测试用例,甚至连 MC/DC 覆盖率分析、语句覆盖、分支覆盖、条件覆盖都帮你算好。
VectorCAST 对测试用例映射的支持也很成熟。从需求管理工具导入了需求条目后,你在 VectorCAST 里为被测函数创建测试用例时,可以直接勾选关联某条需求。后续跑完测试,它出的报告里,需求、用例、代码行覆盖、测试结果是一张表,清清楚楚。
不过,VCAST 这套工具的学习成本不低。刚开始用的时候,建议从单个函数全流程跑通开始,熟悉了再扩展到整个模块。千万别想着一上来就把整个系统全部纳入测试,管控成本会非常高。
6.5 开源和商用怎么选
我的经验是分场景:如果你做的是消费电子、工控类产品,对安全认证没有硬性要求,用 Unity + Ceedling 完全够,成本低、灵活度高。但如果项目是要过功能安全认证的,比如汽车 ECU、飞控系统,那 Testbed 或 VectorCAST 基本是刚需,因为它们提供的覆盖率分析、需求追溯、报告导出能力,开源工具替代起来非常费劲,自己搞一套合规工具链的人工成本远高于买工具的费用。
7. 常见问题排查与实操记录
7.1 测试跑得越来越慢怎么办
这是每个项目都会遇到的坎。测试一多,跑一次全量从几秒变成几分钟,再到十几分钟,团队就开始不跑测试了。我的排查思路是分层处理:
先区分哪些是单元测试,哪些是集成测试。真正的单元测试应该是毫秒级的,如果你发现一个测试跑了 5 秒,那它一定不是单元测试,要么启动了 Spring 容器,要么连了数据库,要么真的发起了网络请求。这类测试命名时就该带上@Tag("integration")或放到src/test-integration/目录,和单测分开跑。
再把慢的拉出来单独优化。定位到最慢的几个测试之后,用@MockBean替换掉@SpringBootTest,或者用mock()替换掉真实依赖,通常能把单测从秒级拉到毫秒级。
7.2 覆盖率 100% 但全是“假测试”
覆盖率这件事,我一直觉得是一面镜子,它反映的是“哪些代码被执行了”,而不是“哪些行为被验证了”。我见过有人写测试,方法里全是assertDoesNotThrow,跑完覆盖率报表全绿,但业务逻辑根本没人验证过——这显然不对。
真正有用的做法是做变异测试。所谓变异测试,就是工具自动把源码做少量修改,比如把>改成<、把+改成-,然后跑一次全量测试,看测试能不能把这些变异体抓出来。如果某个变异体通过了,说明你的测试没真正覆盖到这个条件分支。现代质量体系里面,agent 加上了一层又一层的约束:单元测试、gherkin 测试、qa 流程、质量指标、变异测试,这一整套层层递进的做法,目标就是用自动化手段把“人的疏忽”逐级兜住。
Java 生态里做变异测试的工具有 PIT Mutation Testing,前端也有 Stryker Mutator,虽然跑起来比较慢,但对核心模块做一次变异测试,比跑一百遍覆盖率仪有价值得多。
7.3 团队成员不愿意写测试怎么办
这是管理问题,不是技术问题。我见过团队买最好的工具、定最严的指标,最后测试还是没人写。原因很简单:大家觉得写测试是额外负担,对个人没好处。
我的做法是三个思路并行。第一,把“不写测试”变成代码评审里的一个硬性否决项,没有单测的核心逻辑代码不允许合并。第二,让写测试的人有获得感,比如把测试质量纳入绩效评估,而不是只看功能完成度。第三,也是最关键的,降低写测试的门槛。如果团队成员需要花半小时 mock 一个依赖才能开始写测试,那一定是生产代码设计有问题,先解决可测试性问题,再谈写测试。
7.4 问题速查表
| 症状 | 可能原因 | 解决思路 |
|---|---|---|
| 测试运行特别慢 | @SpringBootTest启动上下文 | 换成@WebMvcTest或纯 Mockito 测试 |
| mock 方法返回值一直为 null | 没有正确 stubbing | 检查when(...).thenReturn(...)是否匹配到实际调用参数 |
| 测试之间互相影响 | 共享静态状态或数据 | 每个测试独立的 setUp/tearDown,用完清理 |
document is not defined | 前端测试环境没配 DOM | 设置environment: 'happy-dom'或jsdom |
| Vue 组件触发事件后 DOM 没更新 | 忘记了 await | await wrapper.find(...).trigger('click') |
| 嵌入式测试跑一遍要很久 | 大量硬件依赖或串行执行 | 写桩函数,宿主机并行跑单元测试,目标板只跑验收测试 |
| 覆盖率达标但上线出问题 | 断言太少或断言不准确 | 补充行为断言,用变异测试评估测试有效性 |
| 团队不写测试 | 管理层无要求、无硬性门槛 | 评审拦截 + 绩效挂钩 + 降低写测试门槛 |
7.5 最后一点个人心得
做了这么多年测试,我最深的感触是:单元测试的核心不是工具,也不是覆盖率指标,而是项目里的每一个开发者,从内心深处相信“这段代码可以被验证”这句话。测试本身就是一种纪律,需要日复一日地重复。我自己现在的习惯是:每完成一个功能,不管多急,都至少为核心路径补一个测试,哪怕只是最惨淡的一行断言。量变产生质变,这个习惯帮我省下的排查时间,远远大于写测试花掉的时间。
如果这篇文章能让你少踩一个坑,就够了。