ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

单元测试全面指南:从核心概念到前端与嵌入式实战

单元测试全面指南:从核心概念到前端与嵌入式实战 1. 单元测试到底在测什么先搞清楚这三件事再动手开始聊单元测试之前我想先纠正一个很多新人都会有的误解单元测试不是把你写的函数跑一遍、看看结果对不对就完事了。它真正在做的事情有三件验证“输入→输出”的正确性、锁定代码行为防止后续改动破坏已有功能、以及逼迫你把代码写得更容易被测试——这第三点往往才是最大的收获。你想想一个函数如果耦合了一堆外部依赖比如直接读数据库、调网络请求、依赖全局变量你根本没法把它单独拎出来测。所以“能不能写单元测试”本身就是代码质量的试金石。项目里引入单元测试后你一定会被迫去拆模块、抽象接口、隔离副作用这些动作对代码架构的改善通常比测试本身带来的价值还大。那到底什么算“单元”狭义理解是一个函数、一个方法实践里通常是一个类、一个模块、一个组件。不同语言、不同技术栈“单元”的粒度不太一样。Java里常见的是测一个Service类的某个方法Vue项目里可能是测一个组件的渲染结果和交互行为嵌入式C项目里可能是测一个函数在给定输入下是否返回预期值。粒度虽然不同但核心思想一致隔离被测对象控制所有外部因素只看被测对象本身的表现。这篇文章我会把单元测试这件事从头到尾拆开讲覆盖为什么做、怎么做、常见的坑在哪、以及不同场景下的实操套路。内容会比较多但每一条都是我在真实项目里验证过的东西不会跟你说一堆空话。2. 为什么你的项目需要单元测试以及它解决不了什么问题2.1 单元测试的真正价值不是“测bug”而是“敢重构”很多人以为单元测试是为了找bug。这么说也不算错但它真正的价值在项目进入维护期之后才体现出来。功能开发阶段你写一个模块跑一下、调通、界面能点看起来就“正常”了。真正可怕的是三个月后产品经理过来说“这个页面加个筛选条件那个接口字段改一下”。你改了A模块结果B模块莫名其妙挂了你排查了半天发现是A模块的一个返回值格式变了B模块下游没接住。这种问题靠人工回归测试能发现但成本很高——你得记得所有受影响的功能点还得一个个手工点一遍。有了单元测试回归就变成了一条命令的事。跑一次全量测试几秒钟出结果红了就说明你改坏了什么而且能精确到是哪个函数、哪个断言挂了。有了这张安全网你才敢去重构那些看起来“能跑但很乱”的代码。没有测试保护的代码你动它之前手心是冒汗的有测试保护的代码你改它就像换了刹车片再去开车。2.2 单元测试解决不了的问题别把测试当万能药这里得泼一盆冷水。单元测试能保证“每个零件单独工作正常”但它保证不了“零件组装起来能正常运转”。集成问题、部署问题、真实环境下的性能问题、并发问题这些都不是单元测试的覆盖面。所以稍微成熟一点的团队测试体系是分层的单元测试解决单模块逻辑正确性集成测试解决模块间协作端到端测试走真实用户路径。单元测试是地基但不是全部。你要是项目里只有单元测试指望它兜住所有问题那上线前照样会慌。另外单元测试对UI视觉效果基本无能为力。按钮颜色对不对、布局是否错位、动画流不流畅这些需要肉眼判断的东西不是单元测试的菜。组件测试能测的是渲染出来的DOM结构、文本内容、事件触发后的状态变化而不是“好不好看”。2.3 什么时候不该硬上单元测试说实话不是所有代码都值得写单元测试。我自己判定的标准是三条有没有复杂逻辑比如条件分支多、边界情况多、算法密集的模块值得测。后不后悔改它那种你每次改动都提心吊胆的代码赶紧补测试。是不是基础设施像工具函数、公共组件、核心业务逻辑全项目都在依赖它们必须测。反过来纯样板代码、一次性脚本、还没稳定下来的原型页面硬写测试就是浪费时间。测试也有维护成本这一点很多人不提。你写了100个断言其中80个永远不失败剩下20个每次改需求都要跟着改——那你得掂量掂量这测试是资产还是负债。3. 单元测试的核心概念断言、测试用例、测试套件与生命周期3.1 断言最小单位的“对错判断”不管用什么语言、什么测试框架单元测试最底层的构件就是断言。断言的本质就是一句话“我这里期望得到X如果实际不是X那这一条就失败。”// JavaScript 里用 Node.js 内置的 assert 模块 import assert from node:assert/strict; // 断言两个值严格相等 assert.equal(add(1, 2), 3); // 断言某个条件为真 assert.ok(result.length 0, 结果列表不应该为空); // 断言抛出指定异常 assert.throws(() { parseJSON(invalid json); }, SyntaxError);用Java写的话JUnit里的断言长这样assertEquals(5, calculator.add(2, 3)); assertTrue(result.isSuccess()); assertThrows(IllegalArgumentException.class, () - service.process(null));Python这边是用assert语句或者pytest的断言语法def test_add(): assert add(2, 3) 5 def test_raises(): with pytest.raises(ValueError): parse_int(abc)你会发现断言这玩意儿本身很简单就是个布尔判断。但写断言是最考验功力的地方断言写得好不好决定了测试失败时你能不能一眼看出问题在哪。好的断言是“精确”的——我只关心我要验证的那个行为而不是把一堆无关的东西全塞进去。3.2 测试用例、测试套件与生命周期函数单个测试用例就是一个完整的“输入→执行→断言”流程。多个测试用例组织在一起就形成测试套件。几乎所有成熟的测试框架都提供生命周期钩子让你在特定时机执行准备和清理工作。拿Jest举例生命周期钩子有这么几个钩子函数执行时机典型用途beforeAll当前文件所有测试之前执行一次建立数据库连接、初始化全局配置beforeEach每个测试用例之前执行构造被测对象、重置状态afterEach每个测试用例之后执行清理测试数据、还原mockafterAll当前文件所有测试之后执行一次关闭数据库连接、释放资源这里有一个关键原则每个测试用例必须相互独立。用例A的执行结果不能影响用例B。你可能会想那我用beforeAll里创建好的对象所有用例共用不就行了吗行是行但如果你某个测试内部改了那个对象的状态下一个测试拿到的就不是最初的状态了测试就会变得不稳定。所以经验法则能用beforeEach就绝不用beforeAll去准备可变状态。class Stack { constructor() { this.items []; } push(item) { this.items.push(item); } pop() { return this.items.pop(); } get size() { return this.items.length; } } describe(Stack 类, () { let stack; // 每个测试前都创建新实例确保隔离 beforeEach(() { stack new Stack(); }); test(push 之后 size 增加, () { stack.push(a); expect(stack.size).toBe(1); }); test(pop 返回并移除栈顶元素, () { stack.push(x); stack.push(y); expect(stack.pop()).toBe(y); expect(stack.size).toBe(1); }); test(空栈 pop 返回 undefined, () { expect(stack.pop()).toBeUndefined(); }); });3.3 测试命名写给三个月后的自己看测试代码也是代码而且是给未来的你看的文档。命名不规范测试失败了你还得靠读源码才能猜出它想验证什么那就失去了意义。一个好的测试命名应该回答三个问题被测对象是什么、前提条件是什么、期望结果是什么。对比一下下面两种命名// 反面教材看不出想干嘛 test(test_add, () { ... }); test(works correctly, () { ... }); // 正面教材一句话说清楚行为 test(add() 传入负数时抛出 RangeError, () { ... }); test(当库存不足时订单状态保持为 pending, () { ... });中文完全可以用我自己的项目里就大量用中文测试名编写和阅读的效率都更高。团队统一标准就行别混着来。4. 单元测试的三大技术支柱Mock、Stub与依赖注入4.1 为什么要Mock把“外部世界”隔离掉真实项目里的函数很少有纯粹的计算型函数。它们会调用数据库、请求HTTP接口、读文件、发消息队列、依赖系统当前时间……这些外部依赖在测试环境里要么不可用要么不可控要么跑起来太慢。Mock模拟对象就是用来解决这个问题的你用一个可控的替身去替代真实依赖替身的行为由你设定它记录自己被调用的情况供你事后断言。这样一来被测函数完全不感知自己面对的是替身而你的测试则获得了对“外部世界”的完全掌控。打个比方你要测试一个“下单”函数它内部会调用“库存服务”。真实测试里你总不能真去连库存系统万一库存真不够测试就失败了。正确的做法是Mock一个库存服务设定“查询库存返回100”或者“返回0”然后分别断言下单函数的行为。这样你能在几分钟内把“库存充足”“库存不足”“库存服务超时”这些路径全测一遍这在真实集成环境里根本做不到。4.2 Stub与Mock的区别别混为一谈很多人Stub和Mock混着叫但严格说它们是两种不同的替身Stub桩只提供预设的返回值不关注它被怎么调用的。“你调用我就返回这个结果。”Mock模拟对象除了提供返回值还能验证交互行为——它被调用了几次、传了什么参数、调用顺序是什么。“你调用了我我希望你是用参数X调用的而且只调了两次。”实际使用中大部分框架的mock功能同时涵盖了这两者。你在做行为验证时用的是Mock在做状态验证时用的是Stub。比如“查询库存返回0”是为了让被测代码进入“库存不足”分支那这就是Stub你要验证“当用户未登录时调用重定向函数且只调用一次”那这就是Mock的职责。4.3 手动依赖注入什么时候做、什么时候用框架控制外部依赖有两种主流做法依赖注入手动传参和Mock框架自动替换。依赖注入的思路是被测对象不自己创建依赖而是通过构造函数、方法参数等方式把依赖传进来。这样测试时直接传入测试替身就行完全不需要Mock框架。// 反模式内部直接 new 一个依赖 class OrderService { private payment: PaymentGateway; constructor() { this.payment new PaymentGateway(); // 测试时没法替换 } } // 正模式依赖注入外部传入 class OrderService { constructor(private payment: PaymentGateway) {} // 测试时传一个 Mock 的 PaymentGateway 进来 }这种设计对代码架构本身也是有好处的——模块间依赖变得显式而不是暗藏在底层。代价是你需要在创建对象的地方手动拼装依赖代码会多一些样板。Mock框架比如Jest的jest.mock()、Python的unittest.mock、Java的Mockito则是在运行时动态替换模块或对象的方法。它的好处是侵入性小不用改生产代码坏处是容易让人产生依赖——反正什么都能mock写代码的时候就不太在意依赖关系了长期下来架构会变烂。我的习惯是代码里能依赖注入的地方优先用依赖注入只有实在改不动生产代码了才上Mock框架。这不是洁癖而是代码可测试性的问题。依赖注入是架构层面的改善Mock框架是测试层面的补丁。4.4 一个真正实用的Mock示例场景有一个用户注册函数内部调用数据库检查邮箱是否已存在、写入用户记录、发送欢迎邮件。三个依赖全是外部的全部要mock。// userService.js async function registerUser({ email, password }, deps) { const { userRepo, emailService } deps; const existing await userRepo.findByEmail(email); if (existing) { throw new Error(邮箱已被注册); } const user await userRepo.create({ email, password }); await emailService.sendWelcomeEmail(user); return user; } // userService.test.js describe(registerUser, () { const userRepo { findByEmail: jest.fn(), create: jest.fn(), }; const emailService { sendWelcomeEmail: jest.fn(), }; const deps { userRepo, emailService }; beforeEach(() { jest.clearAllMocks(); }); test(邮箱已存在时抛出异常且不创建用户, async () { userRepo.findByEmail.mockResolvedValue({ id: 1, email: ab.com }); await expect(registerUser({ email: ab.com, password: 123456 }, deps)) .rejects.toThrow(邮箱已被注册); expect(userRepo.create).not.toHaveBeenCalled(); expect(emailService.sendWelcomeEmail).not.toHaveBeenCalled(); }); test(注册成功时创建用户并发送欢迎邮件, async () { userRepo.findByEmail.mockResolvedValue(null); userRepo.create.mockResolvedValue({ id: 2, email: newb.com }); const user await registerUser({ email: newb.com, password: 123456 }, deps); expect(userRepo.create).toHaveBeenCalledWith( expect.objectContaining({ email: newb.com }) ); expect(emailService.sendWelcomeEmail).toHaveBeenCalledTimes(1); expect(user.id).toBe(2); }); });这个例子里我用的是手动依赖注入通过deps传入依赖配合Jest的jest.fn()做测试替身。你会发现整个过程不需要mock模块系统测试逻辑特别直白而且生产代码也因为这层设计变得更清晰——你一眼就能看出registerUser依赖哪些东西。5. 单元测试的代码覆盖率指标怎么解读多少才算够5.1 四种常见覆盖率指标覆盖率是衡量测试对代码覆盖程度的量化指标常用的一共有四种指标含义通俗解释行覆盖率被测代码被执行到的行数占比哪些行跑过了函数覆盖率被测代码被调用过的函数占比哪些函数被调了分支覆盖率if/else、switch等分支被覆盖的占比哪些条件分支走过了语句覆盖率被执行到的语句占所有语句的占比哪些语句执行过了行覆盖率高不等于测试质量高。举个极端例子一个函数有100行其中一个if分支内部的代码被覆盖了但条件判断里“为假”的那个分支检测没测过。行覆盖率可能达到90%但漏掉的恰恰是最容易出bug的边界分支。所以在我眼里分支覆盖率比行覆盖率更值得关注。一个条件判断的两个分支真/假都走到了说明你对这个判断的正反面都有了验证这比单纯追求行数更有意义。5.2 覆盖率目标别盲目追求100%“覆盖率必须100%”是我见过的最深的坑之一。为了凑100%你会写出大量“只为了把某行代码跑一遍”的测试——没有有意义的断言没有边界验证纯粹是凑数。这种测试完全没价值还会拖慢测试执行时间、增加维护成本。业界的共识一般是核心业务模块覆盖率建议80%~90%以上工具类、公共组件尽量高90%上下基础设施、异常处理逻辑重点看分支覆盖率UI、配置、样板代码不用刻意追求我自己在项目里通常把行覆盖率目标定在80%分支覆盖率定在70%同时配合一条硬性规则新增代码必须有对应的测试逻辑覆盖新增分支没有例外。这条规则不追求绝对数值好看但能保证新代码不会裸奔。5.3 覆盖率报告怎么用才有价值生成覆盖率报告不是目的看报告才是。我拿到一份覆盖率报告最关心的不是总百分比数字而是几个具体视图第一未被覆盖的行高亮。哪个文件、哪个函数、哪一行代码没被覆盖到。第二步是思考为什么没覆盖是漏了测试还是这段代码本身是死代码如果是死代码那删掉它比补测试更有价值。第二分支覆盖细节。某个if条件的“假分支”没走到往往意味着一个边界场景没测到。针对性地补数据。第三差异对比。这次提交新增的代码里有哪些还没覆盖——这就是回归测试的盲区。6. 前端单元测试专项Vue 3 Vitest Vue Test Utils 实战6.1 工具选型Vitest、Jest、Mocha怎么选前端单元测试的框架从早期MochaChai到后来大行其道的Jest再到现在的Vitest工具迭代还是比较快的。我的建议是新项目直接上Vitest。Vitest最大的优势不是快而是跟Vite共享同一套配置。你项目里Vite的resolve.alias、define、pluginsVitest直接就能用不用像Jest那样还得单独配一遍moduleNameMapper。再加上Vitest对ESM和TypeScript的原生支持写测试的时候几乎零配置。Jest的问题在于它对ESM的支持一直是二等公民。Vue3项目基本都是ESM你用Jest就得处理transform、moduleNameMapper、ts-jest这些配置体验一言难尽。MochaChai组合更偏向Node.js后端前端组件测试场景支持比较弱。选型结论Vitest是首选Vue Test Utils官方测试工具库 vue/test-utils 负责组件挂载和交互模拟。如果你项目还在用Vue2那Jest方案相对成熟一些但也到该升级的时候了。6.2 环境搭建从零开始配好一套可跑的测试环境假设你已经有一个Vue3 Vite项目安装依赖npm install -D vitest vue/test-utils vitest/ui jsdom然后在vite.config.ts里加测试配置/// reference typesvitest / import { defineConfig } from vite; import vue from vitejs/plugin-vue; export default defineConfig({ plugins: [vue()], test: { // 测试环境用 jsdom模拟浏览器 DOM environment: jsdom, // 开启全局模式可以在测试文件里直接用 describe/it/expect不用 import globals: true, // 覆盖率 coverage: { provider: v8, reporter: [text, html], include: [src/**/*.{ts,vue}], exclude: [src/main.ts, src/router/**], }, }, });在package.json里加脚本{ scripts: { test: vitest, test:run: vitest run, test:coverage: vitest run --coverage } }这里有个点要提醒environment: jsdom意味着组件的生命周期钩子、DOM操作都能模拟但它不是真浏览器。像window.matchMedia、ResizeObserver这类API jsdom默认没有遇到就得手动mock。6.3 组合式函数Composable测试Vue3里大量逻辑被抽象成组合式函数useXxx这类纯逻辑的测试写起来最舒服因为没有DOM参与直接当普通函数测试。// src/composables/useCounter.ts import { ref } from vue; export function useCounter(initial 0) { const count ref(initial); const increment () { count.value; }; const decrement () { count.value--; }; const reset () { count.value initial; }; return { count, increment, decrement, reset }; } // src/composables/__tests__/useCounter.spec.ts import { useCounter } from ../useCounter; describe(useCounter, () { test(初始化值为传入的参数, () { const { count } useCounter(10); expect(count.value).toBe(10); }); test(increment 每次增加 1, () { const { count, increment } useCounter(0); increment(); increment(); expect(count.value).toBe(2); }); test(decrement 到 0 后不会变成负数防抖逻辑, () { const { count, decrement } useCounter(0); decrement(); expect(count.value).toBe(0); }); });6.4 组件测试挂载、断言、交互一网打尽组件测试你要掌握三件事怎么挂载、怎么断言、怎么模拟交互。挂载用mount()挂载完整组件或shallowMount()挂载但不渲染子组件。交互用wrapper.find()定位元素用.trigger()触发事件用.setValue()设置输入框值。断言主要看DOM里的文本、类名、属性以及组件实例的状态。// src/components/CounterButton.vue template div p classcount{{ count }}/p button classincrement clickcount1/button button classreset clickcount 0重置/button /div /template script setup langts import { ref } from vue; const count ref(0); /script // src/components/__tests__/CounterButton.spec.ts import { mount } from vue/test-utils; import CounterButton from ../CounterButton.vue; describe(CounterButton, () { test(初始渲染显示0点击1后变为1, async () { const wrapper mount(CounterButton); // 初始值 expect(wrapper.find(.count).text()).toBe(0); // 触发点击 await wrapper.find(.increment).trigger(click); // 点击后更新 expect(wrapper.find(.count).text()).toBe(1); }); test(点击重置按钮后归零, async () { const wrapper mount(CounterButton); await wrapper.find(.increment).trigger(click); await wrapper.find(.increment).trigger(click); expect(wrapper.find(.count).text()).toBe(2); await wrapper.find(.reset).trigger(click); expect(wrapper.find(.count).text()).toBe(0); }); });6.5 Pinia状态管理与Vue Router的测试状态管理和路由是前端组件里最常见的两个外部依赖处理不当测试就是一团乱麻。Pinia测试的关键是每个测试用createPinia()创建独立实例逻辑从组件里抽出来先测store本身再测用了store的组件。// src/stores/user.ts import { defineStore } from pinia; export const useUserStore defineStore(user, { state: () ({ name: , isLogin: false, }), actions: { login(name: string) { this.name name; this.isLogin true; }, logout() { this.name ; this.isLogin false; }, }, }); // src/stores/__tests__/user.spec.ts import { createPinia, setActivePinia } from pinia; import { useUserStore } from ../user; describe(user store, () { beforeEach(() { // 每个测试前重置 Pinia 实例 setActivePinia(createPinia()); }); test(login 后 isLogin 变为 true, () { const store useUserStore(); store.login(张三); expect(store.isLogin).toBe(true); expect(store.name).toBe(张三); }); test(logout 重置状态, () { const store useUserStore(); store.login(张三); store.logout(); expect(store.isLogin).toBe(false); expect(store.name).toBe(); }); });Vue Router测试有一个坑真实路由实例需要router.push()触发导航这在测试环境里不但慢而且容易出怪问题。官方插件pinia/testing配合createTestingPinia可以给组件注入临时store路由则建议直接用mock的router对象或者把需要路由感知的逻辑从组件里抽出来单独测。组件测试时可以通过global.plugins注入Pinia实例也可以用global.mocks给组件塞假的$route和$router// 组件测试里注入 mock 路由 const wrapper mount(MyComponent, { global: { mocks: { $route: { params: { id: 123 } }, $router: { push: vi.fn() }, }, }, });6.6 ESLint Prettier 与 Vitest 集成很多人问ESLint和Prettier跟单元测试有什么关系关系很大。你写的测试代码也是代码同样需要统一的风格和质量门禁。Vitest项目里常见的组合是npm install -D eslint eslint-plugin-vitest然后在ESLint配置里加上vitest插件// eslint.config.js import vitest from eslint-plugin-vitest; export default [ // ... 其他配置 { files: [**/*.spec.ts, **/*.test.ts, **/__tests__/**], plugins: { vitest }, rules: { ...vitest.configs.recommended.rules, }, }, ];这样做的好处是测试代码里常见的错误比如忘记写断言、describe嵌套过深、.only被误提交都能在CI阶段就被拦截下来。尤其.only——如果你调试时不小心留了一个test.only()在测试文件里本地跑没问题但是CI上只会执行这一个测试其他测试全被跳过等上线后才发现好多功能根本没测到。真踩过一次你就知道有多痛。7. 嵌入式软件单元测试怎么做Testbed与其他方案对比7.1 嵌入式单元测试的特殊性跑在PC上而不是板子上嵌入式软件的单元测试和互联网后端、前端最大的区别你不能直接在目标板上跑测试。原因很多板子资源有限、没有标准输入输出、烧录一次固件很慢、有些硬件依赖无法在实验室复现比如传感器信号、通信时序。所以嵌入式单元测试的主流做法叫“宿主测试”Host-based Testing——把被测代码编译成PC上可运行的版本在开发机上跑测试。这要求被测代码尽量做到“平台无关”不直接操作寄存器、不内联汇编、不直接访问硬件地址而是通过抽象层HAL去访问硬件。举个例子你写了一个计算温度的算法函数输入是ADC原始值输出是摄氏度。这个函数本身不碰硬件那你完全可以在PC上编译它喂不同的ADC值断言输出的温度是不是预期的。这就是嵌入式单元测试最典型的场景。7.2 难点硬件依赖怎么测真正棘手的是那些依赖硬件的代码。比如一个控制LED闪烁的函数直接操作GPIO寄存器void led_blink(int times) { for (int i 0; i times; i) { REG_GPIO | (1 5); // 点亮 delay_ms(500); REG_GPIO ~(1 5); // 熄灭 delay_ms(500); } }这段代码在PC上根本编译不了——REG_GPIO这个地址在PC上不存在。解决办法是指定HAL抽象把寄存器操作封装成函数// hal_led.h void hal_led_on(void); void hal_led_off(void); // led.c void led_blink(int times) { for (int i 0; i times; i) { hal_led_on(); delay_ms(500); hal_led_off(); delay_ms(500); } }测试时用C语言的mock框架比如CMock、Fake Function Framework去替换hal_led_on和hal_led_off然后统计函数被调用了几次、调用的顺序对不对。7.3 Testbed单元测试实战不少嵌入式团队用的工具叫Testbed它来自LDRA。Testbed本身不只是一个单元测试工具更准确的定位是“静态分析动态测试覆盖率分析”一体化平台在航空航天、汽车电子这些对安全性要求极高的行业里用得比较多。Testbed做单元测试的流程通常是导入代码把你写好的C/C代码导入Testbed。编译配置选择编译器、指定头文件路径、宏定义。静态分析Testbed先跑一轮静态分析找出编码规范问题、可疑代码未初始化变量、不可达分支等。自动生成测试用例Testbed可以根据代码自动生成桩代码Stubs和驱动代码Drivers把被测函数包裹起来自动填充参数。插桩在代码关键位置插入探针用于采集覆盖率信息。执行测试在主机环境运行测试收集结果和覆盖率。生成报告输出每个函数的测试结果、行覆盖率、分支覆盖率、MC/DC覆盖率等。MC/DC覆盖率是Testbed这类工具很看重的一个指标——每个条件的每个布尔值都至少影响一次判定结果。这是航空航天安全标准比如DO-178C的硬性要求一般互联网项目很少见到。7.4 轻量级替代方案Unity CMockTestbed很强大但价格不便宜学习曲线也陡峭。如果你的嵌入式项目没那么严格的认证要求仅仅是想让逻辑代码有测试保护我推荐另一套轻量级组合Unity CMock。Unity是一个超轻量的C语言测试框架核心就几个文件源码量极小非常适合嵌入式环境。CMock是它的配套mock工具可以自动根据头文件生成mock函数。// 被测函数avg_filter.c // 计算滑动平均窗口大小为3 #include avg_filter.h static float buffer[3]; static int idx 0; static int count 0; float avg_filter(float new_value) { buffer[idx] new_value; idx (idx 1) % 3; if (count 3) count; float sum 0; for (int i 0; i count; i) { sum buffer[i]; } return sum / count; } // 测试文件test_avg_filter.c #include unity.h #include avg_filter.h void setUp(void) {} void tearDown(void) {} void test_avg_filter_initial_value(void) { TEST_ASSERT_FLOAT_WITHIN(0.001, 5.0, avg_filter(5.0)); } void test_avg_filter_average_of_three(void) { avg_filter(1.0); avg_filter(2.0); TEST_ASSERT_FLOAT_WITHIN(0.001, 2.0, avg_filter(3.0)); } void test_avg_filter_overflow_wraps(void) { avg_filter(100.0); avg_filter(200.0); avg_filter(300.0); // 第四个值进来后窗口应该 [400, 200, 300] TEST_ASSERT_FLOAT_WITHIN(0.001, 300.0, avg_filter(400.0)); } int main(void) { UNITY_BEGIN(); RUN_TEST(test_avg_filter_initial_value); RUN_TEST(test_avg_filter_average_of_three); RUN_TEST(test_avg_filter_overflow_wraps); return UNITY_END(); }然后在PC上编译运行gcc -o test_runner \ test_avg_filter.c avg_filter.c unity.c \ -I. -Iunity -lm ./test_runner你还可以在CMake里集成CTest让测试自动跑起来include(CTest) add_executable(test_avg_filter test/test_avg_filter.c src/avg_filter.c third_party/unity/unity.c ) target_include_directories(test_avg_filter PRIVATE src third_party/unity ) add_test(NAME avg_filter_tests COMMAND test_avg_filter)7.5 嵌入式单元测试的覆盖率与平台策略嵌入式项目里能跑宿主测试的代码通常集中在算法逻辑、通信协议解析、状态机、数据处理这几类模块。硬件驱动层和中断服务程序比较难test一般靠的是代码评审集成测试硬件在环测试来兜底。还有一点特别重要“编译器不同行为可能不同。”嵌入式开发里目标平台编译器是arm-none-eabi-gcc这类交叉编译工具链而宿主测试用的是本机gcc。C语言里有些行为在标准里是“未定义”的不同编译器处理可能不一样。比如整数溢出、类型转换的边界行为在目标编译器上产出的结果和宿主编译器可能不同。所以宿主测试通过了不代表目标板行为一定正确——这就是为什么嵌入式项目还会做硬件在环测试比如用QEMU模拟目标环境或者直接在开发板上跑集成测试来验证编译器和运行时行为。8. 常见问题与排查技巧实录8.1 测试老是“不稳定”——一会过一会挂不稳定测试Flaky Test是最磨人的问题。我总结过最常见的几个原因原因一测试间状态泄漏。用例A修改了某个全局状态用例B没清干净就开始了。排查方法把测试单独跑一遍如果单独跑全绿、全部跑就不是稳定那基本就是状态泄漏。原因二依赖了真实时间。测试代码里用了真实的setTimeout、new Date()、Date.now()导致断言受真实时间影响。解决办法是引入假的定时器或者mock时间。// Vitest 中启用伪定时器 beforeEach(() { vi.useFakeTimers(); }); test(debounce 函数在等待时间内只触发一次, () { const fn vi.fn(); const debounced debounce(fn, 1000); debounced(); debounced(); debounced(); vi.advanceTimersByTime(1000); expect(fn).toHaveBeenCalledTimes(1); });原因三异步没等完就断言。常见于前端组件测试触发了事件后立即断言还没来得及等DOM更新。解决办法是await下一次DOM更新周期await nextTick()或await flushPromises()。test(点击后异步加载数据并渲染, async () { const wrapper mount(FetchComponent); await wrapper.find(button).trigger(click); // 必须 await 异步函数完成否则断言时数据还没到 await flushPromises(); expect(wrapper.text()).toContain(加载完成); });原因四随机数据。测试里用了Math.random()生成参数结果依赖了随机值。遇到这种情况mock掉随机数生成器或者把随机数种子固定。8.2 组件在测试环境里报错“window is not defined”凡是遇到这个错误第一反应就是你的测试环境不是浏览器环境。Vitest默认的environment是node如果你要测组件、DOM相关的东西必须把environment改成jsdom或happy-dom。改了还报错的话说明你使用的某个第三方库依赖了jsdom没实现的API比如window.matchMedia需要手动mock// 在测试文件顶部或者 setup 文件里 Object.defineProperty(window, matchMedia, { writable: true, value: (query: string) ({ matches: false, media: query, onchange: null, addListener: vi.fn(), removeListener: vi.fn(), addEventListener: vi.fn(), removeEventListener: vi.fn(), dispatchEvent: vi.fn(), }), });8.3 Vue3组件测试中“wrapper.find”找不到元素十个有八个是时机问题。组件挂载后DOM是异步更新的如果你在触发某个状态变化后立刻findDOM还没来得及重新渲染自然找不到。记住一个口诀改了响应式状态后先await nextTick()再find。import { nextTick } from vue; test(条件渲染, async () { const wrapper mount(MyComponent); expect(wrapper.find(.loading).exists()).toBe(true); // 触发加载完成 wrapper.vm.state loaded; await nextTick(); expect(wrapper.find(.content).exists()).toBe(true); });8.4 覆盖率明明很高上线还是出了bug这是最打击人的情况了。覆盖率数字漂亮不代表测对了。我的经验是出现这种问题通常有三个原因一是断言写得不够严格。比如只是断言“函数不报错”但没有校验返回值具体内容。相当于检查了“程序跑了没”没检查“程序跑得对不对”。二是mock得太狠了。把外部依赖全mock掉测试就变成了自娱自乐——被测代码和mock的依赖一起编了一个故事但真实世界里依赖的行为跟mock不一样。解决方案是引入一些轻量的测试替身比如用真实的内存数据库替代mock的Repository。三是集成环节出了问题。单元测试覆盖了“单元”的正确性但单元和单元对接的那一层没测。这就是为什么我前面说单元测试不是全部它需要配合集成测试。8.5 测试写在工程里但CI上没跑这个听起来很基础但我真见过不少团队的CI配置压根没执行测试命令。写好测试不跑跟没写没区别。CI的最小配置记得加一步# GitHub Actions 示例 steps: - name: 安装依赖 run: npm ci - name: 跑单元测试 run: npm run test:run - name: 生成覆盖率报告 run: npm run test:coverage再进阶一步用--changedSince或者--onlyChanged之类的参数做增量测试只测本次提交涉及的代码能显著缩短CI时间。9. 实践中的几条心得体会最后说几个我自己的习惯都是踩过坑以后沉淀下来的。测试代码里没有“重构”只有“重写”。测试代码的重点是“表达行为”不是“写得多优雅”。我见过有人为了减少重复在测试里写了一套精致的工厂函数结果测试本身出了bug花了一下午排查最后发现是工厂函数的问题。测试代码宁可重复、冗余一点也要直白——别人一天后看你的测试代码应该三秒钟能看懂你想验证什么。那些高度抽象的测试工具函数会让真正想了解行为的人抓狂。“红→绿→重构”的节奏一定要守。先写一个会失败的测试红再让测试通过绿最后做代码优化重构。这个顺序本身就是设计过程——为了写出可通过的测试你得提前想清楚函数签名、返回值、异常处理。很多开发者的习惯是先写实现再补测试顺序反了测试往往只能覆盖到“实现已经做对的场景”而真正的边界问题早就被忽略了。测试文件放哪里团队要统一。常见做法有两种一种是跟被测文件同目录建__tests__文件夹一种是独立一个test目录。两种各有各的道理我更倾向于跟被测文件放在一起。理由很简单依赖的代码和测试代码挨得近改代码的时候原生就能看到旁边的测试文件提醒你“改这里会挂”不用额外翻目录。“有空再补测试”是最大的谎言。项目里永远找不到“有空”的时间。测试这件事最佳时机就是代码写完之后、功能合并之前——哪怕一开始只覆盖主路径也比完全裸奔强。从来没见过哪个项目是上线后靠某个周末补了一堆测试的。我个人在实际操作中体会最深的一点是单元测试最难的部分从来不是怎么写测试而是怎么设计出让测试好写的代码。当你发现一个函数死活没法测时停下来想一想——不是测试工具不行大概率是这个函数本身的责任边界不清晰。顺着测试的阻力去重构代码往往能发现架构上隐藏的问题。这一点可能才是单元测试带给一个项目最大的长期价值。
返回列表