想象你是一家餐厅的老板,刚招了一批新厨师。你不可能等客人投诉才回头修菜——那就等于线上事故。于是你定下规矩:每道新菜上市前,必须走完三道尝菜程序。先单独尝每味食材,再尝混合后的酱汁与主料,最后换成顾客身份把整桌菜从头吃一遍。尝菜的过程就是代码里的测试,那张写满尝菜方法、能一键执行的检查清单,就是测试框架。Jest 像你用惯了的标准菜谱,稳妥、全面、资料多;Vitest 则像新开的效率厨房,食材早就在 Vite 传送带上备好,出手快、复用流程顺。本文就用这条后厨主线,带你把测试金字塔、单测写法、Jest 与 Vitest 配置、Mock 替身、覆盖率、快照测试和 TDD 一次讲透——读完你就能在自己的项目里把测试灶台搭起来。
掌握测试框架,等于让代码在交付前先被验证一遍。这是从“能写页面”走向“能扛工程”的分水岭,也是前端基建、CI 质量治理岗位的核心能力。
可视化演示
展示测试金字塔层级结构、Jest与Vitest核心能力对比,以及TDD红绿重构循环的开发流程。
本节配有交互动效(进入视口后自动加载,约 3KB)。
测试金字塔层级 单元测试:数量最多,快速隔离验证单个函数 E2E测试:数量最少,模拟真实用户操作流程 Unit > Integration > E2E 金字塔结构确保测试速度和覆盖率的平衡
测试金字塔
新菜单上线前,主厨会在后厨把三道尝菜流程走一遍:开火前单独尝每味食材,下锅后尝酱汁与主料的融合度,最后以顾客身份坐在餐桌前完整点一遍。三道程序一道都不能少,但成本差异巨大。测试金字塔,就是把这套尝菜哲学搬到代码世界。
代码里的“味觉关卡”分为三层:单元测试(unit test)锁住单个函数或组件的输出;集成测试(integration test)验证模块与模块的协作;端到端测试(E2E)启动真实浏览器模拟用户完整操作。层级越往上,越接近真实体验,也越慢、越贵、越容易被环境波动影响。
因此工程团队把最多、最快的单元测试放在塔底,少量 E2E 放在塔尖。为什么分层有用?可以用“漏网概率”来理解:假设每层发现缺陷的概率分别是 p_U、p_I、p_E,三层各自独立把关,那么所有层都漏掉才会让缺陷溜到线上。总发现概率是一个简单又经典的概率公式:
主厨不会顿顿把整桌菜从头吃一遍,但会保证每道工序都有人把关。金字塔的比例就是“便宜的多备、昂贵的少用”的平衡——你以后评审测试方案时,第一反应就该是:这层贵不贵?这层够不够多?
- p_U / p_I / p_E各层测试单独发现某个缺陷的概率,取值在 0 到 1 之间。
- 1 减 p这一层漏掉缺陷、让问题继续溜走的概率。
- 三层连乘只有三层同时漏掉,缺陷才无人拦截。
- 用 1 减去全漏从“绝对漏掉”的反面推出“至少一层抓住”。
单元测试
单元测试相当于开火前单独尝一口盐。你不需要等待整锅菜熟,只需要知道“这一味食材本身有没有坏”。对前端来说,“食材”就是一个个函数、工具库、组件的 Props 计算逻辑。
在 Jest 与 Vitest 中,一个单元测试由三个零件组成:describe 把相关用例归成一个“食材篮子”,it/test 描述具体用例,expect 做出断言。断言是硬比较,不是印象——框架只认“相等、不等、是否为真”这类明确结论。
常见的断言写法有:expect(actual).toBe(expected) 判断引用相等,对象内容比较用 toEqual 做深比较;toBeTruthy、toBeNull、toHaveLength 覆盖常见场景。从抽象角度看,每个用例都是一个纯函数:输入被测函数与参数,输出“过/不过”。
单独尝食材有两大好处:定位快、跑得快。一个工具函数出了问题,describe 里的用例名会直接指出是哪一味食材坏了,而不是让你去翻整本菜谱。
- f(x)被测函数在测试中的真实输出,也叫 actual。
- expected你写用例时锁定的正确答案。
- 比较命中实际等于期望则通过,否则断言抛异常。
- 汇总判定框架收集所有用例结果,决定测试进程退出码,CI 据此拦截。
Jest配置
Jest 就像后厨那本写满检查清单的标准菜谱:每道工序都规定“切多大、放多少盐”,还带页码快捷键,随时能翻到某一小节执行。它由 Facebook 开源,是目前生态最成熟、文档最全的前端测试框架之一。
Jest 的核心理念是“零配置起步”:默认扫描 *.test.js 与 tests 目录下的文件。你只需 npm install --save-dev jest,再在 package.json 写入 "test": "jest"。需要自定义时,用 npx jest --init 生成 jest.config.js,可配置 testMatch 匹配规则、testEnvironment 指定 JSDOM 或 Node 环境、coverageThreshold 设覆盖率门槛。
常用命令包括:npm test 全量执行,--watch 监听文件变化自动重跑,-t “关键词” 只跑匹配的用例。Jest 还能当质量门禁:在 CI 里,只要行覆盖率或分支覆盖率低于阈值,测试进程就以非零码退出,构建直接红灯。判定逻辑可以用一个条件式表示:
标准菜谱的价值在于可复制:任何新厨师照着清单也能稳定出品。Jest 同理——配置显式、生态成熟,让测试成为团队共识,而不是某个高手的独门手艺。
- C_line当前项目行覆盖率百分比。
- θ_line配置文件里写死的最低行覆盖阈值。
- 且(∧)行覆盖与分支覆盖必须同时达标,缺一不可。
- 判定输出条件成立则退出码 0、CI 放行;否则红灯拦下合并请求。
Vitest优势
又一家效率厨房开业了:食材早就在 Vite 传送带上洗净切好(依赖预打包),按下按钮立刻出菜,连隔壁灶台的热处理结果都能复用。Vitest 就是这家效率厨房。
Vitest 是 Vite 生态原生的测试框架,最直接的差异在转译层:Jest 默认还要再配一套 Babel 或 ts-jest 把 TS/JSX 转成 CommonJS,Vitest 直接复用 Vite 的解析与 esbuild 转译,几乎没有额外配置。项目已有 vite.config.ts,Vitest 自动读取,扩展字段写在 test 里。
体验最明显的是启动速度和热更新:大项目里 Vitest 因为共享 Vite 模块缓存,往往比 Jest 快一个量级。它还默认多进程并行跑测试文件,整体耗时近似可以估算:
换灶台不等于废掉菜谱。Vitest 的 API 与 Jest 高度兼容——it、expect、mock 几乎同名,把 import 从 @jest/globals 换成 vitest 基本就是迁移主成本。新项目可以直接上 Vitest,存量 Jest 项目也不必强拆,按需演进。
- N测试文件总数,也就是要出炉的菜道数。
- MVitest 启动的 worker(并行进程)数量,相当于灶台数。
- 向上取整 ⌈⌉最后一组不满一车也算一轮。
- 乘以平均耗时每个文件的平均执行时间,两者相乘得到总耗时估算。
Mock模拟
冬天没有鲜蟹,主厨用豆腐调出“赛螃蟹”的味型。这时后厨验证的是刀工与火候,而不是蟹肉供应链。前端测试里的 Mock,就是这道工序里的“豆腐蟹肉”。
前端模块总是依赖 axios、localStorage、setTimeout 这些“外界食材”。真依赖会带来网络抖动、浏览器差异和漫长的等待。Mock 用替身替换它们:vi.fn() 创建替身函数并记录每次调用,vi.mock('axios') 在模块加载层面整体替换导出,使被测模块拿到的永远是可控替身。
替身函数最有价值的能力是“被验证”:你可以断言它被调用过、被调用了 N 次、以特定参数调用过。配合 vi.useFakeTimers() 快进定时器,倒计时、轮询这类用例从等 5 秒变成毫秒级。从数据视角看,mock 函数就是一张调用记录表:
但别把所有食材都换成豆腐——Mock 只模拟外部边界,自己的业务逻辑该真刀真枪。主厨可以用豆腐模拟蟹肉,绝不会用豆腐模拟自家灶台的火候。判断标准就一句话:这层依赖是不是房子外面的墙?
- a_j第 j 次调用替身时传入的实参快照。
- r_j替身提前预设的返回值。
- 调用记录集合 C_mock框架把每一次(参数,返回)存成一份档案。
- 全称判断 ∀逐一核对每次调用是否符合预期参数。
覆盖率统计
收工前主厨会翻台账:菜谱上三百道步骤,今天实际操练了几道?没操练的步骤,明天可能就翻车。测试里的覆盖率,就是这张“实操台账”。
覆盖率指标常见有四类:行(line)、函数(function)、分支(branch)、语句(statement)。行覆盖率统计“这行代码有没有被执行过”;分支覆盖率统计 if/else 的每个真假分支是否都走过。注意:覆盖率只数“尝了几口”,不判断“尝得对不对”——高覆盖不等于高质量。
在 Jest 或 Vitest 里,一条 --coverage 就能生成报告:text 在终端输出表格,lcov 生成 HTML 页面,CI 归档后每个 PR 都能看到增删趋势。coverageThreshold 可以设置全局或按目录的阈值,不达标构建直接失败。核心指标公式:
主厨不会只看台账就开饭,但台账是不可缺的仪表盘。合理策略是:核心业务链路行覆盖 80%、关键分支全部覆盖,把火力集中在风险最高的灶台,而不是无脑追求全项目 100%。
- L_covered被插桩探针记录到已执行的行数。
- L_total可执行语句的总行数,注释和空行不算。
- 比值乘 100%把比例转换成常见的百分比读数。
- 与阈值比较核心目录低于 80% 就红灯,门禁拦住合并。
快照测试
招牌菜上线前,后厨会给它拍一张标准照贴在墙上。下次出菜再拍一张对比:一模一样就放行;有差异必须主厨签字——是改良还是翻车,人说了算。前端快照测试就是这个机制。
在组件测试里,toMatchSnapshot() 第一次运行时会把组件渲染出的 DOM 结构序列化成文本,存进 snapshots 目录;之后每次运行,框架都拿新输出与快照文件逐字符比较。相同则通过,不同则失败并打出 diff。
失败后要人工判断:如果是刻意变更(文案调整、类名重构),确认 diff 内容后按 u 更新快照;如果是意外回归,就得回头修代码。快照适合稳定的 UI 结构、配置文件、接口桩数据;不适合频繁变化的时间戳、随机数、内联样式。差异量可以形式化地表示:
所以快照是“变更提醒器”,不是“正确性保证器”。主厨靠照片发现菜变了,但决定变好变坏的永远是经验主厨本人——快照更新时的 code review,一步都不能省。
- s_i^new本次渲染序列化后的第 i 个字符片段。
- s_i^old上次保存的快照对应片段。
- 求和统计每个不相等的位置记 1,累计出差异量。
- D=0 才通过任何差异都必须触发 review 流程。
TDD开发
顶级后厨研发新菜,会先把顾客口味写成期望卡钉在墙上:“咸度三分、摆盘干净、出菜温度 60 度”,然后才开火。菜还没做出来,验收标准已经在等它了。这就是 TDD 的起点。
TDD(测试驱动开发)的循环有三拍:红灯 -> 绿灯 -> 重构。先用测试描述期望并确认它失败(红),再写最小实现让它通过(绿),最后在测试保护下重构整理内部结构,然后写下一个用例回到红灯。测试不是写码之后的附属品,而是设计工具——它逼你先想清楚输入输出与边界条件。
对前端开发,TDD 尤其适合纯函数、工具库、状态 reducer、解析器这类逻辑密集的代码;UI 视觉层则用组件测试与快照辅助。TDD 跑得勤,重构才有底气下刀,回归成本被大幅压缩。状态流转可以形式化地表示:
新手最容易犯的错,是把 TDD 理解成“先写代码、后补测试”。那就像吃完饭才写期望卡——菜已经端上桌了,晚了一步。真正的 TDD,是让测试先开口说话,代码后接话。
- 红 → 绿写一个最小的实现,让失败测试转绿。
- 绿 → 绿在测试全绿的保护下重构代码结构,行为不变。
- 绿 → 红添加新用例,期望再次落空,开启下一轮。
- 循环不变量每一步结束时测试都必须跑过——红只是过程,绿才是常态。
AI 应用场景
场景 1:组件库上线前的 CI 质量门禁
阶段:持续集成与交付
解决的问题:组件库被多个业务复用,一次回归会炸一片页面。传统方式是上线后等用户反馈,成本极高。用 Jest 给每个组件写单元测试与快照,流水线跑 npm test -- --coverage,行覆盖低于 80% 直接失败;快照 diff 必须维护人确认。就像后厨出菜前按检查清单逐项打勾。
具体案例:某中台团队的 Button 组件在 v2 改版时,快照第一时间发现 DOM 结构变化,评审确认是有意改动后才更新快照并放行,避免了一次波及十几个应用的样式回归事故。
场景 2:业务模块的 Mock 提速验证
阶段:研发效能提升
解决的问题:支付模块依赖外部 SDK 和两次轮询,本地连不上测试环境就没法验证。用 vi.fn 模拟 SDK 返回值、vi.useFakeTimers 快进定时器,几毫秒就能跑完平时要等 5 秒的用例,把“假蟹肉”用在了真正的墙外依赖上。
具体案例:开发在提交前本地跑 200 条单元测试,平均耗时 1.8 秒;提交后 CI 复跑,一次失败用例带着堆栈直接定位到轮询分支,修复只用了一分钟。
场景 3:老项目从 0 到 1 补齐测试
阶段:工程化改造
解决的问题:存量项目一行测试都没有,重构像走钢丝。先给纯函数、状态 reducer 用 TDD 补用例,再快照锁住关键页面结构,最后引入 Vitest 作为统一测试跑手,逐步提高覆盖率阈值,让重构从“赌博”变成“有网的保护”。
具体案例:某遗留管理后台经过三个迭代,测试覆盖率从 5% 提升到 72%,上线发布频次从两周一次提高到隔天一次,线上回归工单减少了六成。
Python 演示
# -*- coding: utf-8 -*-"""A tiny Jest-like test runner used in the article demo."""import timeimport traceback class Suite: def __init__(self): self.cases = [] self.results = [] def register(self, name, fn): self.cases.append((name, fn)) def run(self): start = time.time() passed = 0 for name, fn in self.cases: try: fn() self.results.append(("PASS", name, "")) passed += 1 except AssertionError as exc: self.results.append(("FAIL", name, str(exc))) except Exception: self.results.append(("ERROR", name, traceback.format_exc(limit=1))) for status, name, detail in self.results: print("%-5s %-14s %s" % (status, name, detail)) cost = time.time() - start print("passed %d/%d, cost %.3fs" % (passed, len(self.cases), cost)) def expect(actual): def to_equal(expected): assert actual == expected, "expected %r == %r" % (actual, expected) def to_be(expected): assert actual is expected, "expected %r is %r" % (actual, expected) def to_be_true(): assert actual is True, "expected True, got %r" % (actual) return {"toEqual": to_equal, "toBe": to_be, "toBeTrue": to_be_true} suite = Suite() def test_addition(): expect(1 + 1).toEqual(2) def test_boolean(): expect(2 + 2 == 4).toBeTrue() def test_concat(): expect("front" + "end").toEqual("frontend") suite.register("1 + 1 = 2", test_addition)suite.register("2 + 2 == 4", test_boolean)suite.register("string concat", test_concat) def main(): suite.run() if __name__ == "__main__": main()
class Suite:相当于 Jest 的全局容器,负责收集用例并统一执行。def register(self, name, fn):模拟test()注册用例,Jest 自动扫描 .test.js 文件后也做同样的事。except AssertionError as exc:断言抛错就是用例失败,与 Jest 捕获 expect 失败同理。def expect(actual):断言入口,返回的 dict 就是一组匹配器,类似expect(x).toEqual(y)。assert actual == expected, ...深比较失败时抛出带提示的异常,输出定位信息。suite.register("1 + 1 = 2", test_addition)注册三条用例,对应三个单元测试。if __name__ == "__main__":保证被 import 时不执行,命令行运行才触发 main()。
常见误区
误区“我把覆盖率跑到 100%,这模块应该绝对安全了吧?那以后改它就不用写测试了吧?”
正解覆盖率只统计“代码被执行过”,不统计“执行结果被验证过”。一个测试完全可以执行到某行却不做任何断言——像尝了一口土豆但没咽下去,也不知道咸淡。真正要盯的是断言质量与分支覆盖,而不是把数字刷到 100%。覆盖率是台账,不是奖状。
为何常见:覆盖率是个醒目数字,容易把“过程指标”误当“目标”,人天生喜欢能打卡的量化目标。
误区“E2E 最贴近真实用户,那我每个功能都写 E2E,单元测试是不是可以省了?”
正解E2E 要启动浏览器、等待真实接口,慢且脆。一个细小文案改动就要跑整套菜单,像每道菜都要从点单到买单完整吃一遍。金字塔结构要求:大量便宜的单元测试打底,少量昂贵的 E2E 兜底。只吃全席不尝食材,成本会拖垮迭代。
为何常见:“真实”二字很有说服力,新手容易忽略速度与稳定性成本。
误区“测试要稳定,就应该把所有依赖都 mock 掉,这样永远不会因为网络挂掉。”
正解Mock 只适合替换“外部边界”,比如网络请求、时间、浏览器 API。自己模块间的协作逻辑如果也换成替身,等于把真食材全换成豆腐,最后测的是空气。主厨模拟蟹肉,但绝不会模拟自家灶台。
为何常见:刚学会 mock 时会有“掌控感”,觉得替身越多越可控,实际是测了一个假系统。
误区“快照失败了?肯定是我改对了,直接按 u 更新就行。”
正解快照失败只说明“输出变了”,不说明“变对了”。它是一张顾客视角的标准照,主厨必须比对 diff:是标签文案主动调整,还是样式意外回归?无脑更新等于自己给自己放水,回归就会被吞掉。
为何常见:更新快照最快消除红字,省去逐行看 diff 的功夫,惰性使然。
误区“我每次都先实现完再补单测,这不也是 TDD 吗?”
正解TDD 的顺序不可逆:先写失败的测试(红),再用最小实现让它通过(绿),最后重构。补测试发生在代码定型之后,测试只是“事后拍照”,无法驱动设计。就像吃完饭才写期望卡,菜已经端上桌了。
为何常见:大家习惯从“实现”出发,觉得有测试就行,忽略了“测试先行逼迫想清楚接口”这个核心价值。
面试题
Q1. 测试金字塔中数量最多、执行最快的是哪一层?
- A.E2E测试
- B.快照测试
- C.单元测试← 正确答案
- D.集成测试
答案:C。金字塔塔底最宽,放的是最多、最快、最便宜的单元测试;越往上越贵越少。E2E 在塔尖,快照是单测的一种工具,不属于金字塔层级。
Q2. 断言两个对象内容深度相等时,应优先使用哪个匹配器?
- A.toThrow 检查抛错异常
- B.toBe 判断引用相等
- C.toMatch 匹配正则字符
- D.toEqual 深比较对象内容← 正确答案
答案:D。就像尝两盘菜,toBe 只问“是不是同一盘端过来的”,toEqual 才会逐块比对配料。题目要求内容深度相等,正确答案是 toEqual;其他选项职责不同。
Q3. Vitest 相比 Jest 最核心的优势是什么?
- A.独有更快的转译热更新
- B.自动生成测试报告文档
- C.内置更强大的断言匹配器
- D.复用Vite配置并加速转译← 正确答案
答案:D。Vitest 生于 Vite 生态,直接复用 vite.config 与 esbuild 转译,省去 Jest 那套 Babel/ts-jest 配置,启动和热更新更快。A 是速度表象,本质仍是 D 的配置复用与转译管线。
Q4. 想在测试中替换 axios 模块,应使用哪个 API?
- A.在钩子中重跑真实请求
- B.使用 it.skip 跳过用例
- C.使用 jest.mock 或 vi.mock← 正确答案
- D.使用 describe 组织分组
答案:C。jest.mock / vi.mock 在模块加载层替换导出,是实现模块级替身的标准手段。beforeEach 只能辅助初始化,skip 是跳过用例,describe 只是分组,都改变不了真实请求。
Q5. 分支覆盖率衡量的是什么?
- A.每个函数是否被调用过
- B.每行代码是否被执行过
- C.每个分支是否都被走到← 正确答案
- D.每个文件是否都被加载过
答案:C。分支覆盖率专门盯 if/else、switch 等条件结构的真假路径是否都走过,对应后厨“两种火候都实操过”。函数覆盖率、行覆盖率、文件加载是另外的指标。
Q6. 下列适合使用快照测试的场景有?
- A.组件渲染结构检查← 正确答案
- B.接口返回数据对比← 正确答案
- C.性能基准耗时测定
- D.数据库备份恢复流程
答案:AB。快照适合稳定的序列化输出,组件 DOM 结构和接口数据都能拍“标准照”比对。性能需要专门基准工具,数据库备份属于后端运维,都不在前端快照能力范围内。
Q7. TDD 循环中,红灯之后的正确动作是什么?
- A.提交代码到主干分支
- B.最小实现使测试变绿← 正确答案
- C.直接删除失败测试
- D.重构使代码更加简洁
答案:B。红灯代表新期望刚写下且失败,此时唯一目标是写最小实现让它变绿,可以“作弊式”地简单。删除测试等于放弃期望,重构要等绿灯后做,提交更不是这一步的事。
Q8. 在 CI 中让测试真正成为质量门禁的做法有?
- A.手工本地逐个点击页面
- B.使用mock替代全部依赖
- C.流水线中执行npm test← 正确答案
- D.设置覆盖率阈值拦截构建← 正确答案
答案:CD。流水线执行 npm test 让每次提交都跑用例,覆盖率阈值不达标则退出码非零、构建变红,这两者构成硬门禁。手工点击依赖人肉且不可重复,mock 全部依赖则测了假系统。
Q9. 关于覆盖率阈值,正确的理解是什么?
- A.依赖版本锁定方式
- B.测试速度最高上限
- C.代码质量最低门槛← 正确答案
- D.快照更新自动流程
答案:C。覆盖阈值是“最低标准”——低于它就拦构建,相当于后厨规定“这道菜至少实操过几遍才能上菜单”。它不锁依赖、不限速、也不管快照更新流程。
Q10. 关于 E2E 测试,正确的说法有?
- A.模拟完整用户操作流程← 正确答案
- B.运行成本通常最高← 正确答案
- C.适合替代所有单元测试
- D.无需等待后端接口就绪
答案:AB。E2E 以顾客身份走完整点单上菜流程,最真实也最贵,所以放金字塔尖。它慢且脆,不能替代大量廉价的单元测试;通常还需要稳定的测试环境与接口,不是免依赖的银弹。