FDE·技术基础
基础目录
测试从0到1 数据可视化

测试从 0 到 1 · 一个人做项目怎么建立可信度

全盘实测: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 承载表单行,写坏一次整条读不出来。

一句话定位:不是「不会用测试框架」,是从没建立习惯。依赖树里 26 处 better-sqlite3、9 处 zod、7 处 drizzle —— 这些都会用;唯独「改完东西之后怎么证明没弄坏」这一环是空的。而那正是 FDE 这件事的核心能力:你交付的不是代码,是「能放心用」。
01

先承认这个事实:我的测试是 0

诚实是这套体系的起点。不装、不绕、不拿「AI 说它测过了」当证据。下面是实测数字。

扫的是什么实测结果说明
*.test.* / *.spec.* 全盘命中0除了下面那一条
packages/engine/test/run.ts唯一的测试文件,6 个断言手写 node:assert,npm run engine:test 一条命令跑完,不起库、不起服务
vitest / jest / mocha / @playwright/test0 个playwright 只作为 1.63 的传递依赖出现过,没有写任何用例
CI 配置(.github/workflows 等)0 个没有自动化跑测试的地方
但这个文件本身就是「正确做法的证明」。它说明三件事:① 你知道测试要放在 test/ 目录;② 你知道测试要跑纯函数、不起服务;③ 你愿意用 Node 自带的东西,而不是为了测试先装 30 个包。
缺的只是「把它变成习惯」——而不是「先学完框架再开始」。
为什么我把这一节放在最前面:因为「我有个测试文件」和「我有测试」是两件事。6 个断言覆盖的是 engine 的型号解析,其它 26 张表、4 个包、报价链路、状态机、字段校验——全部裸奔。如果你对外讲「我有测试」,那是虚的。诚实的说法是:「我有一处起步的测试,我知道剩下的是短板,我有具体的补法,按出过事的地方排。」
02

没测试会付什么学费:把那次崩库事故推演一遍

讲测试为什么重要,最好的教材不是「覆盖率」,是你自己线上出过的那一次。delimao-system 的 collections 表就是活教材。

事故现场 reconstruction(基于真实表结构)

环节发生了什么为什么没人拦住
表单行落库collections 用一个 JSON blob 字符串存整组表单行AI 为了「少建几张表」把结构化数据塞进一个字段——它没有问你这些东西将来要不要单独查
一次写入某次写入截断或格式异常,blob 变成 {"items":[{...},blob 没有约束:没有 JSON schema,没有长度校验,没有部分更新的事务保护
读取整行 JSON.parse 直接抛异常,这一行读不出来读取路径没有 try/catch,也没有「坏行跳过、坏行计数」
后果放大因为它是行级关联数据,读这行的功能全挂;页面列表里整页白屏一行的坏数据把一屏的数据全毁了——这就是 JSON blob 相对拆表的根本差别
这条事故真正教我的不是「别用 JSON blob」。(16-database-deep.html 里已经讲过为什么该拆表。)
真正教我的是:一个没有约束的写入点 + 一个没有防御的读取点,中间没有任何东西会报警。
写坏了没人知道,读坏了整页崩,崩之前系统一直在正常工作。
这就是零测试的代价——不是「改代码慢一点」,是「出事了才发现,而且发现的时候用户已经不敢用了」。

同一个 bug,有测试的版本会怎样

零测试有测试
发现时机用户报障(可能是上线三个月后)改完代码的 3 秒内
定位成本翻日志、复现、猜是哪行写坏的测试直接告诉你「这条 POST 写入后读回来是坏的」
修复范围「先修这行,过两天又坏了」修写入 + 修读取,同一个用例再跑一遍证明闭环
下次同类换个字段再犯一次回归测试留下来,同类再犯会被挡住
把这张表记住就够了:测试的唯一价值是「把发现问题的时机从客户嘴里提前到你自己的终端上」。其它所有关于覆盖率、TDD、测试金字塔的说法,都是这一条的不同包装。
03

一个人的测试分层:哪些必须测,哪些根本不用测

一个人做项目,没有同事帮你 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 是自欺欺人枚举字典、展示型组件
一个人的经验公式:凡是「算错了会赔钱 / 会丢数据 / 会不可逆」的,写测试;凡是「错了只是不好看 / 只是慢一点」的,不写。
按照这个公式,你的第一优先级非常清楚,而且只有四块:算价、状态机、字段校验、迁移。四块加起来可能不到 150 行测试代码——但它能挡住你这辈子踩过的那三个大坑。
反过来说,千万别做的事:不要为了「覆盖率 80%」去给 collections 之外的一切补测试。覆盖率是给团队用的指标,一个人用它是自虐。你的目标不是数字,是「这四条链路我改坏了会被拦下来」。
04

从 0 写起的 50 行最小骨架(可直接复制)

不需要装任何东西。Node 18+ 自带 node:test 和 node:assert,TypeScript 用 tsx 跑就行(你项目里本来就在用 tsx watch)。

① 写一个测试文件(engine 层)

// 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"
  }
}
/run.ts 是怎么跑的?它就是上面这个形态——一个文件、node:assert 手写、一条 npm run engine:test、几秒跑完、不起库不起服务。
你已经做对了两件事:目录叫 test/、跑法是一条 script。从 run.ts 到 price.test.ts,变的只是把 6 个断言拆成 4 个 test() 并加上 node:test 的用例名——不到 10 分钟。

