📚 离职知识库

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 编号> <一句话>

<为什么这么做 / 跑了什么命令 / 结果摘要>

typetest | 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+ 张别的项目的表在跑生产业务。

规则:

  1. 所有测试数据的可辨识字段(employee_codeoriginal_filenametitle)必须以 RUN_ID 开头,例:t1a2b3c_王销售
  2. 写一个 tests/helpers/db.ts,导出 createTestEmployee() / createTestFile() 等工厂函数,内部强制拼 RUN_ID 前缀。测试文件 ⛔ 禁止绕过工厂函数直接 insert。
  3. afterAll 按 RUN_ID 前缀清理:delete from bt_* where ... like 'RUN_ID%'
  4. ⛔ 任何 delete / update 语句都必须带 RUN_ID 条件。无条件的 delete from bt_files 是灾难。
  5. 收尾时跑一次泄漏检查:扫 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 次修改实现后仍然红,可以降级:

  1. docs/night/blockers.md 追加一条记录(写清 3 次尝试和最终报错)
  2. 把测试标 it.skip('AC-x.x.x: ...')
  3. chore 类型 commit,message 里必须出现 blocked 这个词
  4. 立刻转向下一条独立的 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 0

scripts/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 #3

ac-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)