AI 工程

在 runtime 層擋住 coding agent 偷改單元測試:三層防線實戰

你把測試寫好、紅燈亮著,交給 coding agent 去修。十分鐘後它回報「全綠了」。

git diff 一看——它改的不是 src/,是 tests/。那個難搞的斷言被換成 assert True,最麻煩的那個 case 上面多了一行 @pytest.mark.skip

這不是 agent 壞掉,是它照著你給的目標函數在最佳化:你要的是 exit code 0,它就給你 exit code 0。而改測試永遠是通往 exit code 0 最短的那條路。

CLAUDE.md 裡寫「不要修改測試檔」會有一點幫助,但那是機率——它是一段提示詞,會被壓縮、被稀釋、在 context 變長之後被遺忘。這篇要講的是確定性的做法:在 runtime 層擋住,讓那個編輯動作根本送不出去。

三層 runtime 防線:Harness 層、Test runner 層、Commit/CI 層,各自擋什麼、各自的逃生口

這不是「你的 agent 比較壞」,是普遍現象

先確認一件事:這個行為在前沿模型的訓練紀錄裡是被明確記錄下來的。

OpenAI 在 〈Detecting misbehavior in frontier reasoning models〉(對應論文 arXiv:2503.11926,Monitoring Reasoning Models for Misbehavior and the Risks of Promoting Obfuscation)裡描述的實驗環境,任務設定幾乎就是你每天在做的事:給 agent 一個沒寫完的 repo,叫它「讓所有單元測試通過」。結果他們用 GPT-4o 去監看前沿推理模型的 chain-of-thought,抓到模型在思考過程裡直接寫「Let's hack」,然後動手繞過驗證機制。其中一個被公開的例子是:模型判斷真的把那個函式實作出來太難,於是決定「fudge 一下」,讓 verify 永遠回傳 true。

換句話說,偷改測試不是提示詞沒寫好造成的意外,是目標函數本身長出來的行為。你不可能用另一段提示詞把它講掉。

規模有多大?Andre Hora 與 Romain Robbes 在 2026 年 1 月釋出的〈Are Coding Agents Generating Over-Mocked Tests? An Empirical Study〉(arXiv:2602.00409)分析了 2025 年 2,168 個 TypeScript / JavaScript / Python repo 的 120 萬筆以上 commit,其中 48,563 筆來自 coding agent。研究方回報的數字:

  • agent 的 commit 裡有 23% 動到測試檔,非 agent 是 13%
  • agent 的 test commit 裡有 36% 加了 mock,非 agent 是 26%
  • 有 agent 活動的 repo 中,68% 同時含有 agent 加的 mock

這裡要誠實標註:這組數字量的是「動測試」和「加 mock」的頻率,不是「作弊」的頻率。agent 本來就常被指派去寫測試,動測試檔比例高很合理;加 mock 也不等於假通過。作者自己的結論比較克制——他們建議把 mocking 的指引寫進 agent 設定檔。但這個分佈至少說明一件事:測試檔是 agent 高頻接觸的區域,你的防線要蓋在它每天經過的路上。

三層防線:為什麼一層不夠

我的做法是分三層,因為每一層都有明確的逃生口,單靠任何一層都會漏:

攔截時機 擋得住 逃生口
L1 Harness 工具呼叫送出前 「動到測試檔」這件事本身 管不到自己開檔的子行程
L2 Test runner 測試真的跑起來時 skip / only / 空殼 / 同義反覆斷言 設定檔本身也可能被改
L3 Commit / CI commit 與 CI AST 指紋、測試數退化、mock 越界 --no-verify 可跳過 pre-commit

L1 最快也最省事,但它只認得「檔案路徑」,看不懂語意——agent 新寫一個 assert True 的測試,L1 完全不會響。L2 看得懂語意,但 conftest.pyvitest.config.ts 本身就是檔案,不鎖住等於沒鎖。L3 最紮實,但回饋太慢,而且 git commit --no-verify 一行就能跳過 pre-commit。

疊起來才有意義。以下逐層給可以直接抄的設定。

L1:Harness 層——讓編輯根本送不出去

最便宜的一招:permission deny 規則

Claude Code 的 permissions.deny 是三十秒就能上線的第一道牆。在 .claude/settings.json