③ 一条「改坏了它真的会红」的验证

1

先跑一遍,必须是绿的

npm run engine:test。绿的才继续,红的说明断言写错了。

2

故意把被测的那行改坏

把 calcPrice 里的 * 1.15 改成 * 1.5,或者把 throw 改成 return 0。

3

再跑一遍,必须看到红

看到红了才算这个测试有效。没红 = 这个测试是假的(断言写空了、断言的值和代码一样错)。

4

改回去,保留用例

这一步 15 秒。它是「我信得过自己的测试」的唯一依据。

第 02 步和第 03 步是整个测试体系里最值钱的动作——不写测试的人永远跳过它们,于是永远不知道自己写的是不是假测试。假测试比没测试更糟:它让你以为安全。
05

测试怎么覆盖 SQLite 真库(不上容器、不起服务)

你的库是 SQLite + better-sqlite3(26 处)+ WAL。在这种环境里造测试数据最快的方式是直接建一个临时 db 文件——不要为了测试起 Docker、不要起内存版、更不要 mock 掉整个 db 层。

① 一个 20 行的测试库工厂(每个测试文件复用)

// 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 }) }
  }
}

② 用 beforeEach / afterEach 保证用例之间不互相污染

// 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% 测不出来
一句话:SQLite 上做集成测试的成本几乎为零(一个 mkdtempSync),所以它能测的东西特别多——约束、外键、迁移、WAL 并发、blob 读写,全都测得起。别用 mock,别上容器,用真文件。
06

把「算不了」当成断言(FDE 特有场景)

这是零售 × AI 落地工程师这个身份特有的测试难点,也是最容易漏的一类。
一般软件里,「无数据」「失败」是异常路径;在零售单据里,「算不了」是常态——生产 400 单里 97% 无尺寸,意味着「无尺寸输入」是全系统出现频率最高的输入,而不是边角料。

为什么这一层特别容易没测试

一般函数的测试你的算价函数的测试
输入都合法 → 只测正向路径97% 的输入本身就是非法的(缺尺寸),正向路径反而是少数
失败 = 抛异常,测 assert.throws 一次就够失败有几种写法:抛异常 / 返回 null / 返回 0 / 返回 undefined 且什么都不说——这四种里有两种是灾难
边界是数值范围边界是业务存在性:这个型号有没有、这个尺寸配不配这块玻璃、这个经销商有没有价格档

负向用例该怎么写(4 类断言,缺一不可)

// ① 「缺东西」必须明确报错,且说清缺什么
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」——门店的人会以为这是免单活动,照着下单;月底对账才发现少了三万块。
所以「失败返回什么」这件事,本身就是业务决策,必须被写进测试里锁住。
这也是为什么 zod 挡不住这一层:zod 能证明「width 是 number 或 null」但「null 进去该返回 null 还是 0」是业务规则,只能靠断言锁。
给这套断言一个名字叫「说清楚三件事」,面试/交付时也能这样讲:
① 行不行(ok 布尔);② 行不了的金额是 null(不是 0);③ 不行是因为什么、缺哪一块(reason 里点名字段)。
三条齐了,前端就能把「算不了」显示成「缺尺寸,先补尺寸」而不是「¥0」——一个字段的差别,从「能用」变成「不敢用」。
07

核心判断:一个人的测试策略该追求什么

我的判断很直接:一个人的测试不该追求覆盖率,该追求「能挡住上次那个坑」。

两种写法做法三个月后怎么样
覆盖率路线先搭框架、配 CI、写配置文件、补到 60% 覆盖率起手一周看不到效果,中间某天改业务急着上线,测试没跑就跳过了,慢慢废弃
「挡住上次那个坑」路线把上一次线上出事的那次操作,原样写成一条断言每条断言背后都有一段真实事故,你知道它为什么在那儿,就不会随手删
所以我的公式是:
回归测试 = 把上次线上出事的那一次操作,写成一条断言。
先写 10 条能挡住真实事故的断言,比搭一套 CI 值钱。

