1. 从一句玩笑话说起:Caveman 到底是个什么项目
先交代一下背景。我之前在一个小团队里做数据工具开发,日常需求大部分是写脚本、拼接口、整理临时数据。有次项目复盘,同事吐槽测试覆盖率太低,leader 拍板说要引入一套完整的测试框架。于是我们花了两天时间调研方案:Jest、Vitest、Mocha、Playwright……最后选型选到吵架,环境搭到一半发现好多旧脚本压根跑不起来,还得先补一堆依赖。
后来我把一个最头疼的脚本翻出来,用最原始的方式把逻辑跑通、把断言写进代码里,五分钟搞定。当时我在 PR 描述里写了句:“this is caveman style testing”,意思是不用任何框架,纯靠原生断言和手动触发函数来验证逻辑。
没想到这句调侃成了项目代号,后来干脆就叫它 “caveman”。现在回想起来,这个所谓的“项目”其实从头到尾都没有一个正经的仓库、没有版本号、没有 CI,它就是一套写代码的思路和几个高度可复用的测试手法。但它帮我解决了一个非常现实的问题:在不想引入重型测试基建的前提下,如何保证脚本级别的代码质量。
这篇文章我想认真聊聊 caveman 到底是什么、为什么这种“原始人式”的测试思路在特定场景下比大而全的测试框架更好用、以及我自己从里面沉淀下来的一套实操方法。不管你是独立开发者、运维工程师,还是在小团队里做内部工具的人,只要手上有“不需要长期维护但必须逻辑正确”的脚本,这篇文章大概率能帮到你。
2. 内容整体设计与思路拆解
2.1 Caveman 的核心本质:回归测试的原点
在展开具体代码之前,先把概念说透。Caveman 不是一个开源库,也不是某种 CLI 工具,它本质上是一种“测试哲学”的实践集合:把所有外部依赖降到最低、用语言自带的断言机制做校验、测试代码与被测代码混排在同一个文件里、跑测试的方式就是直接执行脚本本身。
听起来很“原始”,但你仔细想一下,这不就是测试最本来的样子吗?我在写这段文字前认真回顾了一下:
- 单元测试要解决的核心问题,是“在输入确定的情况下,输出是否符合预期”。
- 测试框架存在的意义,在于帮你批量收集用例、生成报告、统计覆盖率、接入 CI。
- 但对于一次性或低频率运行的脚本,批量收集用例和 CI 根本用不上。
所以 caveman 的思路是直接砍掉中间层。我不需要npm install任何东西,不需要describe、it这些语法糖,不需要 mock 工具。我用 JavaScript 自带的console.assert、Node 的assert模块,或者 Python 里的裸assert语句,把对每个函数的验证逻辑直接放进代码里。脚本运行完,所有断言通过,控制台干干净净;一旦断言失败,进程会报错退出,我用报错信息反推逻辑问题。
打个比方,别人开跑车上赛道测圈速,装备齐全、数据丰富;我骑共享单车也能完成同样的通勤需求,虽然拿不出漂亮的数据报表,但我知道自己到了目的地。对于某些路程,共享单车就是最优解。
2.2 为什么会有“用不上框架”的场景
我总结了一下,caveman 这种方法能真正发挥价值,一般逃不开以下三个前提条件:
低频但逻辑复杂。比如每月跑一次的数据对账脚本,或者只在季度末生成一次的报表工具。你说它重要不?重要。出错后果严重不?严重。但它有这个频率,值得为它搭一套完整测试框架吗?不值得。我见过太多团队为这种低频脚本强行上框架,结果下个季度再打开仓库,依赖装不上、接口变了、测试先挂一片。
一次性数据处理。比如做历史数据迁移,或者批量清洗一批脏数据。这类代码通常只运行一次,本身就没有持续维护的需求。如果用 TDD 的思路去开发,反而会拖慢进度。但完全不测又不行——万一迁移完才发现某些字段算错了,后悔都来不及。
原型验证阶段。新想法刚起步,技术方案还没定,代码天天大改。这时候测试框架的目标本身就是移动的靶子,写一堆固定用例纯属浪费。caveman 的轻量方式能让你在验证完思路之后,毫无负担地把验证代码一并删掉,不留技术债。
这三个场景有一个共性:代码的生命周期短或者运行频率低,但单次运行的可靠性要求很高。在这种矛盾下,重型测试框架的投入产出比极低,caveman 反而是最匹配的答案。
2.3 方案选型的边界:什么时候别用 caveman
话要说全面。caveman 不是银弹,我在实际项目里也栽过跟头。
有一次我负责一个订单状态流转模块,涉及多步异步回调、状态机切换、数据库读写。我当时图省事,也用了 caveman 的思路,把断言直接写在业务代码里。结果随着业务不断叠加,函数越来越长,断言散落在各个角落,排查问题的成本反而比之前更高了。后来我痛定思痛,把所有断言集中抽到独立的测试文件,引入了一个轻量测试框架,世界才重新清静。
所以我的判断标准很明确:如果这段代码会被多人长期维护,或者业务逻辑会持续迭代,那就老老实实上正规测试框架。caveman 只适合“写给自己用”或“短平快交付”的代码。这个边界一定要在心里画清楚,否则就是从一个极端走到另一个极端。
3. 核心细节解析与实操要点
3.1 断言工具的选型:原生能力远比你想的强
要用好 caveman,第一步是掌握语言本身自带的断言能力。以 JavaScript 为例,Node 环境至少有三个层次的校验手段。
console.assert是最基础的。它接受一个布尔表达式,为false时输出错误信息。但它有个巨大的坑:默认情况下断言失败不会抛异常,不会中断执行。看下面这个例子:
console.assert(1 + 1 === 3, "计算结果错误!"); console.log("这行代码依然会执行");输出结果是:
Assertion failed: 计算结果错误! 这行代码依然会执行这在测试场景下非常危险,因为你可能拿到一堆断言失败的信息,但程序“正常运行”到结束了,你会误以为所有逻辑都对。所以我用console.assert时一定会配合环境变量来开启严格模式:
if (process.env.STRICT_ASSERT) { console.assert = (condition, message) => { if (!condition) { throw new Error(message); } }; }把这一行放在脚本顶部,测试时用STRICT_ASSERT=1 node script.js运行,断言失败会立刻中断,定位速度飞快。
Node 的 assert 模块才是真正的正统方案。它不需要安装任何第三方包,原生自带,失败时直接抛AssertionError:
const assert = require("assert"); function add(a, b) { return a + b; } // 用例 1:常规加法 assert.strictEqual(add(1, 2), 3, "普通加法结果不符"); // 用例 2:负数加法 assert.strictEqual(add(-1, -2), -3, "负数加法结果不符"); // 用例 3:浮点数加法(注意精度问题) assert.ok(Math.abs(add(0.1, 0.2) - 0.3) < 1e-10, "浮点数加法精度不符");strictEqual是严格相等(===),deepStrictEqual可以用来深比较对象和数组。我强烈建议统一用assert模块,而不是console.assert,因为它的失败行为更可信、错误信息更规范。
Python 环境就更简单了,直接用裸assert:
def add(a, b): return a + b assert add(1, 2) == 3, "普通加法结果不符" assert add(-1, -2) == -3, "负数加法结果不符" assert abs(add(0.1, 0.2) - 0.3) < 1e-10, "浮点数加法精度不符"在 shell 脚本里,则可以用set -e配合显式的字符串比较和退出码。
3.2 测试代码与被测代码的组织方式
Caveman 场景下,测试代码放哪里是有讲究的。我见过有人单独建一个 test 目录,文件名加.test.js后缀,然后还需要配package.json的 scripts。这真的是多此一举。低频脚本最重要的是“打开就能跑”,任何额外步骤都会让你在下一次使用它时产生犹豫,然后不自觉地去复制粘贴一个旧版本来改,反而破坏了验证逻辑。
我的默认做法是:把测试代码直接放在业务函数的下一行,用注释块圈定范围。
/** * 将 CSV 行转换为对象数组 */ function csvToObjects(csvText) { const lines = csvText.trim().split("\n"); const headers = lines[0].split(",").map((h) => h.trim()); return lines.slice(1).map((line) => { const values = line.split(",").map((v) => v.trim()); const record = {}; headers.forEach((header, index) => { record[header] = values[index]; }); return record; }); } // ===== Caveman 验证 ===== const assert = require("assert"); const sampleCsv = "name,age,city\nAlice,30,Beijing\nBob,25,Shanghai"; const parsed = csvToObjects(sampleCsv); assert.strictEqual(parsed.length, 2, "应解析出 2 条记录"); assert.strictEqual(parsed[0].name, "Alice", "第一条记录 name 字段不符"); assert.strictEqual(parsed[0].city, "Beijing", "第一条记录 city 字段不符"); assert.strictEqual(parsed[1].age, "25", "第二条记录 age 字段不符"); // 边界用例:空行处理 const emptyParsed = csvToObjects("\n\n"); assert.strictEqual(emptyParsed.length, 0, "空输入应返回 0 条记录"); console.log("✅ 所有 csvToObjects 验证通过"); // ===== 验证结束 =====运行方式只有一个:node script.js。所有断言通过,最后一行就是你自定义的“通过”提示;断言失败,进程在对应位置中断,错误信息直接指向问题行。
这里有个小细节值得注意:断言块上面我用了一个醒目的注释分隔。因为在业务代码里混排测试代码,如果不做明显标记,后期维护的人会分不清哪些是业务逻辑、哪些是验证逻辑,甚至把断言当成业务代码误删。养成用注释块圈定范围的习惯,能省很多事。
3.3 副作用的控制:如何让验证可重复运行
写 caveman 测试时最大的拦路虎,是业务函数往往带有副作用——写文件、发网络请求、改数据库。如果不加控制,脚本每跑一次就会污染一次环境,验证结果自然不稳定。
我踩过的坑是:有次写一个批量重命名文件的脚本,为了验证重命名逻辑是否正确,脚本里直接调用真实的重命名函数。第一次运行正常,第二次运行直接报“文件不存在”,因为文件已经被改过一次名字了。
后来我总结出一套副作用隔离方法,核心思路就是把纯逻辑和副作用拆开。举个例子:
// 目录名清理逻辑:纯函数,可反复测试 function cleanFileName(name) { return name .replace(/[\\/:*?"<>|]/g, "-") .replace(/\s+/g, "_") .slice(0, 100); } // 业务入口:副作用所在 function renameFilesInDirectory(dirPath) { const fs = require("fs"); const files = fs.readdirSync(dirPath); files.forEach((file) => { const newName = cleanFileName(file); if (newName !== file) { fs.renameSync(`${dirPath}/${file}`, `${dirPath}/${newName}`); } }); } // ===== Caveman 验证(只测纯逻辑) ===== const assert = require("assert"); assert.strictEqual(cleanFileName("report 2024/05.txt"), "report_2024-05.txt", "路径分隔符应替换"); assert.strictEqual(cleanFileName("a*b?c"), "a-b-c.txt".replace(/\.[^.]*$/, "") + ".txt", "非法字符应替换"); assert.strictEqual(cleanFileName("x".repeat(200)).length, 100 + 4, "超长文件名应截断并保留扩展名"); console.log("✅ cleanFileName 验证通过"); // ===== 验证结束 =====第二个断言我故意写了个“会失败的预期”?不对,应该改成更清晰的预期:
assert.strictEqual(cleanFileName("a*b?c"), "a-b-c", "非法字符应替换");总的原则是:要验证的应该是“逻辑”而不是“行为”。把逻辑抽成纯函数,副作用只留在入口处。这样不仅测试方便,代码本身的健壮性也会上一个台阶。
如果确实需要验证带有副作用的完整流程,我的方案是引入一个“测试模式”环境变量,让函数在测试模式下输出到临时目录而不是真实路径。这个做法说起来有点套路化,但在脚本工具里出奇地好用。
4. 实操过程与核心环节实现
4.1 一个完整案例:CSV 数据合并工具
为了把上面这些经验串起来,我完整复刻一个最近实际做过的 caveman 式脚本开发过程。场景是这样的:运营部门给我两个 CSV 文件,一个存用户基础信息,一个存用户订单汇总,我需要按用户 ID 合并成一个总表输出。
第一步,落地基础的业务逻辑,不考虑任何测试的问题:
const fs = require("fs"); function readCsv(filePath) { const text = fs.readFileSync(filePath, "utf8"); const lines = text.trim().split("\n"); const headers = lines[0].split(",").map((h) => h.trim()); return lines.slice(1).map((line) => { const values = line.split(",").map((v) => v.trim()); const record = {}; headers.forEach((header, index) => { record[header] = values[index]; }); return record; }); } function mergeUsers(userInfo, userOrders) { const orderMap = new Map(); userOrders.forEach((order) => { orderMap.set(order.userId, order); }); return userInfo.map((user) => { const order = orderMap.get(user.userId) || {}; return { ...user, ...order }; }); } function toCsv(records) { const headers = Object.keys(records[0] || {}); const lines = [headers.join(",")]; records.forEach((record) => { lines.push(headers.map((h) => record[h] ?? "").join(",")); }); return lines.join("\n"); }第二步,写验证用例。我构造了两个极小型但能覆盖边界情况的 CSV 数据,直接在代码文件底部加验证块:
// ===== Caveman 验证 ===== const assert = require("assert"); // 构造样例数据 const userInfo = [ { userId: "1", name: "Alice" }, { userId: "2", name: "Bob" }, { userId: "3", name: "Charlie" }, ]; const userOrders = [ { userId: "1", orderCount: 3, totalAmount: 299.5 }, { userId: "3", orderCount: 1, totalAmount: 19.9 }, ]; const merged = mergeUsers(userInfo, userOrders); // 用例 1:合并条数与用户数一致 assert.strictEqual(merged.length, 3, "合并后条数为 3"); // 用例 2:有订单的用户字段正确合并 assert.strictEqual(merged[0].orderCount, 3, "Alice 的订单数应为 3"); assert.strictEqual(merged[0].totalAmount, 299.5, "Alice 的总额应为 299.5"); // 用例 3:无订单的用户应保留默认空值 assert.strictEqual(merged[1].orderCount, undefined, "Bob 无订单,orderCount 应为 undefined"); assert.ok(!("totalAmount" in merged[1]), "Bob 不应有 totalAmount 字段"); // 用例 4:toCsv 反向转换 const csvOutput = toCsv(merged); assert.ok(csvOutput.includes("userId,name,orderCount,totalAmount"), "CSV 头部应包含合并字段"); assert.ok(csvOutput.includes("2,Bob"), "Bob 的行应保留"); // 边界用例:空数据合并 const emptyMerged = mergeUsers([], userOrders); assert.strictEqual(emptyMerged.length, 0, "空用户列表合并结果应为空"); console.log("✅ csv 合并工具验证通过"); // ===== 验证结束 =====第三步,直接执行node mergeCsv.js,看到验证通过提示。整个过程中我没有安装任何测试相关依赖,没有创建__tests__目录,“跑测试”的成本就是执行一次脚本。
有朋友可能会问:验证代码这样混在业务文件里,交付给运营的时候怎么办?我的做法是:交付前把验证块整体删掉,代码整洁再给出去。反正验证代码是一次性的,留在交付物里只会增加阅读负担。如果想保留验证能力,就另外用 git tag 记一下版本。
4.2 异步场景怎么验证:三种收敛方式
脚本开发里躲不开异步逻辑,caveman 验证异步函数要比同步麻烦不少,核心问题是测试进程可能在异步操作完成前就退出了。
我在实际开发中一共总结出三种收敛方式,从简单到复杂排列:
方式一:顶层 await(现代 Node 环境)
如果你的 Node 版本支持 ES modules("type": "module"或.mjs后缀),直接用顶层 await:
import assert from "assert"; async function fetchUserData(apiClient, userId) { const response = await apiClient.get(`/users/${userId}`); return response.data; } // 模拟一个返回固定数据的 apiClient const mockApiClient = { get: async (url) => ({ data: { id: url.split("/").pop(), name: "Mock User" }, }), }; // Caveman 验证 const user = await fetchUserData(mockApiClient, "42"); assert.strictEqual(user.name, "Mock User", "mock 数据应正确解析"); assert.strictEqual(user.id, "42", "userId 应正确传递"); console.log("✅ 异步函数验证通过");方式二:Promise 链 + .then
在 CommonJS 环境下,用.then来串起来:
const assert = require("assert"); async function fetchUserData(apiClient, userId) { const response = await apiClient.get(`/users/${userId}`); return response.data; } const mockApiClient = { get: async (url) => ({ data: { id: url.split("/").pop(), name: "Mock User" }, }), }; fetchUserData(mockApiClient, "42") .then((user) => { assert.strictEqual(user.name, "Mock User"); assert.strictEqual(user.id, "42"); console.log("✅ 异步函数验证通过"); }) .catch((err) => { console.error("❌ 验证失败:", err.message); process.exit(1); });方式三:独立验证入口 + 手动调用
遇到逻辑特别复杂的异步流程,我会在其他地方写一个selfTest()函数,业务代码明确判断“当前是否为自测试模式”:
const assert = require("assert"); async function selfTest() { const result = await runFullPipeline("fixtures/input.csv", "fixtures/output.csv"); assert.strictEqual(result.processed, 10, "应处理 10 条数据"); assert.strictEqual(result.failed, 0, "不应有失败记录"); console.log("✅ 完整流水线验证通过"); } // 支持 --selftest 参数触发 if (process.argv.includes("--selftest")) { selfTest().catch((err) => { console.error(err.message); process.exit(1); }); }这样既不影响正常功能,也保留了随时验证的能力。第三种方式是我现在最推荐的,因为--selftest参数给脚本增加了一个稳定的“内置体检”接口,不管谁拿到脚本,第一件事就是跑一下node tool.js --selftest,确认环境没问题再用。
4.3 批量验证用例的组织技巧
随着验证点变多,散落的assert语句会变得难以阅读和维护。我的解法很简单:把验证点装入一个数组,循环执行。
const testCases = [ { name: "正常合并", fn: () => mergeUsers( [{ userId: "1", name: "Alice" }], [{ userId: "1", orderCount: 2 }] ), expect: (result) => result[0].orderCount === 2, }, { name: "空用户列表", fn: () => mergeUsers([], [{ userId: "1" }]), expect: (result) => result.length === 0, }, ]; let passed = 0; testCases.forEach((tc) => { try { const result = tc.fn(); if (!tc.expect(result)) { throw new Error("验证结果不符合预期"); } passed++; console.log(` ✓ ${tc.name}`); } catch (err) { console.error(` ✗ ${tc.name}: ${err.message}`); } }); if (passed !== testCases.length) { process.exit(1); } console.log(`✅ 全部 ${passed} 个用例通过`);这种组织方式有几个好处:失败时能精确知道是哪个用例挂了;用例列表一眼就能看出覆盖了哪些场景;往列表里加新用例几乎零成本。唯一需要注意的是,fn函数里千万不能有副作用执行两次会出问题的操作。如果确实需要副作用场景,就单独拆出来执行。
5. 常见问题与排查技巧实录
长时间实践 caveman 风格,我攒了不少问题和对应的排查手段,整理成表格给各位参考。
| 症状 | 可能原因 | 排查方案 |
|---|---|---|
| 脚本“正常执行”但没有输出验证通过提示 | console.assert失败不抛异常,程序继续跑 | 统一改用assert模块,或开启严格模式 |
| 断言报错但错误信息定位不准 | 断言消息写得不够具体 | 给每个断言写包含上下文的消息,如“用户 42 的订单数应为 3,实际为 2” |
| 第二次运行脚本结果与第一次不同 | 业务函数有副作用,污染了运行环境 | 抽取纯逻辑函数,副作用入口手动隔离 |
| 异步验证总是不稳定,偶尔报错 | 异步操作未完成前进程已退出 | 改用--selftest入口 + async 函数 + 顶层 await |
| 浮点数比较时断言失败 | 精度问题,比如0.1 + 0.2 !== 0.3 | 用Math.abs(a - b) < 1e-10做近似比较 |
| 验证代码混在业务代码中太乱 | 坚持把验证块放在文件底部,并用醒目标记 | 交付时直接删掉验证块,或抽成selfTest() |
| 测试用例太多,根本跑不完 | 你已经在做框架级的事 | 回到开头:这类代码别用 caveman,直接引入正式测试框架 |
下面挑两个问得最多的问题展开讲讲。
浮点数断言永远踩坑。我早期写过一段月度报表脚本,里面有个计算百分比保留两位小数的函数,跑测试时偶尔会报错。排查了很久才发现是0.1 + 0.2这种典型的浮点数精度问题。从那以后,凡是涉及浮点数比较,我无脑用近似比较法:
function nearlyEqual(a, b, epsilon = 1e-10) { return Math.abs(a - b) < epsilon; } assert.ok(nearlyEqual(0.1 + 0.2, 0.3), "浮点数精度比较");这是最稳妥的写法,别去背什么单精度双精度的细节,直接用。
自测代码干扰了业务输出。有段时间我给一个导出工具加了好几个console.log输出验证过程,结果交付给同事后,他以为这些是业务日志,写了个解析脚本去抓这些输出。我赶紧把验证提示统一改成特定前缀,比如[SELFTEST],并且默认情况下只能通过--verbose参数打印出来。这个教训让我学会了:验证代码默认要沉默,只有验证失败才说话,否则它就是噪音。
6. 从 Caveman 到现代测试的自然演进
写到这里,肯定有朋友会有疑惑:难道我们以后所有代码都用这种方式?当然不是。每个方法都有它的适用半径,找到半径边界本身是种能力。
我的经验是,当项目中出现以下信号时,就该考虑从 caveman 往正式测试框架迁移了:
- 代码开始被多人修改,而你无法预知改动会不会破坏核心逻辑。
- 同一份代码的调用方开始增多,比如从“我给运营写的工具”变成“三个部门都在用的平台能力”。
- 你发现自己开始写“测试的测试”——手动检查验证代码本身有没有写错。
- 需要覆盖率指标来量化风险,或者需要接入 CI 门禁。
具体怎么平滑迁移?我不会建议你一夜之间重写所有用例。最低成本的迁移路径是:
- 把
assert裸断言原样包装成一个直观的用例函数。 - 在项目中引入
node:test或vitest,它不需要任何配置就能运行。 - 把聚合在 python 脚本或 node 脚本底部的自测块摘出来,原样搬运到测试目录的
.test.js或.test.py文件中。 - 代码逻辑一个字符都不用改,因为 caveman 的断言本身就是合法的测试断言。
我做过一次这么迁移的项目,从零测试框架到跑通npx vitest run,只花了不到半天。因为原本的验证用例已经覆盖了大半核心路径,搬过去就是顺手的事。Caveman 不是一条只能走到黑的死胡同,它恰好是通向正规化测试的一条忠实起跑线——你写下的每个断言,将来都可以无缝变成单元测试的第一批用例。
7. 写在后面的一点个人体会
回到 Caveman 这个项目本身,我觉得它最重要的产出其实不是任何一行代码,而是一种对“工具和场景匹配”的判断力。
我在小团队里待久了,见过太多为了“显得专业”而引入重型工具、最后反被工具拖累的例子。测试框架本身没有错,错的是在一个只需要assert的地方上了全套流水线。反过来,如果你在做大型系统还坚持用 caveman,那也一样是灾难。
大家以后做脚本或者内部工具时,不妨先停下来问自己三个问题:这段代码会运行多少次?改动频率高不高?使用者是不是只有我一个人?如果答案都是“少、低、是”,那就不妨试试 caveman 风格。写完脚本之后,顺手加一层验证,确保它交付出去的那一刻是带着全部正确性出去的。很多时候,保证正确,比展示正确,更帅气。