02 · TDD 规程与反作弊
本机
~/personal下 40+ 个项目没有一个配过测试(实测 vitest/playwright/testing-library 命中 0)。 Baton 是第一个。所以这一节的配置都要从零装,别去找现成的抄。
#1. 红-绿-重构的硬规则
1. 每条 AC 的实现,必须先有一条【只动测试文件】的 commit(红),
再有一条【只动实现文件】的 commit(绿)。
⛔ 不允许在同一个 commit 里既加测试又加让它通过的实现。
2. 红色 commit 之前必须真的跑一次测试,确认它失败,并且失败原因是
"功能未实现"而不是"测试自己写错了"。把失败摘要写进 commit body。
3. 绿色 commit 之后必须再跑一次,把通过结果写进 commit body。
4. 测试文件(tests/**、*.test.ts、*.spec.ts)⛔ 禁止出现在 feat:/fix:/refactor:
类型的 commit 的 diff 里。确实要改测试,只能用 test: 类型的 commit,
且必须同时在对应 spec 的 CHANGELOG.md 里追加一条说明。
5. 一条 commit 只服务一条(或一组强相关的)AC。⛔ 禁止巨型 commit。为什么这么严:明早人类会跑 git log --oneline --reverse,看到的应该是 test → feat → test → feat 的锯齿。看到连续三条 feat 中间没有 test,就知道这段是"先写完再补测试",也就是假 TDD。
#2. Commit message 规范
<type>(<spec_id>): <AC 编号> <一句话>
<为什么这么做 / 跑了什么命令 / 结果摘要>type ∈ test | feat | fix | refactor | chore | docs
红
test(005): AC-5.2.4 未勾选内容对接手人不可见的失败测试
新增 tests/integration/handover-scope.test.ts,构造 A 有 4 条记忆、交接只勾 2 条的场景,
断言 B 完成确认后只能查到那 2 条。
npx vitest run --project integration --> 1 failed(预期,交接授予逻辑尚未实现)
失败原因:目前 B 查到 0 条(授予逻辑完全没写),符合"未实现"预期。绿
feat(005): AC-5.2.4 实现交接可见权授予与范围限制
lib/handover.ts 增加 grantScope(),检索层在 owner 之外并入"已完成交接授予"的 id 集合。
npx vitest run --project integration --> 1 passed⛔ commit message 里不许出现任何密钥。
#3. 测试分层
| 层 | 测什么 | ⛔ 不测什么 | 环境 | 目录 |
|---|---|---|---|---|
| unit(node) | 纯函数:切片算法、文本归一化、RRF 融合、zod schema、状态机流转 | 不碰真实 DB、不碰网络 | vitest node | tests/unit/ |
| component(jsdom) | shadcn 组件交互:开关切换、表单校验、勾选清单行为 | 不测像素级样式;fetch 一律 mock | vitest jsdom | tests/components/ |
| integration | API Route + 真实 Supabase:隔离、交接授予、检索、状态机 | 不测 UI 渲染 | vitest node(真连库) | tests/integration/ |
| e2e | 关键路径在真实浏览器跑通:登录 → 上传 → 搜索 → 交接 → 确认 | 不做穷举分支覆盖 | Playwright chromium | tests/e2e/ |
必须真连、⛔ 不许 mock 的东西:
- Supabase(集成测试里
vi.mock('...supabase')= 违规) - 文件解析(要真喂一个 PDF fixture)
允许 mock 的只有:LLM/embedding HTTP 调用(用固定 fixture 向量)、时间、随机数。
#4. 配置文件(直接抄)
#4.1 安装
npm i -D vitest @vitejs/plugin-react jsdom @testing-library/react @testing-library/jest-dom \
@vitest/coverage-v8 @playwright/test dotenv
npx playwright install chromium#4.2 vitest.config.ts
// Vitest 配置:三个 project 分环境跑——node 跑纯逻辑,jsdom 跑组件,integration 真连 Supabase
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import path from 'node:path'
export default defineConfig({
plugins: [react()],
resolve: { alias: { '@': path.resolve(__dirname, './') } },
test: {
globals: true,
reporters: ['default', 'json', 'junit'],
outputFile: {
json: './docs/night/vitest-results.json',
junit: './docs/night/vitest-junit.xml',
},
coverage: {
provider: 'v8',
reporter: ['text', 'html', 'json-summary'],
reportsDirectory: './docs/night/coverage',
// 阈值只卡核心目录。全局阈值故意设得低,避免为了刷数字去写垃圾测试。
thresholds: {
lines: 55,
branches: 50,
functions: 55,
statements: 55,
'lib/**': { lines: 80, branches: 70, functions: 80, statements: 80 },
'app/api/**': { lines: 70, branches: 60, functions: 70, statements: 70 },
},
exclude: [
'node_modules/**', '.next/**', 'tests/**', 'scripts/**',
'**/*.config.ts', '**/*.d.ts',
'app/**/layout.tsx', 'app/**/loading.tsx',
'components/ui/**', // shadcn 生成的组件不计入,不是我们的代码
],
},
projects: [
{
extends: true,
test: {
name: 'unit',
environment: 'node',
include: ['tests/unit/**/*.test.ts', 'lib/**/*.test.ts'],
},
},
{
extends: true,
test: {
name: 'component',
environment: 'jsdom',
setupFiles: ['./tests/setup/jsdom.ts'],
include: ['tests/components/**/*.test.tsx'],
},
},
{
extends: true,
test: {
name: 'integration',
environment: 'node',
setupFiles: ['./tests/setup/integration.ts'],
include: ['tests/integration/**/*.test.ts'],
fileParallelism: false, // 真连库,串行跑,避免测试数据互相污染
testTimeout: 30000,
},
},
],
},
})tests/setup/jsdom.ts
import '@testing-library/jest-dom/vitest'tests/setup/integration.ts
// 集成测试的安全带:所有测试数据必须带本次运行的前缀,跑完自动清理
import { config } from 'dotenv'
config({ path: '.env.local' })
// RUN_ID 由启动时间派生,同一次 vitest run 内所有文件共享(通过环境变量传递)
if (!process.env.BT_TEST_RUN_ID) {
process.env.BT_TEST_RUN_ID = `t${Date.now().toString(36)}`
}
export const RUN_ID = process.env.BT_TEST_RUN_ID#4.3 playwright.config.ts
// Playwright 配置:默认跑 dev server(快,用于红绿循环);
// 阶段闸门和最终验收用 E2E_PROD=1 跑真实生产构建。
import { defineConfig, devices } from '@playwright/test'
const useProd = process.env.E2E_PROD === '1'
export default defineConfig({
testDir: './tests/e2e',
outputDir: './docs/night/e2e-artifacts',
fullyParallel: false,
forbidOnly: true, // .only 直接让整个 run 失败,这是反作弊硬闸
retries: 1, // 只重试一次,抹平网络抖动;重试仍失败即真失败
workers: 1,
reporter: [
['list'],
['json', { outputFile: './docs/night/playwright-results.json' }],
['html', { outputFolder: './docs/night/playwright-html', open: 'never' }],
],
use: {
baseURL: 'http://localhost:3000',
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
video: 'off',
actionTimeout: 10000,
},
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
webServer: {
command: useProd ? 'npm run build && npm run start' : 'npm run dev',
url: 'http://localhost:3000/login', // 用 /login 探活:它是唯一免登录页
reuseExistingServer: !useProd,
timeout: 180 * 1000,
stdout: 'pipe',
stderr: 'pipe',
},
})⚠️ E2E 必须先过密码门。写一个 tests/e2e/fixtures.ts,在 beforeEach 里 POST /api/login 拿 Cookie 注入 context。本地 .env.local 必须配好 HUB_SITE_PASSWORD / HUB_AUTH_SECRET,否则登录接口返 503(见 08-可复用资产清单 §1)。
#4.4 package.json scripts
{
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"typecheck": "tsc --noEmit",
"lint": "next lint",
"test": "vitest run",
"test:unit": "vitest run --project unit",
"test:integration": "vitest run --project integration",
"test:cov": "vitest run --coverage",
"e2e": "playwright test",
"e2e:prod": "E2E_PROD=1 playwright test",
"anticheat": "bash scripts/anti-cheat-check.sh",
"gate": "bash scripts/gate.sh"
}
}#5. 集成测试的数据卫生
⛔ 绝对不要在测试里删除或修改非本次运行创建的数据。 Leo-hub 上有 20+ 张别的项目的表在跑生产业务。
规则:
- 所有测试数据的可辨识字段(
employee_code、original_filename、title)必须以RUN_ID开头,例:t1a2b3c_王销售。 - 写一个
tests/helpers/db.ts,导出createTestEmployee()/createTestFile()等工厂函数,内部强制拼 RUN_ID 前缀。测试文件 ⛔ 禁止绕过工厂函数直接 insert。 afterAll按 RUN_ID 前缀清理:delete from bt_* where ... like 'RUN_ID%'。- ⛔ 任何
delete/update语句都必须带 RUN_ID 条件。无条件的delete from bt_files是灾难。 - 收尾时跑一次泄漏检查:扫
bt_employees里是否残留超过 2 小时的t*前缀行,有就清掉并记进 worklog。
#6. 反作弊
#6.1 你会想干但不许干的 14 件事
| # | 手法 | 怎么被抓到 |
|---|---|---|
| 1 | it.skip / describe.skip / xit 跳过跑不过的 |
grep |
| 2 | it.only 只跑自己想跑的 |
grep + playwright forbidOnly: true |
| 3 | 恒真断言 expect(true).toBe(true) |
grep |
| 4 | 只写 toBeDefined() / toBeTruthy() 敷衍 |
统计弱断言占比 > 60% 的文件 |
| 5 | expect 塞进 try/catch 且 catch 吞掉异常 |
正则扫描 |
| 6 | mock 掉被测模块本身 | 检查 vi.mock 路径是否命中同名源文件 |
| 7 | E2E 只截图不断言 | 文件里有 screenshot( 但无 expect( |
| 8 | 把失败用例注释掉 | grep // it( |
| 9 | 偷偷调低覆盖率阈值 | 对比 scripts/coverage-baseline.json |
| 10 | 删掉难跑的测试文件 | git diff --diff-filter=D |
| 11 | if (process.env.X) return 绕过用例 |
grep |
| 12 | .rejects.toBeDefined() 掩盖真实错误类型 |
grep,要求 .rejects.toThrow(具体) |
| 13 | 集成测试里 mock 掉 Supabase | grep tests/integration/ 里的 vi.mock('...supabase |
| 14 | 把 AC 从 spec 里悄悄删掉 | AC 矩阵与 spec 文件条数对不上 |
#6.2 唯一允许 skip 的场景
一条 AC 连续 3 次修改实现后仍然红,可以降级:
- 往
docs/night/blockers.md追加一条记录(写清 3 次尝试和最终报错) - 把测试标
it.skip('AC-x.x.x: ...') - 用
chore类型 commit,message 里必须出现blocked这个词 - 立刻转向下一条独立的 AC
反作弊脚本会白名单放行同时满足「blockers.md 有记录」+「commit message 含 blocked」的 skip。其余一律判违规。
#6.3 scripts/anti-cheat-check.sh
#!/usr/bin/env bash
# 反作弊扫描:命中任何一条即记录,有命中则非零退出。
# 用法:bash scripts/anti-cheat-check.sh
set -uo pipefail
cd "$(git rev-parse --show-toplevel)"
OUT="docs/night/anti-cheat-report.md"
mkdir -p docs/night
VIOL=0
TESTS=$(git ls-files | grep -E '\.(test|spec)\.(ts|tsx)$' || true)
{ echo "# 反作弊扫描报告"; echo; echo "扫描时间:$(date '+%F %T')"; echo; } > "$OUT"
hit () {
VIOL=$((VIOL+1))
{ echo "## [命中 $VIOL] $1"; echo '```'; echo "$2"; echo '```'; echo; } >> "$OUT"
}
if -n "$TESTS" ; then
# 1/2 skip / only(skip 需白名单:commit 含 blocked 且 blockers.md 有记录)
H=$(echo "$TESTS" | xargs grep -nE '\.(only)\(|\bfit\(|\bfdescribe\(' 2>/dev/null || true)
-n "$H" && hit "only/fit 独占测试(一律违规)" "$H"
H=$(echo "$TESTS" | xargs grep -nE '\.skip\(|\bxit\(|\bxdescribe\(' 2>/dev/null || true)
if -n "$H" ; then
while IFS= read -r line; do
F=$(echo "$line" | cut -d: -f1)
LASTMSG=$(git log -1 --format=%s -- "$F" 2>/dev/null || echo "")
if "$LASTMSG" != *blocked* || ! grep -q "$(basename "$F")" docs/night/blockers.md 2>/dev/null; then
hit "未走 blocked 流程的 skip" "$line (最近 commit: $LASTMSG)"
fi
done <<< "$H"
fi
# 3 恒真断言
H=$(echo "$TESTS" | xargs grep -nE 'expect\(true\)\.toBe\(true\)|expect\(1\)\.toBe\(1\)|expect\(\)\.' 2>/dev/null || true)
-n "$H" && hit "恒真/占位断言" "$H"
# 4 弱断言占比
for f in $TESTS; do
T=$(grep -c 'expect(' "$f" 2>/dev/null || echo 0)
W=$(grep -cE 'toBeDefined\(\)|toBeTruthy\(\)' "$f" 2>/dev/null || echo 0)
if "$T" -gt 2 && $(( W * 100 / T )) -ge 60 ; then
hit "弱断言占比过高" "$f: $W/$T"
fi
done
# 5 try/catch 吞断言
H=$(echo "$TESTS" | xargs perl -0777 -ne '
while (/try\s*\{(.*?)\}\s*catch\s*\([^)]*\)\s*\{(.*?)\}/sg) {
my ($t,$c)=($1,$2);
print "$ARGV\n" if $t=~/expect\(/ && $c!~/throw|fail\(/;
}' 2>/dev/null || true)
-n "$H" && hit "try/catch 吞掉断言失败" "$H"
# 6 mock 掉被测模块
for f in $TESTS; do
S=$(basename "$f" | sed -E 's/\.(test|spec)\.(ts|tsx)$//')
H=$(grep -nE "vi\.mock\([\"'].*${S}[\"']" "$f" 2>/dev/null || true)
-n "$H" && hit "疑似 mock 掉被测模块本身 ($f)" "$H"
done
# 8 注释掉的测试
H=$(echo "$TESTS" | xargs grep -nE '^\s*//\s*(it|test|describe)\(' 2>/dev/null || true)
-n "$H" && hit "被注释掉的测试用例" "$H"
# 11 env 条件绕过
H=$(echo "$TESTS" | xargs grep -nE 'if\s*\(.*process\.env.*\)\s*(return|continue)' 2>/dev/null || true)
-n "$H" && hit "用 process.env 条件跳过测试逻辑" "$H"
# 12 rejects 弱断言
H=$(echo "$TESTS" | xargs grep -nE '\.rejects\.(toBeDefined|toBeTruthy)\(\)' 2>/dev/null || true)
-n "$H" && hit "rejects 未断言具体错误" "$H"
fi
# 7 E2E 只截图不断言
for f in $(git ls-files 'tests/e2e/*' 2>/dev/null || true); do
if grep -q 'screenshot(' "$f" && ! grep -q 'expect(' "$f"; then
hit "E2E 只截图无断言" "$f"
fi
done
# 13 集成测试 mock supabase
H=$(git ls-files 'tests/integration/*' 2>/dev/null | xargs grep -nE "vi\.mock\(.*supabase" 2>/dev/null || true)
-n "$H" && hit "集成测试里 mock 了 Supabase" "$H"
# 9 覆盖率阈值被下调
if -f scripts/coverage-baseline.json && -f vitest.config.ts ; then
BAD=$(node -e "
const fs=require('fs');
const b=JSON.parse(fs.readFileSync('scripts/coverage-baseline.json','utf8'));
const c=fs.readFileSync('vitest.config.ts','utf8');
const out=[];
for (const [,k,v] of c.matchAll(/(lines|branches|functions|statements):\s*(\d+)/g)) {
if (b[k]!==undefined && Number(v)<b[k]) out.push(k+': '+v+' < 基线 '+b[k]);
}
if(out.length) console.log(out.join('\n'));
" 2>/dev/null || true)
-n "$BAD" && hit "覆盖率阈值被下调" "$BAD"
fi
# 10 测试文件被删
ROOT=$(git rev-list --max-parents=0 HEAD | tail -1)
DEL=$(git diff --diff-filter=D --name-only "$ROOT"..HEAD 2>/dev/null | grep -E '\.(test|spec)\.(ts|tsx)$' || true)
-n "$DEL" && hit "测试文件被删除" "$DEL"
# 15 客户端泄漏服务端密钥(安全,不是作弊但同等严重)
if -d .next/static ; then
H=$(grep -rl "service_role\|SERVICE_ROLE\|sb_secret_" .next/static 2>/dev/null || true)
-n "$H" && hit "🚨 构建产物中疑似泄漏服务端密钥" "$H"
fi
{ echo "---"; echo "**总命中:$VIOL**"; } >> "$OUT"
echo "反作弊扫描完成,命中 $VIOL 条,详见 $OUT"
"$VIOL" -gt 0 && exit 1
exit 0scripts/coverage-baseline.json(P0 就建好,之后 ⛔ 不许改)
{ "lines": 55, "branches": 50, "functions": 55, "statements": 55 }#6.4 scripts/gate.sh(阶段闸门)
#!/usr/bin/env bash
# 阶段闸门:跑完全绿才允许进入下一阶段。用法:bash scripts/gate.sh P2
set -uo pipefail
cd "$(git rev-parse --show-toplevel)"
PHASE="${1:-unknown}"
FAIL=0
run () {
echo "▶ $1"
if eval "$2" > /tmp/bt_gate.log 2>&1; then
echo " [PASS] $1"
else
echo " [FAIL] $1"
tail -30 /tmp/bt_gate.log
FAIL=1
fi
}
run "类型检查" "npm run typecheck"
run "单元测试" "npx vitest run --project unit --passWithNoTests"
run "组件测试" "npx vitest run --project component --passWithNoTests"
run "集成测试" "npx vitest run --project integration --passWithNoTests"
run "反作弊扫描" "bash scripts/anti-cheat-check.sh"
run "生产构建" "npm run build"
echo "===== 闸门 $PHASE 结果:$( $FAIL -eq 0 && echo 通过 || echo 未通过) ====="
exit $FAIL#7. 进度落盘
每完成一条 AC(GREEN 或 BLOCKED)立刻往 docs/night/progress.log 追加一行(⛔ 只许追加,不许改历史行):
2026-07-31T00:41:02+08:00 | AC-1.1.1 | RED | a1b2c3d | 密码门重定向失败测试已提交
2026-07-31T00:52:18+08:00 | AC-1.1.1 | GREEN | e4f5g6h | proxy.ts 生效,vitest 1 passed
2026-07-31T03:33:47+08:00 | AC-2.2.5 | BLOCKED | h7i8j9k | 扫描件检测阈值不稳,见 blockers #3ac-matrix.md 由这个文件生成,⛔ 不许凭记忆手写。
#8. 卡死降级规则(再说一遍,因为最容易被忽略)
同一条 AC 修改实现后连续 3 次仍红 → 走 6.2 的 blocked 流程,换下一条
单条 AC 投入超过 25 分钟 → 同上
同一个根因连续 block 掉 3 条 AC → 整个模块标 blocked,跳到别的模块
一个阶段超预算 50% → 按 00-总纲 §3.2 的优先级砍功能,进下一阶段⛔ 绝对不要在一个问题上死磕到天亮。 半个诚实的项目 > 一个卡死在第二阶段的完美开头。
来源:沉淀/03-项目方案与交接/接棒-通宵施工包-20260731/02-TDD规程与反作弊.md(整理于 2026-08-18)