第一个月就干这 10 条(按你项目里已确认的事故排)

#断言(一句话)挡住的真实事故
1无尺寸输入 → ok=false、amount=null、reason 含「尺寸」400 单 97% 无尺寸算不出价
2collections 写入坏 JSON → 抛错且表里 0 行blob 写坏整条读不出来、整页白屏
3collections 写入 → 读回来 deepEqual 完全相等上面那条的正向孪生
4未知型号 → 抛错,不静默按市场价算型号不存在还出了价
5负宽度 / 宽度 99999 → 抛错物理上不存在的输入进了算价
6算价结果 = 手工算过的固定值(3186.40 这种)改了系数把全员价格算错
7migrate 在空库上从零跑 → 27 张表齐全迁移漏表,新环境起不来
8migrate 加字段在老数据上跑 → 老行还在迁移清库
9订单状态:已取消 → 不能跳到「已发货」状态机乱跳,业务对不上账
10同一个下单请求并发 5 次 → 库存扣减只生效 1 次SQLITE_BUSY / 超卖(见 17-concurrency-deep.html)
10 条断言,大概 120 行测试代码,一个下午能写完。
它能挡住的比例,比一个 80% 覆盖率但全是 getter 的测试套件高得多。
而且这 10 条每一条都能讲出一段「我怎么踩的」——这就是给面试官和给客户讲的东西。
红线:不要把测试写成「复述实现」。
assert.equal(typeof r, 'object')、assert.ok(r.ok !== undefined) 这种只检查返回值长什么样、不检查值对不对的用例,是假测试。
判断方法:把被测函数的实现整段删掉,让它返回 null,你的用例会不会红?不会红,就是假的。
08

什么时候该上 CI:一个人的规模阈值

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 服务
最常见的误判:把「上 CI」当成「有测试」的证明。
CI 只是一台会自动跑你写的东西的机器。如果那 10 条断言还没写,上 CI 只会让红色的时间从「本地 3 秒」变成「等 6 分钟」——你还会更烦。
顺序是:先有 10 条断言 → 跑本地脚本 green → 再考虑要不要搬到 CI。
你现在的答案很明确:13 个项目都是你一个人写的,所以不上 CI;但当第一次有第二个人动你的仓库时(这是 FDE 项目十几天内就会发生的事),npm test 必须能在 10 秒内全绿,否则那个人改一行你就发现不了。这也是我把「先补 10 条」排在「上 CI」前面的原因。
09

补测试的顺序:按「出过事的地方」排

13 个项目、27 张表,不可能一次补完。下面这个顺序是按「我已经付过学费的地方」排的,不是按技术难度排。

1

collections 的读写闭环(2 条断言)

第一优先,因为它是唯一已经出过事故的地方。把第 02 节那次崩库写成两条:写坏 JSON → 拒绝写入、正常写入 → 读回来完全相等。
做完这一步,你就有了一个能讲的真实案例:我知道我的数据是怎么坏的,我能证明我挡住了。

2

packages/engine 的算价 + 型号解析(4 条断言)

第二优先,因为直接赔钱。把已有的 6 个型号断言升级成 node:test 的具名用例,再加第 06 节的 4 条负向断言(无尺寸 / 未知型号 / 负宽度 / 物理不存在)。
做完这一步,你就有底气说「报价这块我敢改」。

3

迁移脚本在空库上从零跑通(2 条断言)

第三优先,因为不可逆。一次新环境部署、一次误删重建,迁移跑错就是整库重来。
做法:临时库 + 依次跑全部 migration + 断言表数量等于 27。

4

订单/工单状态机的非法跳转(按实际状态枚举补)

第四优先,因为错了不报错但账对不上。每一条「已取消还能发货」「已发货还能改尺寸」都该有一个 assert.throws。

5

并发写入(1 条断言)

第五优先,最贵也最容易漏。同一单据并发 5 次写入,断言最终只产生 1 条有效变更(见 17-concurrency-deep.html 的 WAL / SQLITE_BUSY)。
这条最好写也最难写对,留到最后,因为它需要临时文件 + 真正的并发调用。

6

接口层(tRPC engine.quote 的关键路径)

最后才做。起 HTTP 的成本高一档,只挑「算钱」和「写库」两个动作。前端 React/Vue 的组件测试:不排期——一个人做,视觉本来就要人看。

做完 1–5 步,你大概有 20 条断言、300 行测试代码。
在别人眼里这已经是「有测试的项目」;在没有测试的人眼里,这是「这个人出过事、并且把事故固化成了防线」。
第 ② 步的「敢改报价」是真正的外卖——FDE 交付后必然要调参数、改系数,没测试的时候每次调价都是一次赌博。
10