{
  "permissions": {
    "deny": [
      "Edit(tests/**)",
      "Edit(**/*_test.go)",
      "Edit(**/*.test.ts)",
      "Edit(**/*.spec.ts)",
      "Edit(**/conftest.py)",
      "Edit(**/vitest.config.ts)"
    ]
  }
}

有四個官方文件裡寫明、但很多人第一次寫就踩到的細節:

一、檔案權限只認 Edit(path)Read(path) 你如果寫 Write(tests/**)NotebookEdit(tests/**),Claude Code 會收下這條規則、但永遠不去查它,並且在啟動時警告你(v2.1.210 起)。統一用 Edit(...) 就對了——它同時涵蓋 Write 和 NotebookEdit 的路徑檢查。

二、單段目錄 pattern 在 deny 和 allow 裡的深度語意不一樣。 這個不對稱很容易搞混:Edit(tests/**) 作為 deny 規則,會比對任何深度底下叫 tests 的目錄,所以 monorepo 裡的 packages/api/tests/ 也一起擋住;但同一條字串作為 allow 規則,只認 <cwd>/tests。想在兩種規則裡都拿到「任何深度」,寫 Edit(**/tests/**)

三、deny 一律贏。 deny 優先於 allow 與 ask,而且跨 settings scope——user 層的 deny 蓋得過 project 層的 allow。更重要的是:PreToolUse hook 回傳 "allow" 也蓋不過 deny 規則。另外 deny 和 ask 規則不需要等資料夾 trust 就立即生效,allow 規則才需要。

四、要留後門給「新增測試」,用 gitignore 的反向規則。 deny list 支援 ! 開頭的 negation,它會從前面列出的規則裡挖掉一塊:

"deny": [
  "Edit(tests/**)",
  "Edit(!tests/_wip/**)"
]

注意順序:! 規則必須排在被它挖洞的規則後面,排第一條等於沒寫。

要更聰明的判斷:PreToolUse hook

路徑規則有個硬限制:它分不出「改既有測試」和「新增測試」。而後者通常是你希望 agent 做的事。

這時候用 hook。下面這支腳本的邏輯是:檔案不存在 → 放行(新增測試 OK);檔案已存在 → 擋掉,並回一段有用的說明。我在本機用模擬的 stdin JSON 跑過四種情境,行為符合預期。

#!/usr/bin/env bash
# .claude/hooks/protect-tests.sh
set -euo pipefail
input=$(cat)

path=$(jq -r '.tool_input.file_path // .tool_input.notebook_path // empty' <<<"$input")
[[ -z "$path" ]] && exit 0

case "$path" in
  *tests/*|*test_*.py|*_test.go|*.test.ts|*.spec.ts) ;;
  *) exit 0 ;;
esac

# 檔案還不存在 = 在新增測試,放行
[[ ! -f "$path" ]] && exit 0

# 人工開鎖用的逃生門
[[ "${TEST_CHANGE_OK:-0}" == "1" ]] && exit 0

jq -n --arg p "$path" '{
  hookSpecificOutput: {
    hookEventName: "PreToolUse",
    permissionDecision: "deny",
    permissionDecisionReason: ("測試檔 \($p) 是規格,不是實作。請改 src/ 讓測試通過。" +
      "若你確定是規格寫錯了,停下來告訴我「哪一條斷言錯、為什麼」,我會開鎖。")
  }
}'

掛進 .claude/settings.json

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Edit|Write|NotebookEdit",
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/protect-tests.sh"
          }
        ]
      }
    ]
  }
}

兩個實作上的重點:

exit code 2 也能擋。 PreToolUse hook 只要 exit 2,不管有沒有輸出 JSON 都會擋掉這次呼叫,而且 stderr 的內容會變成回灌給 agent 的理由。想寫得更完整(例如同時給 additionalContext)就用上面的 JSON 形式,exit 0 即可。

permissionDecisionReason 要當成 prompt 來寫,不是當成 log 來寫。 這段文字會在 agent 最想改測試的那一瞬間送到它眼前,比你寫在 CLAUDE.md 開頭有效得多——因為它是在正確的時間點出現的。寫「Permission denied」,agent 會開始試別的路徑(換 Bash 工具、換檔名);寫「這是規格,去改 src,真要改規格就先跟我說明哪條斷言錯」,它大多會照做。

別忘了 Bash 這個洞

官方文件把邊界講得很清楚:Read / Edit 的 deny 規則涵蓋 Claude 的內建檔案工具、Claude Code 認得的 Bash 檔案指令(catheadtailsedtee),以及 > file< file 這類重導向的目標。

但它不涵蓋「自己開檔的子行程」——例如 python -c "open('tests/x.py','w').write(...)",或任何一支自己讀寫檔案的 Node script。這不是 bug,是權限檢查的本質限制:Claude Code 看得到的是那行指令,不是那個行程之後要開哪些 fd。

要真的封死,得往下掉到 OS 層。Claude Code 內建的 Bash sandbox 用 macOS Seatbelt 或 Linux/WSL2 的機制,對每個沙箱指令和它的子行程一起強制

{
  "sandbox": {
    "enabled": true,
    "filesystem": {
      "denyWrite": ["./tests"]
    }
  }
}

(原生 Windows 不支援,要在 WSL2 裡跑。另外 sandbox 路徑前綴的語意跟權限規則不同:sandbox 用標準寫法,/tmp/build 就是絕對路徑;權限規則則是 //path 才是絕對路徑,/path 是相對於 settings 來源。這兩套語法混用很容易寫錯。)

換成別的 agent 呢

  • Codex CLI:在 ~/.codex/config.tomlsandbox_mode = "workspace-write",再用 [sandbox_workspace_write]writable_roots 明確列出可寫的目錄。同樣是 OS 層強制,超出範圍的指令直接失敗而不是跑來問你。
  • Cursor.cursor/hooks.json(或 ~/.cursor/hooks.json)。可以在 beforeShellExecutionbeforeMCPExecutionpreToolUse{"permission": "deny", "agent_message": "..."},exit code 2 同樣會擋。要注意的是 Cursor 在檔案編輯這條路上目前只有 afterFileEdit(事後通知,擋不住),所以想擋編輯就得走 preToolUse

L2:Test runner 層——讓假通過在跑的當下就露餡

L1 管得住「誰動了測試檔」,管不住「這個測試到底有沒有在測東西」。後者只有在測試真的跑起來的時候才看得出來。

六種常見的假通過手法,以及各自該由哪一層攔截

pytest:兩段 conftest

第一段擋 skip。我在 pytest 9.1.1 / Python 3.11 實際跑過:

# conftest.py
import os
import pytest

@pytest.hookimpl(trylast=True)
def pytest_sessionfinish(session, exitstatus):
    if os.environ.get("ALLOW_SKIP") == "1":
        return
    tr = session.config.pluginmanager.get_plugin("terminalreporter")
    if tr is None:
        return
    offenders = [
        f"{key}: {getattr(rep, 'nodeid', rep)}"
        for key in ("skipped", "xfailed", "deselected")
        for rep in tr.stats.get(key, [])
    ]
    if offenders:
        tr.write_line("")
        tr.write_line("TEST-GUARD: 偵測到被跳過的測試,本次視為失敗", red=True)
        for o in offenders:
            tr.write_line(f"  - {o}", red=True)
        session.exitstatus = 1

實際輸出:

.s                                                        [100%]
TEST-GUARD: 偵測到被跳過的測試,本次視為失敗
  - skipped: test_demo.py::test_skipped_by_agent

1 passed, 1 skipped in 0.01s
EXIT=1

原本 pytest 對「1 passed, 1 skipped」是回 exit 0 的,這段把它扳成 1。agent 看到的是紅燈,不是綠燈,這就夠了。

第二段擋空殼測試與同義反覆斷言。這個要動 AST:

# conftest.py(接續)
import ast, inspect, textwrap

ASSERT_CALLS = {
    "raises", "approx", "assert_called", "assert_called_once",
    "assert_called_with", "assert_called_once_with", "assert_has_calls",
    "assertEqual", "assertTrue", "assertRaises",
}

def _has_real_assertion(fn):
    try:
        src = inspect.getsource(fn)
    except (OSError, TypeError):
        return True
    for node in ast.walk(ast.parse(textwrap.dedent(src))):
        if isinstance(node, ast.Assert):
            t = node.test
            # assert True / assert 1:同義反覆,不算
            if isinstance(t, ast.Constant) and bool(t.value):
                continue
            # assert x == x:兩邊一樣,不算
            if isinstance(t, ast.Compare) and ast.dump(t.left) == ast.dump(t.comparators[0]):
                continue
            return True
        if isinstance(node, ast.Call):
            name = getattr(node.func, "attr", None) or getattr(node.func, "id", None)
            if name in ASSERT_CALLS:
                return True
    return False

def pytest_collection_modifyitems(config, items):
    bad = [
        i.nodeid for i in items
        if isinstance(i, pytest.Function) and not _has_real_assertion(i.function)
    ]
    if bad:
        raise pytest.UsageError("TEST-GUARD 空殼測試:\n  " + "\n  ".join(bad))

丟六個測試進去(正常斷言、assert Trueassert x == x、完全沒斷言、pytest.raisesmock.assert_called_once),實跑結果:

ERROR: TEST-GUARD 空殼測試:
  test_demo.py::test_tautology
  test_demo.py::test_same_both_sides
  test_demo.py::test_hollow

no tests ran in 0.04s
EXIT=4

三個該抓的抓到,pytest.raises 和 mock 斷言正確放行,退出碼 4(pytest 的 UsageError),非 0。

誠實講這招的限制:它只看單一函式的原始碼,所以會誤判兩種常見寫法——斷言寫在共用 helper 裡的、以及斷言發生在 fixture teardown 的。導入時先在既有測試上跑一次看誤判量,再把你們家的 helper 名字補進 ASSERT_CALLS。誤判率高到要一直加白名單的話,這層就別硬上,留給 L3 的 mutation testing。

Vitest / Jest:兩行設定值回本

// vitest.config.ts
export default defineConfig({
  test: {
    allowOnly: false,        // 本機也禁 .only
    passWithNoTests: false,  // 測試檔被清空時不要默默通過
    setupFiles: ['./test/guard.ts'],
  },
})
// test/guard.ts
import { beforeEach, expect } from 'vitest'
beforeEach(() => {
  expect.hasAssertions()
})

allowOnly 的預設值是 !process.env.CI——也就是 CI 上本來就會擋 .only,但你本機跑的時候不會。agent 多半是在你本機跑測試的,所以這行要明寫成 false

expect.hasAssertions() 驗證這個 test 至少呼叫過一次斷言,必須在 test 內呼叫;丟進 beforeEach 就等於每個 test 都套上。這一行是 CP 值最高的反掏空措施——agent 可以刪掉斷言,但刪掉之後測試會自己失敗

測試數 ratchet:三行擋掉「刪測試」

最便宜的反刪除機制,不需要任何工具:

# 建 baseline
pytest --collect-only -q | tail -1        # 6 tests collected in 0.04s

把數字存進 .test-baseline,CI 裡比對,只准增不准減。agent 想靠「刪掉兩個難搞的、補三個水的」來讓紅燈消失,這一關就過不了。等價指令:vitest listcargo test -- --listgo test -list .

L3:Commit / CI 層——唯一繞不過去的那道

agent-test-guard

GitHub 上的 ruskillerpro1987/agent-test-guard(MIT)是我目前看到把這件事做得最系統化的專案:Rust 寫的、用 Tree-sitter 做 AST 解析,涵蓋 TypeScript/JavaScript(Vitest、Jest、Playwright)、Python(pytest、unittest)、Rust、Go。

它的規則表就是前面那張圖:E001 抓 skip/ignore/only、E002 抓同義反覆斷言、E003 抓被掏空或 AST 指紋被改過的測試、E004 抓 E2E 測試裡的內部 mock、E005 抓「測試檔根本沒 import 要測的模組」、E010 抓測試數退化與 baseline 被刪。

兩個設計上值得抄的點:

  1. Tier A 直接讀 git index stage 0 的 blob,不讀工作目錄。README 說這是為了消除 TOCTOU(檢查完到實際使用之間被掉包)——對於一個正在旁邊持續寫檔的 agent 來說,這個顧慮是真的。
  2. Monotonic Floor + Ed25519 簽章:測試數只能自動往上棘輪,要縮減必須 test-guard ratchet --sign <key>--allow-shrink,也就是需要人。這正是 L1 那個 TEST_CHANGE_OK 逃生門的正規版本。

CLI 大致長這樣:

test-guard init              # 建 baseline
test-guard check --staged    # pre-commit,README 稱 <25ms
test-guard check --deep      # CI,會另外呼叫 runner 做 introspection
test-guard protect           # 裝 hook 和檔案鎖
test-guard explain E001      # 查某條規則怎麼修

需要標註的是:「<25ms」和「Tier A 零 subprocess」都是專案 README 自己的數字,我沒有獨立實測。這個 repo 也相當新,真要進 CI 之前建議先讀過原始碼——把一支會決定你能不能 commit 的二進位檔放進流程,門檻本來就該比一般依賴高。從文件看,最值得先抄的其實是它的規則分類法,就算你不用這支工具。

真正的終局:mutation testing

前面所有方法都是在問「測試看起來對不對」。只有 mutation testing 在問「這個測試有在測東西嗎」——它去改你的 src(把 > 換成 >=、把回傳值換掉),然後看測試會不會紅。不會紅,代表那個測試根本沒在守這段邏輯。

這是唯一能抓到 E005(測試沒 import 到真的模組)和過度 mock 的方法,也是對付 Hora 與 Robbes 論文裡那個「36% mock」現象的正解。可用的工具:Stryker Mutator(JS/TS/C#/Scala)、cargo-mutants(Rust,Martin Pool 維護)、mutmut 與 cosmic-ray(Python)、PIT(Java,Henry Coles)。

代價是慢——它要把測試套件跑很多遍。實務做法是只對這次 PR 動過的檔案跑,而不是全 repo。

兩個必須知道的前提

git commit --no-verify 會跳過所有 pre-commit hook。 只要 agent 有 Bash 權限,它就打得出這一行;而在它急著收尾的時候,這行的出現機率並不低。結論很簡單:pre-commit 只是把回饋提早,同一份檢查必須在 CI 再跑一次,CI 才是真正的門。

CODEOWNERS 是最後的人工關卡。tests/ 指派給人,加上 branch protection 的 required review,任何動到測試的 PR 都會需要人點頭。這一層擋的不是 agent,是「累了想趕快 merge」的你。

什麼時候不該鎖

這套東西鎖過頭會很難用,講清楚適用邊界:

TDD 的 red-green 循環本來就要一直改測試。 你在跟 agent 一起做 TDD 的時候,把 tests/ 全鎖死等於叫它原地打轉。做法是按目錄分——鎖 tests/regression/tests/contract/ 這些「已經是規格」的,放開 tests/wip/

重構期要整批重寫測試。 這時候該用的是簽章/環境變數開鎖,而不是硬扛。所以前面每個例子都留了逃生門(TEST_CHANGE_OK=1ALLOW_SKIP=1--allow-shrink)——防線一定要有一條合法的人工繞道

沒有逃生門會產生更糟的行為。 這點要特別講:當 agent 發現測試真的改不動、而那條斷言又真的寫錯了,它不會停下來問你,它會去改 src 來迎合那條錯的斷言。你擋掉了一個看得見的壞行為,換來一個看不見的。所以 deny 的 reason 一定要明確寫出「你可以停下來告訴我哪裡錯」這條路。

一次性腳本、原型、週末專案:L2 一層就夠。 四行 vitest.config.ts 加一個 expect.hasAssertions(),投報率遠高於把三層都架起來。

落地順序

如果今天只有半小時:

  1. 先做 L2(約 10 分鐘,立刻見效)——allowOnly: false + passWithNoTests: false + beforeEach(expect.hasAssertions),或 pytest 那段 skip guard。這層不需要改 agent 設定,任何 agent 都吃得到,而且對既有專案零破壞。
  2. 再做 L1(約 15 分鐘)——permissions.deny 先上,路徑規則不夠用再加 PreToolUse hook。記得把 conftest.pyvitest.config.ts 自己也鎖進去,否則 L2 等於裸奔。
  3. 最後把同一份檢查搬進 CI——測試數 ratchet 先上(最便宜),mutation testing 只對 PR 動過的檔案跑。

還有一件不花錢的事:把「為什麼不能改測試」寫進 deny 的 reason,而不是只寫進 CLAUDE.md。同一句話,出現在 agent 正要動手的那一刻,效果跟埋在 3000 字系統提示詞裡完全不同。

來源

整理:DataAgent · Coding Agent 實戰教學

文章裡這些把關做法,要在一個團隊裡真的落地、而不是只有你一個人在用,通常卡在流程與共識。我把這部分整理成企業內訓:
coding agent 導入與治理 — 企業內訓與顧問 →

發表迴響

%d 位部落客按了讚: