资讯动态

caveman:一个极简 JavaScript 测试框架,让单元测试回归原始与快速

发布时间:2026/10/29 21:52:34 来源:尧图企业网站定制
如果你在 npm 上搜 caveman大概率会先看到一堆关于“穴居人”概念的小游戏和素材包。但我这次想聊的是另一个东西——一个只有几百行代码的 JavaScript 测试框架。它把自己的名字起作 caveman意思非常直白像原始人一样写测试。不搞 watch 模式、不做 AST 转换、不拉起 worker 线程、不预装断言库把测试文件 require 进来跑出结果完事。很多人第一次看到这个项目会觉得“这也能叫框架”但如果你正在维护一个纯函数组成的底层库受够了 Jest 每次启动都要等两三秒caveman 可能会给你一种意外的爽快感。1. 先搞清楚 caveman 到底是个什么项目1.1 名字的由来与项目定位caveman 由开发者 Brighthas 发布在 GitHub 上项目全称就是简单的一个单词。作者给这个测试框架起这个名字本身就是一种态度现代测试框架堆了太多复杂概念而“原始人”只需要一块石头和一根木棒。这个框架的核心定位就是让 JavaScript 测试尽可能接近 Node.js 的原生体验甚至可以认为它只是一个封装好的测试加载器加契约注入器。从代码功能上讲caveman 负责三件事加载你指定的测试文件或测试目录把被测对象通过 contract 方法注入到测试用例里按顺序执行每个测试项汇总输出结果它没有自己的断言库推荐直接用 Node 原生 assert 模块。它也不维护自己的 mock 体系如果要替换依赖完全靠手工注入。它甚至不提供 describe/it 这种风格化的 BDD 语法测试就是一个普通 JavaScript 数组数组里每一项描述一个测试场景。把所有这些现代化功能全剥掉之后剩下的东西才真正惊人——测试速度可以做到肉眼不可见的级别。我第一次在本地跑通 caveman 的时候第一反应是不太适应。因为过去用 Mocha、Jasmine、Jest 写测试几乎已经习惯了那些结构化的语法和丰富的配置项。但适应之后你会慢慢理解作者为什么要故意把工具做“笨”——当工具足够简单程序员才会倾向于高频使用它测试反馈才能成为开发循环的一部分。1.2 设计哲学为什么一个测试框架要“像个原始人”现代前端工具链有一个通病为了提升开发体验反而拖慢了最基本的运行速度。以 Jest 为例它要读配置、做模块转换、创建沙箱环境、管理 worker 池一套流程跑下来即使是空项目也要好几秒。当你在认真开发一个底层算法库时这几秒的启动时间会不断打断你的心流。你每次改一行代码都要等那个启动流程结束才能看到验证结果。caveman 的设计哲学和这个趋势正好相反。它假设你的代码就是纯 JavaScript不需要 Babel 转换不需要 jsdom 环境不需要 polyfill不需要代码覆盖率统计不需要测试报告上传。它只需要一个 Node.js 进程把被测模块加载进来执行你的测试函数然后打印结果。所有的功能都围绕“快速反馈”这一件事展开。作者在 README 里也强调过类似的思路如果你只是为了验证一段逻辑是否正确为什么要启动一个动辄几百 MB 的运行时和复杂的测试框架这个思路现在回头看和许多极简主义工具的主张不谋而合——不理解需求前不随便加功能。对 caveman 来说测试框架最核心的诉求只有一个用最短的时间告诉我代码对不对。这种设计哲学有一点非常值得学习它把“测试工具”和“测试体验”拆开了。很多工具把体验做成功能以为配置越多、语法越丰富、生态越完整开发者的体验就越好。但真实场景往往是刚好相反你只需要快速跑十来个用例工具却逼你等待框架本身的初始化过程。caveman 这种“原始人”思路其实是在提醒我们复杂工具带来的额外成本最终也是由使用者的注意力支付的。1.3 与主流测试框架的差异对比为了更直观地理解 caveman 的定位我把它和几个主流选项放在一起对比过。Jest 适合大型项目和前端应用Mocha 是经典的 Node 测试框架node:test 是 Node 官方近年内置的轻量测试模块。从功能层级来看caveman 明显站在“极简”这一端。对比维度JestMochanode:testcaveman启动速度慢通常数秒中等快极快断言库自带 expect需额外配 chai 等自带 assert用 Node 原生 assertmock 能力完整 mock 体系需额外配 sinon 等基础 mock不支持手动注入测试语法describe/it/beforeEachdescribe/it/beforeEachtest/before纯数组加回调配置项庞大可深度定制有配置文件极少几乎为零watch 模式有有有无覆盖率内置需额外配通过 flag 支持无依赖注入不明显靠框架 mock不明显不明显通过 contract 显式注入这张表能看出 caveman 不是要取代谁它只是把“跑测试”这件事找回了原始的用途。它适合的恰好是那些不需要复杂环境的场景——没有 DOM、没有网络请求、没有 localStorage、没有 worker就是纯函数逻辑。如果你在做 npm 库里的工具函数、在做数据解析模块、在做命令行小工具caveman 能让你体验到“改完代码立刻跑结果”的顺畅感。2. 快速上手写第一个 caveman 测试2.1 安装与目录结构设计说再多理念都不如实际跑一遍来得直接。先安装依赖npm install caveman --save-dev安装完成后不需要写配置文件。caveman 不会读 jest.config.js 或 mocha.opts它的思路是“你自己在入口文件里决定加载哪些测试文件”。所以目录结构完全由你来定下面是我常用的一个布局my-lib/ ├── lib/ │ ├── add.js │ └── multiply.js └── test/ ├── index.js ├── test-add.js └── test-multiply.js入口文件 test/index.js 负责把所有测试文件汇总并交给 caveman 执行。这比自动匹配测试文件的方式更透明因为哪些测试会被跑、以什么顺序跑扫一眼入口文件就全明白了。不需要隐藏的魔法规则。2.2 编写被测模块与测试用例先写一个最简单的被测模块比如一个加法函数// lib/add.js module.exports function add(a, b) { return a b; };再写对应的测试文件。caveman 的测试文件并不使用 describe 嵌套结构而是导出一个数组每个元素包含 desc、act、assert 三个字段// test/test-add.js var assert require(assert); module.exports [ { desc: add(1, 2) 应该等于 3, act: function(callback) { var lib require(../lib/add); callback(lib(1, 2)); }, assert: function(result, callback) { assert.equal(result, 3); callback(); } }, { desc: add(-1, 1) 应该等于 0, act: function(callback) { var lib require(../lib/add); callback(lib(-1, 1)); }, assert: function(result, callback) { assert.equal(result, 0); callback(); } } ];这里有几个重要的细节需要解释一下。act 函数里做真正的计算并调用 callback 传递结果assert 函数接收结果并做断言。caveman 要求你手动调用 callback它本身不判断你的异步操作什么时候结束。这意味着它可以处理同步逻辑也可以处理异步逻辑完全由你自己控制回调时机。如果你觉得每次都在 act 里 require 很麻烦也可以把被测模块直接传到断言里或者在入口文件中通过 contract 注入。后者是更推荐的做法我下面会讲。2.3 用 contract 做依赖注入直接 require 被测模块虽然简单但每次都要写完整的 require 路径而且不方便替换依赖。caveman 提供了 contract 方法来解决这个问题它可以在运行测试之前把被测对象挂到测试用例的 this 上。这样测试代码只关心被测对象怎么用不关心它来自哪里。入口文件这样写// test/index.js var caveman require(caveman); caveman.contract( { add: require(../lib/add), multiply: require(../lib/multiply) }, ./test );contract 的第一个参数是要注入的对象集合第二个参数是测试目录。caveman 会扫描目录下所有测试文件并把注入对象绑定在测试数组对象的 this 上。测试文件可以改成这样// test/test-add.js var assert require(assert); module.exports [ { desc: add(1, 2) 应该等于 3, act: function(callback) { callback(this.add(1, 2)); }, assert: function(result, callback) { assert.equal(result, 3); callback(); } } ];这里最直接的收益就是替换测试目标方便了。假设你有一个网络请求函数想在测试时换成本地 mock 数据只需要在 contract 里把该模块换成 mock 实现测试代码完全不用动。这种依赖注入思路非常朴素但确实够用。它甚至比某些框架的 mock 机制更简单直观因为一切都在入口文件里显式声明不走任何自动解析的魔法流程。2.4 运行测试与基本输出解读入口文件准备好之后运行命令非常简单node test/index.js你不需要调用 caveman 的 CLI因为它根本没有 CLI。运行后会看到类似下面的输出1. add(1, 2) 应该等于 3 ........................ OK 2. add(-1, 1) 应该等于 0 ........................ OK所有测试通过时每个用例会显示 OK。如果某一个测试失败caveman 会打印具体是哪一个用例失败以及断言抛出的错误信息。测试和命令行工具这种配合方式反而让我觉得回到了最纯粹的开发状态——一条 node 命令看到结果继续写代码。3. 深入核心测试模型、回调约定与速度原理3.1 三层结构契约、测试项、断言caveman 的表面用法很简单但它背后的测试模型值得深入理解。整个框架可以拆成三个层级契约层、测试项层、断言层。契约层对应 contract它定义了“你要测试什么”。这层解决的是被测对象从哪来的问题。通过把依赖注入放在入口文件里测试文件就不用关心模块路径也不再需要在每个文件顶部写一长串 require。更重要的是契约层让测试用例留出了被替换的空间。比如同一个人物模型在单元测试里可以注入内存版在集成测试里可以注入数据库版两套测试代码共用。测试项层对应的是数组里的每一个元素。每个元素是一个完整测试单元desc 描述这个测试的意图act 负责执行被测代码assert 负责校验结果。这种结构的好处是测试意图非常一目了然。不需要在 beforeEach 里搞一堆状态准备每个测试项所需的数据就在自己的 act 里准备。这对阅读测试的人来说体验很好相当于每一个数组元素就是一个小故事。断言层是最底层caveman 故意不设计自己的断言 API直接用 Node 的 assert 模块。这意味着你不需要学习一套东家的断言语法之前用 assert.equal 还是 deepStrictEqual到 caveman 里还是这么写。它也省去了开发一个断言语法的维护成本让框架保持轻量。3.2 手动调用 callback 的异步约定刚开始用 caveman 的人最容易忽略的就是那个 callback。很多代码仓库里你可以看到有人把 callback 理解成可选项觉得同步函数直接返回结果就行。但这里有个明确约定act 必须通过 callback 把结果传递出去assert 结束后必须调用 callback 告诉 caveman 这个测试完成了。我们来看一个稍复杂的例子被测函数改成异步版本// lib/readConfig.js var fs require(fs); module.exports function readConfig(path, cb) { fs.readFile(path, utf8, function(err, data) { if (err) return cb(err); cb(null, JSON.parse(data)); }); };对应的测试应该这样写// test/test-read-config.js var assert require(assert); module.exports [ { desc: readConfig 能读取配置文件, act: function(callback) { this.readConfig(./config.json, function(err, data) { if (err) return callback(err); callback(data); }); }, assert: function(result, callback) { assert.equal(result.name, caveman); callback(); } } ];注意这里的执行流程act 启动异步读取读取完成后把 data 通过 callback 交给 assert。如果读取失败直接调用 callback 并传入错误caveman 会把错误当成测试失败输出。这种回调风格虽然比较“老派”但它的优势是很像在写真实的生产代码流程测试代码与业务代码的思考方式保持一致不需要理解框架层面的时序安排。3.3 为什么它这么快零配置、零变换、零 worker现在聊聊核心问题caveman 为什么能这么快很多人可能觉得“快”只是因为测试量少但其实它的快是结构性的。Jest 启动慢是有原因的。它默认会做模块转译需要读取 Babel 配置还需要为每个测试文件创建独立的沙箱环境还要管理多个 worker 进程来并行执行测试。每一条功能背后都对应初始化成本。对大型项目来说这些成本换来的是更好的隔离性和开发体验可以接受但对一个小型库来说这些成本几乎全是浪费。caveman 则完全不同。它不读配置不改代码不起子进程。它做的事情本质上就是用 require 加载测试文件然后把测试文件里的数组循环执行一遍。所有测试运行在同一进程、同一个模块实例里不会因为沙箱切换产生额外开销。所以无论你的测试文件是 10 个还是 100 个只要你没有太多异步等待整个过程通常不会超过 0.3 秒。这个速度带来的开发体验变化很明显。我记得自己以前用 Jest 跑一个解析器模块的测试每次保存文件后都要等两秒左右才能看到结果手痒的时候会切到浏览器刷一下再回来。现在用 caveman只需要在终端里敲一下 node test/index.js几乎是瞬间看到输出。频繁改代码时这种即时反馈能明显降低切换上下文的成本。当然快不代表万能。如果你有大量测试文件或者测试之间需要严格隔离这种单进程模式也会暴露出问题。我后面会专门讲哪些场景不适合用它。4. 实战经验我踩过的坑与排查技巧4.1 依赖注入模式下 this 丢失的问题caveman 的 contract 依赖注入看着很方便但有一个细节容易踩坑当你在测试项里把 act 函数抽出来单独定义或者使用箭头函数时this 指向可能会丢失。我遇到过的一个典型场景是这样为了让代码更整洁我把多个测试项的 act 抽成了一个公共函数function runAdd(callback) { callback(this.add(1, 2)); } module.exports [ { desc: add(1, 2) 应该等于 3, act: runAdd, assert: function(result, cb) { assert.equal(result, 3); cb(); } } ];结果运行时候报错this.add 不是一个函数。原因很简单caveman 在调用这个 act 时函数内部的 this 并不保证绑定到注入对象上。普通函数声明的 this 由调用方决定一旦抽出来绑定关系就断了。解决办法有两个。一是不要在测试项里复用 act每个测试项都直接写自己的 act二是用箭头函数显式绑定外层的 this比如先把注入对象存成一个变量。我个人更推荐前者虽然会多几行重复代码但它让每个测试用例完全独立阅读起来更清晰。如果你确实需要在多个用例间共享逻辑正确的做法是在入口文件里把注入对象取出来作为参数传给封装的函数而不是依赖 this。这类共享函数适合做数据准备不适合做断言逻辑。4.2 异步回调没调用导致的悬挂测试caveman 要求你手动调用 callback这个设计虽然灵活但也有副作用如果测试代码里某个分支忘了调用 callback整个测试就会悬挂住既不能说通过也不能报失败。更麻烦的是单进程模式下这个悬挂会卡住后面所有测试项。我自己就犯过这个错误。在一个文件读取的测试里我写了类似这样的代码act: function(callback) { fs.readFile(./x.json, function(err, data) { if (err) throw err; callback(data); }); }错误处理用了 throw但 throw 在异步回调里根本不会被 caveman 捕获到异常会直接变成进程的 uncaughtException。我看到的不是一条清晰的测试失败而是整个进程崩溃退出后面的测试也没有继续执行。后来我总结出两条铁律第一异步回调里不要用 throw所有错误都通过 callback 传出去第二给每个 act 设置兜底如果调用 callback 前发生了未预期的异常也要想办法把它转换成 callback 错误。这样才能保证失败不会变成悬挂或崩溃。如果你怀疑自己的测试有悬挂问题可以临时给 caveman 的运行加一个超时机制比如用 shell 的 timeout 命令包裹执行。一旦超过预期时间自动退出就能判断出是否有人忘了调 callback。4.3 没有 watch 模式怎么实现快速重跑caveman 没有内置 watch 功能这劝退了不少人。但实际上快速重跑根本不需要 watch因为它本身启动就很快。你可以直接在终端里用上下键调出历史命令每次改完代码敲一下回车就重新跑了。对于 0.2 秒的运行时间来说watch 模式的意义本来就不大。如果你还是希望保存文件后自动跑完全可以用 Node 自带的 fs.watch 或者简单包一层 nodemon 来实现。不过我觉得这种做法有点多余caveman 的快恰恰让 watch 变成了一个锦上添花而非必需的功能。我自己的习惯是配置一个 npm script{ scripts: { test: node test/index.js } }然后在终端里用npm test跑测试。因为命令足够短所以输入成本很低就算没有 watch每改一次代码手动敲一遍也不会觉得麻烦。对我来说这个流程比开着 Jest 的 watch 等它自动发现文件变化还要顺手。4.4 memoir 只跑失败用例的方法caveman 虽然没有 watch但它提供了一个类似只回归失败用例的能力就是 memoir。这个名字很有意思相当于让框架记住上次哪些测试失败了下次运行可以只执行这些失败用例。我实际用下来memoir 适合在大批测试中定位问题。比如你负责的模块有几十个测试文件其中只有一个文件挂了你不想每次重跑都带上一堆无关用例验证速度。用 memoir 把失败的用例记下来每次只跑失败项修好后再全量跑一遍。具体用法需要看对应版本的 API 文档。核心思路是通过 memo 类把上次的失败信息保存到本地文件下次启动时从中读取并只加载对应的测试文件。为了不在这里写死某个版本的接口我建议你安装后直接查看 node_modules/caveman/README.md 里的示例。记住一点任何工具的功能以你实际安装的版本文档为准不要盲目相信网上的旧帖子。5. 选型建议什么场景用 caveman什么场景用 Jest5.1 适合的纯 JS 库、CLI 工具、解析模块我在前面反复提到 caveman 适合特定场景这里展开说清楚。第一类很合适的是纯 JavaScript 库尤其是那种不依赖 DOM、不依赖 Node 特定 IO 的算法库、工具函数库、数据解析库。比如日期格式化、字符串模板、URL 参数解析这类模块测试目标就是给定输入断言输出完全没有环境依赖。caveman 的单进程模型和回调风格在这里最合适。第二类是 CLI 工具。命令行工具的核心逻辑基本都是输入参数到输出内容的映射非常适合用契约注入来测试。先注入一个模拟的命令行参数对象再断言输出结果。CLI 工具往往强调启动速度测试工具本身就慢会显得很讽刺而 caveman 的快正好匹配这个场景的气质。第三类是数据解析与转换模块。比如把 CSV 转 JSON、把 markdown 转 HTML、把配置文件解析成对象。这些模块的特点是输入输出结构清晰没有横跨多个模块的复杂状态。写测试时只需要准备输入数据和期望输出然后逐一断言。caveman 的数组测试结构特别适合这种表格式的用例组织。5.2 不建议的前端组件、Node HTTP 服务、大型团队反过来也有一部分场景我建议不要用 caveman。前端组件测试是典型的不适合场景。组件需要渲染到 DOM 上需要模拟用户事件需要处理生命周期这些都需要专门的测试工具配合。caveman 里你要手动搭一套 DOM 环境成本远超收益。这个场景直接用 Jest 加 Testing Library 就好。Node HTTP 服务也不适合。服务端接口测试往往需要启动服务、发送请求、验证响应还会涉及数据库 mock 和鉴权逻辑。如果全压在单进程里跑环境清理和依赖隔离会很麻烦。更合适的是使用 supertest 这类工具配合 Mocha 或 Jest 的钩子函数管理生命周期。caveman 没有 beforeEach 和 afterEach意味着你要在每个测试项里自己处理启动和清理代码会变得很啰嗦。大型团队协作场景也要慎重。caveman 没有覆盖率报告、没有测试报告输出格式、没有失败结果的集中收集。在一个多人协作的仓库里缺少这些能力会让质量门禁变得很难做。团队里有人要加 mock有人要配沙箱有人要看报告最后还是老老实实回到统一框架上。caveman 更适合个人项目或者三五人小团队维护的底层库不适合需要统一工具链的组织。5.3 一个选型决策表做一个选型表可以直接保存下来参考项目特征推荐框架纯函数库、独立模块cavemanCLI 工具的核心逻辑caveman解析器、转换器、格式库cavemanReact/Vue 组件Jest Testing LibraryNode HTTP 接口服务Jest/Mocha supertest需要覆盖率门禁Jest需要浏览器环境Vitest / Jest大规模异步集成测试Jest / node:test需要 mock 网络请求Jest / sinon个人快速验证脚本caveman这个表不是绝对的但它能帮你在陷入“工具选择困难症”的时候提供一个快速起点。我自己的原则是只有当项目开始出现测试环境准备成本超过测试本身的时候才值得升级到重型框架。否则caveman 是完全够用的。结尾最后说一点个人体会。我最初接触 caveman 只是抱着猎奇的心态想看看一个把自己命名为“原始人”的测试框架能做成什么样。真正用了一段时间后反而开始反思整个前端工具链的复杂度。我们常常默认一个项目必须配上 jest.config、babel、eslint、webpack 全家桶才能算正规工程。但 caveman 这些什么都没有它依然能稳定地帮我守住回归测试这条底线。复杂工具能解决复杂问题但解决复杂问题之前它也给开发者增加了认知负担和等待时间。所以在组件库、API 服务这种不得不复杂的场景里我会继续用 Jest但在维护自己的工具函数库时我会果断选择 caveman。因为它把测试的过程拉回到最原始也最稳定的交付循环写代码跑测试看结果。那些不断叠加的抽象和配置并不总是让软件变得更好有时候它们只是在让工具链变得更大。而 caveman 这个“原始人”让我重新体会到简单带来的效率才是很多工具最初诞生的理由。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