全盘实测:13 个项目、36 个 Express 包、27 张表,测试文件几乎为 0。唯一的一份在 packages/engine/test/run.ts,用 node:assert 手写了 6 个断言。所以这一篇不讲「怎么配一套测试框架」,讲「一个没有测试基础设施的人,怎么在 0 基础上拿到最低限度的可信度」。
结论基于对本地全部代码库的实测盘点,不美化:
依赖盘点实测(package.json 命中次数):express 85、react 85、vue 74、ffmpeg 67、better-sqlite3 26、sharp 24、zod 9、drizzle-orm 7。这里面没有任何一个测试框架——没有 vitest、没有 jest、没有 mocha、没有 playwright 用例。
真实项目场景:delimao-system(npm workspaces monorepo,4 个包:app 层 + packages/engine + packages/db + packages/shared,27 张表,tRPC + zod + drizzle)、shower-order-p3(tRPC 单项目)。
已确认的技术债:collections 表用单个 JSON blob 承载表单行,写坏一次整条读不出来。
诚实是这套体系的起点。不装、不绕、不拿「AI 说它测过了」当证据。下面是实测数字。
| 扫的是什么 | 实测结果 | 说明 |
|---|---|---|
*.test.* / *.spec.* 全盘命中 | 0 | 除了下面那一条 |
packages/engine/test/run.ts | 唯一的测试文件,6 个断言 | 手写 node:assert,npm run engine:test 一条命令跑完,不起库、不起服务 |
vitest / jest / mocha / @playwright/test | 0 个 | playwright 只作为 1.63 的传递依赖出现过,没有写任何用例 |
CI 配置(.github/workflows 等) | 0 个 | 没有自动化跑测试的地方 |
test/ 目录;② 你知道测试要跑纯函数、不起服务;③ 你愿意用 Node 自带的东西,而不是为了测试先装 30 个包。讲测试为什么重要,最好的教材不是「覆盖率」,是你自己线上出过的那一次。delimao-system 的 collections 表就是活教材。
| 环节 | 发生了什么 | 为什么没人拦住 |
|---|---|---|
| 表单行落库 | collections 用一个 JSON blob 字符串存整组表单行 | AI 为了「少建几张表」把结构化数据塞进一个字段——它没有问你这些东西将来要不要单独查 |
| 一次写入 | 某次写入截断或格式异常,blob 变成 {"items":[{...}, | blob 没有约束:没有 JSON schema,没有长度校验,没有部分更新的事务保护 |
| 读取 | 整行 JSON.parse 直接抛异常,这一行读不出来 | 读取路径没有 try/catch,也没有「坏行跳过、坏行计数」 |
| 后果放大 | 因为它是行级关联数据,读这行的功能全挂;页面列表里整页白屏 | 一行的坏数据把一屏的数据全毁了——这就是 JSON blob 相对拆表的根本差别 |
16-database-deep.html 里已经讲过为什么该拆表。)| 零测试 | 有测试 | |
|---|---|---|
| 发现时机 | 用户报障(可能是上线三个月后) | 改完代码的 3 秒内 |
| 定位成本 | 翻日志、复现、猜是哪行写坏的 | 测试直接告诉你「这条 POST 写入后读回来是坏的」 |
| 修复范围 | 「先修这行,过两天又坏了」 | 修写入 + 修读取,同一个用例再跑一遍证明闭环 |
| 下次同类 | 换个字段再犯一次 | 回归测试留下来,同类再犯会被挡住 |
一个人做项目,没有同事帮你 review、没有 QA,所以「哪些必须测」这个问题比「怎么测」重要得多。测错地方,测试会变成负担,然后你就停掉了。
| 层 | 测不测 | 理由(一句话) | 对应你项目里的真身 |
|---|---|---|---|
| 价格 / 算价计算 | 必须测 ← 第一优先 | 算错了是直接赔钱,而且是客户当场就能发现的错 | packages/engine 的报价、BOM、型号解析——纯 TS、零依赖,最容易测 |
| 状态机 / 单据流转 | 必须测 | 状态跳错(已取消还能发货)不报错但业务全乱 | 订单/工单的状态流转、shower-order-p3 的单据状态 |
| 字段校验 / 边界 | 必须测 | 正是「算不了但看起来合法」的重灾区(无尺寸、负宽度) | zod 9 处 schema、engine 的零件尺寸校验 |
| 数据库 schema 与读写 | 必须测 | 崩过库——这条是拿真事故换来的 | packages/db、27 张表的 schema 与迁移 |
| 迁移脚本(migration) | 必须测 | 迁移只跑一次,跑错就是不可逆的;必须在临时库上完整跑一遍 | db:migrate(packages/db 的迁移) |
| 接口层(tRPC / Express router) | 只测关键的那几个 | 要起 HTTP,成本高一档;挑「算钱、写库」这两个动作测 | engine.quote、下单写入 |
| 样式 / 布局 / 渲染 | 不测 | 改一版 CSS 就红一片,维护成本 > 收益,而且视觉本来就要人看 | tailwind 3/4 的样式、React/Vue 组件 |
| 纯展示组件 / 常量表 | 不测 | 为了覆盖率去测 getter 是自欺欺人 | 枚举字典、展示型组件 |
collections 之外的一切补测试。覆盖率是给团队用的指标,一个人用它是自虐。你的目标不是数字,是「这四条链路我改坏了会被拦下来」。不需要装任何东西。Node 18+ 自带 node:test 和 node:assert,TypeScript 用 tsx 跑就行(你项目里本来就在用 tsx watch)。
// packages/engine/test/price.test.ts
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { parseModelNo, calcPrice } from '../src/price.js'
// 用例 1:真实型号能解析出关键字段(正向)
test('DL-2409-1 能解析出系列和宽度', () => {
const r = parseModelNo('DL-2409-1')
assert.equal(r.series, 'DL')
assert.equal(r.width, 2409)
})
// 用例 2:非法型号必须抛错,不能返回垃圾(负向 —— 见第 07 节)
test('乱写的型号必须抛错', () => {
assert.throws(() => parseModelNo('乱写的型号'))
})
// 用例 3:无尺寸必须「算不了」,不能返回 0 元(这是钱的问题)
test('缺尺寸的订单必须报算不了,不能返回 0 元', () => {
const r = calcPrice({ modelNo: 'DL-2409-1', width: null, height: null })
assert.equal(r.ok, false)
assert.equal(r.amount, null) // ← 注意是 null,不是 0
assert.match(r.reason, /尺寸/) // ← 要说清楚缺的是哪一个
})
// 用例 4:正向算价(一个手工算过的值,必须写死在用例里)
test('DL-2409-1 + 1200×800 报价等于预期值', () => {
const r = calcPrice({ modelNo: 'DL-2409-1', width: 1200, height: 800 })
assert.equal(r.ok, true)
assert.equal(r.amount, 3186.40) // ← 这个值必须是手工算过的,不能是跑出来的
})
assert.equal(r.amount, 3186.40) 里的 3186.40 必须是你自己拿计算器按过一遍的数。如果它只是「上次跑出来长这样」,那你这个测试等于把 bug 固化成了规格。package.json 的 scripts)// packages/engine/package.json
{
"scripts": {
"test": "tsx --test test/*.test.ts",
"engine:test": "tsx --test test/*.test.ts"
}
}
// delimao-system 根 package.json(workspaces)
{
"scripts": {
"test": "npm run test -w @delimao/engine"
}
}
node:assert 手写、一条 npm run engine:test、几秒跑完、不起库不起服务。test/、跑法是一条 script。从 run.ts 到 price.test.ts,变的只是把 6 个断言拆成 4 个 test() 并加上 node:test 的用例名——不到 10 分钟。npm run engine:test。绿的才继续,红的说明断言写错了。
把 calcPrice 里的 * 1.15 改成 * 1.5,或者把 throw 改成 return 0。
看到红了才算这个测试有效。没红 = 这个测试是假的(断言写空了、断言的值和代码一样错)。
这一步 15 秒。它是「我信得过自己的测试」的唯一依据。
你的库是 SQLite + better-sqlite3(26 处)+ WAL。在这种环境里造测试数据最快的方式是直接建一个临时 db 文件——不要为了测试起 Docker、不要起内存版、更不要 mock 掉整个 db 层。
// tests/helpers/makeDb.ts
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import Database from 'better-sqlite3'
/** 建一个一次性测试库:用完即删,不碰任何真实数据 */
export function makeTestDb() {
const dir = mkdtempSync(join(tmpdir(), 'delimao-test-'))
const db = new Database(join(dir, 'test.db'))
db.pragma('journal_mode = WAL') // 和线上一致,才能测出 WAL 相关行为
db.exec(`
CREATE TABLE models (no TEXT PRIMARY KEY, list_price REAL NOT NULL);
CREATE TABLE orders (id INTEGER PRIMARY KEY, no TEXT, payload TEXT NOT NULL);
CREATE TABLE order_items (id INTEGER PRIMARY KEY, order_id INTEGER, qty INTEGER);
`)
return {
db,
close() { db.close(); rmSync(dir, { recursive: true, force: true }) }
}
}
// packages/engine/test/collections.test.ts
import { test, beforeEach, afterEach } from 'node:test'
import assert from 'node:assert/strict'
import { makeTestDb } from '../../../tests/helpers/makeDb.js'
let h: ReturnType<typeof makeTestDb>
beforeEach(() => { h = makeTestDb() }) // 每个用例都拿一个全新空库
afterEach(() => h.close()) // 用完删掉
test('表单行写入后能原样读回(JSON blob 回归)', () => {
h.db.prepare('INSERT INTO orders (no, payload) VALUES (?, ?)')
.run('A001', JSON.stringify({ glass: '8mm', w: 1200, qty: 2 }))
const row = h.db.prepare('SELECT payload FROM orders WHERE no = ?').get('A001') as any
assert.deepEqual(JSON.parse(row.payload), { glass: '8mm', w: 1200, qty: 2 })
})
test('写坏的 payload 必须被挡住,不能静默写进库', () => {
// 这条用例是第 02 节那次崩库的直接回归
assert.throws(() => {
h.db.prepare('INSERT INTO orders (no, payload) VALUES (?, ?)').run('A002', '{"items":[')
})
const n = h.db.prepare('SELECT COUNT(*) AS c FROM orders').get() as any
assert.equal(n.c, 0) // ← 关键断言:坏数据根本没进去
})
// tests/migrate.test.ts —— 迁移只跑一次,跑错不可逆,所以必须测
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { makeTestDb } from './helpers/makeDb.js'
test('migrate:baseline 能在空库上从零建齐全部表', () => {
const { db, close } = makeTestDb()
try {
runAllMigrations(db) // 把 packages/db 的迁移按顺序喂进去
const tables = db.prepare(
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"
).all() as any[]
assert.equal(tables.length, 27) // ← 27 张表,一个都不能少
assert.ok(tables.some(t => t.name === 'collections'))
} finally {
close()
}
})
test('migrate:add_column 在已有数据上升级不丢行', () => {
const { db, close } = makeTestDb()
try {
db.exec('CREATE TABLE orders (id INTEGER PRIMARY KEY, no TEXT)')
db.prepare('INSERT INTO orders (no) VALUES (?)').run('OLD01')
runAllMigrations(db)
const n = db.prepare('SELECT COUNT(*) AS c FROM orders').get() as any
assert.equal(n.c, 1) // ← 加字段不能把老数据清掉
} finally {
close()
}
})
| 做法 | 评价 |
|---|---|
临时 db 文件 + beforeEach 建 schema | 推荐。几毫秒建完,和线上完全一致(同样的 WAL、同样的约束、同样的 SQL 方言),能真的测出 SQLITE_BUSY 之类的问题 |
| memory 版驱动/内存库 | 快,但方言可能和你线上不一样,会漏掉约束类 bug |
| 起 Docker 容器 | 对一个人是过度工程。你全盘 0 个 Dockerfile,为测试开一个只会拖慢反馈 |
mock 掉 better-sqlite3 | 最差。你测的是 mock 而不是 SQL,崩库那类事故 100% 测不出来 |
mkdtempSync),所以它能测的东西特别多——约束、外键、迁移、WAL 并发、blob 读写,全都测得起。别用 mock,别上容器,用真文件。这是零售 × AI 落地工程师这个身份特有的测试难点,也是最容易漏的一类。
一般软件里,「无数据」「失败」是异常路径;在零售单据里,「算不了」是常态——生产 400 单里 97% 无尺寸,意味着「无尺寸输入」是全系统出现频率最高的输入,而不是边角料。
| 一般函数的测试 | 你的算价函数的测试 |
|---|---|
| 输入都合法 → 只测正向路径 | 97% 的输入本身就是非法的(缺尺寸),正向路径反而是少数 |
失败 = 抛异常,测 assert.throws 一次就够 | 失败有几种写法:抛异常 / 返回 null / 返回 0 / 返回 undefined 且什么都不说——这四种里有两种是灾难 |
| 边界是数值范围 | 边界是业务存在性:这个型号有没有、这个尺寸配不配这块玻璃、这个经销商有没有价格档 |
// ① 「缺东西」必须明确报错,且说清缺什么
test('缺 width 时明确报尺寸缺失', () => {
const r = calcPrice({ modelNo: 'DL-2409-1', width: null, height: 800 })
assert.equal(r.ok, false)
assert.match(r.reason, /width/) // ← 必须点名缺的是 width,不能只说「参数有误」
})
// ② 关键:失败时金额是 null,不是 0(0 会被前端当成「免单」)
test('算不了时 amount 必须是 null 而不是 0', () => {
const r = calcPrice({ modelNo: 'DL-2409-1', width: null, height: null })
assert.equal(r.amount, null)
assert.notEqual(r.amount, 0) // ← 显式排除 0,防止哪天有人改成返回 0
})
// ③ 非法业务值(超出物理极限)必须被拒,不能进下一层
test('宽度 99999mm 这种物理上不存在的必须被拒', () => {
assert.throws(() => calcPrice({ modelNo: 'DL-2409-1', width: 99999, height: 800 }))
})
// ④ 未知型号必须报错,不能静默按市场价算
test('库里没有的型号必须报错', () => {
assert.throws(() => calcPrice({ modelNo: 'NO-SUCH-MODEL', width: 1200, height: 800 }))
})
assert.equal(r.amount, 0) 通过的函数,在前端会显示「¥0」——门店的人会以为这是免单活动,照着下单;月底对账才发现少了三万块。ok 布尔);② 行不了的金额是 null(不是 0);③ 不行是因为什么、缺哪一块(reason 里点名字段)。我的判断很直接:一个人的测试不该追求覆盖率,该追求「能挡住上次那个坑」。
| 两种写法 | 做法 | 三个月后怎么样 |
|---|---|---|
| 覆盖率路线 | 先搭框架、配 CI、写配置文件、补到 60% 覆盖率 | 起手一周看不到效果,中间某天改业务急着上线,测试没跑就跳过了,慢慢废弃 |
| 「挡住上次那个坑」路线 | 把上一次线上出事的那次操作,原样写成一条断言 | 每条断言背后都有一段真实事故,你知道它为什么在那儿,就不会随手删 |
| # | 断言(一句话) | 挡住的真实事故 |
|---|---|---|
| 1 | 无尺寸输入 → ok=false、amount=null、reason 含「尺寸」 | 400 单 97% 无尺寸算不出价 |
| 2 | collections 写入坏 JSON → 抛错且表里 0 行 | blob 写坏整条读不出来、整页白屏 |
| 3 | collections 写入 → 读回来 deepEqual 完全相等 | 上面那条的正向孪生 |
| 4 | 未知型号 → 抛错,不静默按市场价算 | 型号不存在还出了价 |
| 5 | 负宽度 / 宽度 99999 → 抛错 | 物理上不存在的输入进了算价 |
| 6 | 算价结果 = 手工算过的固定值(3186.40 这种) | 改了系数把全员价格算错 |
| 7 | migrate 在空库上从零跑 → 27 张表齐全 | 迁移漏表,新环境起不来 |
| 8 | migrate 加字段在老数据上跑 → 老行还在 | 迁移清库 |
| 9 | 订单状态:已取消 → 不能跳到「已发货」 | 状态机乱跳,业务对不上账 |
| 10 | 同一个下单请求并发 5 次 → 库存扣减只生效 1 次 | SQLITE_BUSY / 超卖(见 17-concurrency-deep.html) |
assert.equal(typeof r, 'object')、assert.ok(r.ok !== undefined) 这种只检查返回值长什么样、不检查值对不对的用例,是假测试。null,你的用例会不会红?不会红,就是假的。CI 不是「专业化的标志」,是「多人协作的必需品」。一个人要不要上,看的是「有没有第二个人会动你的代码」。
| 信号 | 说明 | 要不要上 CI |
|---|---|---|
| 只有你一个人写代码 | 改动都在脑子里记得 | 不上。git push 前置 npm test 一条脚本就够了 |
| 有第二个人(同事/外包/客户的技术方)提交 PR | 你不再认识每一行的来路 | 上。这是最硬的阈值 |
| 同一个仓库被两个人同时改 | 冲突和互相覆盖开始出现 | 上,而且要 PR 必须绿才能合并 |
| 客户方会「暂时改一下」你的代码 | FDE 现场交付最常见的场景 | 必须上,否则现场那次改动没法验证 |
| 一天要出 3 个以上交付包 | 手跑已经跟不上 | 可以上,但优先级排在「先补那 10 条断言」之后 |
# 一个人阶段(现在的状态)—— 本地前置,够了
"scripts": {
"pretest": ...,
"test": "tsx --test packages/*/test/*.test.ts",
"prepush": "npm test"
}
# 有第二个人之后的 .github/workflows/test.yml(骨架)
name: test
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm run test
# SQLite 是文件库,CI 上直接建临时文件即可,不需要 db 服务
npm test 必须能在 10 秒内全绿,否则那个人改一行你就发现不了。这也是我把「先补 10 条」排在「上 CI」前面的原因。13 个项目、27 张表,不可能一次补完。下面这个顺序是按「我已经付过学费的地方」排的,不是按技术难度排。
collections 的读写闭环(2 条断言)第一优先,因为它是唯一已经出过事故的地方。把第 02 节那次崩库写成两条:写坏 JSON → 拒绝写入、正常写入 → 读回来完全相等。
做完这一步,你就有了一个能讲的真实案例:我知道我的数据是怎么坏的,我能证明我挡住了。
packages/engine 的算价 + 型号解析(4 条断言)第二优先,因为直接赔钱。把已有的 6 个型号断言升级成 node:test 的具名用例,再加第 06 节的 4 条负向断言(无尺寸 / 未知型号 / 负宽度 / 物理不存在)。
做完这一步,你就有底气说「报价这块我敢改」。
第三优先,因为不可逆。一次新环境部署、一次误删重建,迁移跑错就是整库重来。
做法:临时库 + 依次跑全部 migration + 断言表数量等于 27。
第四优先,因为错了不报错但账对不上。每一条「已取消还能发货」「已发货还能改尺寸」都该有一个 assert.throws。
第五优先,最贵也最容易漏。同一单据并发 5 次写入,断言最终只产生 1 条有效变更(见 17-concurrency-deep.html 的 WAL / SQLITE_BUSY)。
这条最好写也最难写对,留到最后,因为它需要临时文件 + 真正的并发调用。
engine.quote 的关键路径)最后才做。起 HTTP 的成本高一档,只挑「算钱」和「写库」两个动作。前端 React/Vue 的组件测试:不排期——一个人做,视觉本来就要人看。
后果:bug 被固化成规格,而且它永远绿。对策:所有金额类断言,期望值旁边写一行注释说明「这个值怎么来的」(比如「= 玻璃 8mm 单价 × 面积 + 边框,手工核过」)。
后果:比没有测试更糟——它让你以为安全。对策:第 04 节那三步验证(绿 → 改坏 → 必须红)。另外警惕「跑了 N 个测试全绿但没有一个 assert」的文件。
后果:跑单条通过、一起跑失败;改第一个用例导致第五个红。对策:beforeEach 建新库、afterEach 删掉(第 05 节的写法)。「测试不能单独跑」是必须立刻修的信号。
把纯逻辑硬塞进 Http 处理器里、为了 mock 把函数改成全局单例、给每个函数加 options.__disableCheck 参数——这些都是被测试牵着改生产代码。
正确的方向相反:把「算」的部分抽到纯函数(packages/engine 之所以好测,就是因为它没依赖 Express、没依赖 db),然后测纯函数。
好消息:你项目里 engine 已经是零依赖的纯 TS 了——结构天生可测,不需要为测试改任何东西。
后果:测试污染生产数据,或者干脆连不上就整片红。对策:tests/ 目录永远只用临时文件,一个字节都不许碰线上路径。可以在 makeTestDb() 里加一道保险:路径里必须含 tmpdir,否则直接抛错。
这一节沿用 tech-index.html 的诚实传统:主动说清短板在哪、补到什么程度算够。不对外,只留在这里自用。
| 短板 | 真实现状(实测) | 补到什么程度算够 | 不打算补的 |
|---|---|---|---|
| 测试框架 | 0 个框架,唯一测试是 packages/engine/test/run.ts 的 6 个 node:assert | 补到「第 09 节那 20 条断言全绿」就算够。不上 vitest 也能满足,node:test 够用 | 不做组件测试、不做 E2E 覆盖全站 |
| 快照测试 / 契约测试 | 完全没概念落地过(toMatchSnapshot 之类零使用) | 不列为短板——对一个人+纯函数为主的项目不成立 | — |
| 性能压测 | 0 个 k6/autocannon,只有 ffmpeg 67 处这类音视频工具 | 补到「知道 33,176 笔订单的列表接口分页阈值在哪」用一个脚本量一次就行,不建压测体系 | 不做持续压测平台 |
| 测试数据的真实性 | 真问题:测试里的数据是我编的,和 33,176 笔订单的真实分布差很远(比如 97% 无尺寸这种事实,我的测试里根本没体现) | 至少把「无尺寸」这条真实分布写进一个用例的 fixture:造 10 条订单、9 条无尺寸,验证统计函数输出 90% | 不做大规模数据生成 |
| CI / 多环境流水线 | 0 个 CI 配置 | 阈值:有第二个人动代码时再补。在那之前,npm test 一条脚本前置 | 不做多环境部署流水线 |
packages/engine/test/run.ts,node:assert 手写 6 个断言),不把「AI 说它测过了」当证据collections 那次崩库:JSON blob 写坏 → 整行读不出来 → 整页白屏,中间没有任何东西报警node:test + node:assert 从 0 写测试,不装任何框架,tsx --test test/*.test.ts 一条命令scripts 里,以 scripts 为准,README 会过期beforeEach 建 schema,不 mock、不起容器,和线上 WAL 行为一致null 不能是 0——0 会被门店当成免单,月底对账才发现少钱npm test 前置就够先给事实,不绕。我全盘0 个测试框架,唯一的一份测试是 packages/engine/test/run.ts,用 Node 自带的 node:assert 手写了 6 个型号的断言,一条 npm run engine:test 几秒跑完,不起库不起服务。所以我的真实状态是「有起步、没体系」——我不装,直接讲。
我为什么把它作为重点在补,因为这件事我付过学费。delimao-system 里 collections 表用一个 JSON blob 存整组表单行,有一次写入格式坏了,整条记录读不出来——blob 没有 JSON schema、没有长度校验、没有部分更新的事务保护,读取路径也没有「坏行跳过、坏行计数」的防御,结果一行坏数据毁了一屏。这件事教我的不是「别用 JSON blob」,是:一个没有约束的写入点加一个没有防御的读取点,中间没有任何东西会报警。
所以我的判断是:一个人的测试策略不该追求覆盖率,该追求「能挡住上次那个坑」。
公式就一句:把上次线上出事的那次操作,原样写成一条断言。先写 10 条能挡住真实事故的断言,比搭一套 CI 值钱。
而且这 10 条的每一条背后都有一段我能讲出来的事故,所以它们不会被我随手删掉——这是覆盖率路线给不了的好处。
具体技术层面我分三块。第一块是分层:我的公式是「凡是算错了会赔钱、会丢数据、会不可逆的必须测,错了只是不好看的不用测」。落到你这个行业就是四块:价格计算、单据状态机、字段校验、数据库迁移。样式、布局、纯展示组件一律不测——改一版 CSS 就红一片,维护成本大于收益。
第二块是从 0 怎么写。不装框架,Node 18+ 自带 node:test 和 node:assert,TypeScript 用 tsx --test 跑就行,一条命令挂到 scripts。唯一不能跳的一步是「验证测试是真的」:先跑绿,然后故意把被测那行改坏(比如把 *1.15 改成 *1.5),再跑一遍必须红,改回去。没红的测试是假测试,比没测试更糟,因为它让你以为安全。
第三块是 SQLite 环境怎么测。最快的办法是直接建一个临时 db 文件——mkdtempSync 建在 tmpdir 下,pragma journal_mode = WAL 跟线上对齐,beforeEach 建 schema、afterEach 删目录。不要 mock 掉 better-sqlite3(那类事故 100% 测不出来),不要为测试起容器(一个人是过度工程)。迁移脚本单独测一个「空库从零跑 + 断言 27 张表齐全」和一个「老数据上升级不丢行」。
还有一块是这个岗位特有的,我想特别讲:把「算不了」当成断言。一般软件里失败是异常路径,但零售单据里「算不了」是常态——我盘生产数据,400 单里 97% 无尺寸,意味着「缺尺寸输入」是全系统出现频率最高的输入,不是边角料。所以我的负向断言要写四条:缺哪个字段要点名字、失败时金额必须是 null 不能是 0(0 会被门店当成免单照着下单,月底对账才发现少了三万块)、物理上不存在的尺寸必须拒、库里没有的型号必须报错不能静默按市场价算。这也解释了我为什么认为 zod 不够:zod 能证明「width 是 number 或 null」是类型合法,但「null 进去返回 null 还是 0」是业务规则,只能靠断言锁。
关于 CI 我的阈值很清楚:CI 不是专业化的标志,是多人协作的必需品。我到现在 13 个项目都是一个人写,所以不上 CI,本地 npm test 前置就够;但 FDE 项目十几天内就会有第二个人动你的代码(客户的技术方、或者外包),到那天 npm test 必须 10 秒内全绿,否则他改一行你就发现不了。顺序我是倒过来的:先补 10 条断言,再考虑搬上 CI。
最后是我不打算装的部分:不做组件测试、不做全站 E2E、不做快照测试、不做压测平台。我给自己定的够用标准是20 条断言、300 行测试代码全绿。我唯一承认的真实短板,是测试数据是我编的、跟真实分布差很远——所以测试只能证明逻辑对,证明不了数据够,后者靠真库跑统计函数,这两件事不能互相替代,这也是我从不当覆盖率是 KPI 的原因。
这套测试的「为什么写」来自 19 · 怎么读懂 AI 写的代码(第 09 节测试从 0 到 1 的速查版)与 19-deep(异常处理的三种坏味道、assert.throws 为什么必须写);
库那一段的底层在 16 · 数据库存东西 与 16-deep(27 张表分层、JSON blob 为什么该拆表);
并发那条断言的依据在 17-deep · 并发锁与事务(WAL、SQLITE_BUSY)。