常见坑 / 红线(这一篇补充)

坑 1 · 期望值是「上次跑出来的」而不是手工算的

后果:bug 被固化成规格,而且它永远绿。对策:所有金额类断言,期望值旁边写一行注释说明「这个值怎么来的」(比如「= 玻璃 8mm 单价 × 面积 + 边框,手工核过」)。

坑 2 · 假绿:断言什么都没查就通过

后果:比没有测试更糟——它让你以为安全。对策:第 04 节那三步验证(绿 → 改坏 → 必须红)。另外警惕「跑了 N 个测试全绿但没有一个 assert」的文件。

坑 3 · 测试用例之间互相依赖(共享同一个 db 实例)

后果:跑单条通过、一起跑失败;改第一个用例导致第五个红。对策:beforeEach 建新库、afterEach 删掉(第 05 节的写法)。「测试不能单独跑」是必须立刻修的信号。

坑 4 · 为了测试把生产代码改成「好测的形状」

把纯逻辑硬塞进 Http 处理器里、为了 mock 把函数改成全局单例、给每个函数加 options.__disableCheck 参数——这些都是被测试牵着改生产代码。
正确的方向相反:把「算」的部分抽到纯函数(packages/engine 之所以好测,就是因为它没依赖 Express、没依赖 db),然后测纯函数。
好消息:你项目里 engine 已经是零依赖的纯 TS 了——结构天生可测,不需要为测试改任何东西。

坑 5 · 测试里连着真实线上库

后果:测试污染生产数据,或者干脆连不上就整片红。对策:tests/ 目录永远只用临时文件,一个字节都不许碰线上路径。可以在 makeTestDb() 里加一道保险:路径里必须含 tmpdir,否则直接抛错。

最后把这一篇收一句:一个人的测试体系,目标不是「证明代码对」,是「让你敢改」。
敢改,才会改;改得多,才会越来越对。补完第 09 节那 20 条,你最实际的收益不是质量数字,是「下次调价系数的时候不用再熬夜盯着」。
11

我明确不会的(诚实短板)

这一节沿用 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 一条脚本前置不做多环境部署流水线
最扎心的一条我也写下来:「测试数据是我编的」这件事本身,就是 FDE 的真实风险。
我在真库上测出来的 96.6% / 95.2% / 29% / 0% 匹配率,是我「算过」的结果;但我的测试里跑的是我自己造的干净数据。
所以测试只能证明「逻辑对」,证明不了「数据够」。后者靠的是第 05 节那种真库跑法 + 定期用真实订单抽样跑统计函数。
这两件事是互补的,不能互相替代——这也是我不把「测试覆盖率」当 KPI 的原因。
✓

自查清单

  • ✓我知道全盘测试实测是 0(唯一例外 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 会过期
  • ✓我验证测试的办法是四步:绿 → 故意改坏 → 必须红 → 改回去,这是「我信得过测试」的唯一依据
  • ✓我知道 SQLite 测试用临时 db 文件 + beforeEach 建 schema,不 mock、不起容器,和线上 WAL 行为一致
  • ✓我会测迁移:在空库上从零跑一遍,断言 27 张表齐全;在老数据上加字段,断言老行还在
  • ✓我知道「算不了」是 FDE 的常态用例(400 单 97% 无尺寸),负向断言要覆盖缺字段 / 金额 null 不是 0 / 物理不存在 / 未知型号
  • ✓我守住的红线:算不出来时 amount 必须是 null 不能是 0——0 会被门店当成免单,月底对账才发现少钱
  • ✓我的核心判断:一个人的测试策略不追覆盖率,追「能挡住上次那个坑」,先写 10 条真实事故断言,比搭一套 CI 值钱
  • ✓我知道第一个月就写那 10 条(blob 读写 2 + 算价解析 4 + 迁移 2 + 状态机 1 + 并发 1),大约 120 行测试代码
  • ✓我知道上 CI 的阈值是「有第二个人会动你的代码」,不是「项目变大了」;在那之前 npm test 前置就够
  • ✓我的补测试顺序按出过事的地方排:collections → engine → 迁移 → 状态机 → 并发 → 接口层
  • ✓我会识别假测试:把函数实现删掉让它返回 null,用例还不红的,就是假的
  • ✓我知道测试不能替代数据校验——测试里是我编的干净数据,证明不了真实分布(29% 覆盖率这种事)
面试怎么答

题目:你用 AI 开发,测试是怎么做的?

先给事实,不绕。我全盘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)。

← 回到技术基础目录12 篇 + 补测计划 · FDE 技术基础