Day 25|第一次修 Bug,才發現原本的測試也錯了
前言
Day 24 放行之後,單開出去了。但有些 bug 不必等人排進 sprint,它自己就修得掉。今天讓它先重現、用先紅後綠的回歸測試釘住行為、最小幅度改產品碼,最後開一張 PR。開,但絕不自行合併。
開始之前
讓測試變綠最快的方法,是把測試改爛。刪掉那支測試、加個 .skip、把 toBe 放寬成 toBeTruthy,CI 立刻一片綠,PR 看起來很漂亮。
危險在於它跟真的修好長得一模一樣。報告上都是綠的,差別要打開 diff 才看得出來,而 diff 沒人逐行看。
所以修 bug 的順序不是流程建議,是防作弊機制。先重現,確定問題真的存在;再寫一支現在就會紅的測試,把「錯的行為」釘成一個看得見的失敗;然後才改碼,讓它轉綠。
這個順序給你兩個保證。沒紅過的測試證明不了 bug 存在,它可能從頭到尾都在測別的東西;沒轉綠的修復證明不了修好了。兩段缺一段,這次修復就不算數。
還有一條邊界:這個流程最多做到開 PR。 合併會改動共用分支,所以這次不把 merge 的權限交給代理人,最後由人 review 後決定。
這一篇一度沒有實跑紀錄,原因值得寫下來。
先交代一下時間:這個系列按學習順序安排,實驗沒有照發文順序做。今天會用到 8/10 補跑的自架環境;後天和大後天的 CI 案例,則是 8/2 還在測公開站時留下的紀錄。
本專案的受測對象是外部託管的 demo build,本機沒有它的產品碼 repo。修復這條線的前提是「產品 repo 可存取、測試可在本地跑」,這個前提不成立。2026-07-30 那輪值班的 run log 如實記著:
deviations:
- item: "bug-fixer 未執行"
why: >
受測對象是外部託管的 demo build,本機沒有它的產品碼 repo,無從重現→釘測試→改碼→開 PR。
duty-oncall 的「範圍清楚可修者交 bug-fixer」這一步在本專案目前條件下不成立,prs_opened 記 0。prs_opened: 0,不是留白、不是「待補」。跑不了就記跑不了,比補一份假的示範有價值。
要讓這一步真的跑起來,缺的東西很具體:一份自架的受測產品、一個本機可改的 repo、以及 gh auth status 已登入。三項補齊之後,8/10 補跑了一輪,開出一張 PR。
7/30 那筆 run log 不改。 prs_opened: 0 是那一輪的事實,補跑另記一筆 output/runs/2026-08-10.yaml。把舊紀錄改成好看的樣子,等於讓「如實記錄」這件事本身失效。
把 Toolshop 架在本機
Toolshop 的原始碼是公開的,而且附 docker compose。
git clone https://github.com/testsmith-io/practice-software-testing
cd practice-software-testing
docker compose up -d起來之後:
| 服務 | 位址 |
|---|---|
| 前端 | http://localhost:4200 |
| API | http://localhost:8091 |
| Swagger | http://localhost:8091/api/documentation |
| PHPMyAdmin | http://localhost:8000 |
| MailCatcher | http://localhost:1080 |
資料庫重置與塞測資:
docker exec -it pst-laravel-api-1 php artisan migrate:fresh --seed預設帳號是 [email protected] 配 welcome01,另外有 admin 與兩個顧客帳號。
這次補跑使用本機的 sprint5-with-bugs 版本,API 指向 localhost。要切換本機的乾淨版與植入 bug 版,則透過 .env 的 SPRINT 設定選擇。後面 CI 篇仍會拿先前測公開站的結果來說明,別把兩批實驗混成同一次。
架起來一次解掉四件事:
- 修復那條線的前提成立了。 本機有產品碼,才重現得了、才釘得住測試、才改得動、才開得了 PR。
- Cloudflare 那個坑直接消失。 Day 27 到 28 那次
clean紅兩次,根因是公開站的 bot 防護,自架之後不再有這個變因。 migrate:fresh --seed讓測資可以整份重置。 打公開站的時候,測資是所有人共用的,你動不得也清不掉。- API 有 Swagger。 契約 schema 那條 oracle 從「猜回應長什麼樣」變成「拿文件逐欄位比」。
授權要看清楚。這個專案的授權明訂:未經書面許可,不得商業使用、不得公開託管、不得再散布、不得拿來做第三方服務。本機開發與測試沒問題,架到公開網址給別人用不行。
文件沒寫的三個坑
docker compose up -d 之後不會直接可用,三個坑都得自己踩。
一、API 回 500,vendor/ 是空的。 compose 沒有自動裝依賴,要另外叫那個 composer service:
docker compose run --rm composer二、angular-ui 起不來,log 是 Cannot find module '/app/set-version.js'。那支檔案只存在於 sprint5,sprint5-with-bugs 沒有。這時候的選擇很關鍵:補一支 set-version.js 進去,等於動了待測物,之後任何異常都要先排除「是不是我加的那支造成的」。改成繞過它、直接跑 dev server:
docker compose run -d --rm --service-ports --name pst-ui-dev angular-ui \
bash -c "ng serve --host 0.0.0.0 --port 4200"三、PHPUnit 報 Class "Tests\TestCase" not found。 compose 裡那行是 composer install --no-dev --optimize-autoloader,autoload-dev 的 Tests\ 命名空間根本沒進 autoloader。重跑一次帶 dev 的就好。
三個坑的共同點:它們都不是產品的 bug,是環境的。分不出來的話,第 1 步的重現就會建立在錯的地基上。
動手試試
第 1 步:重現
用顧客帳號(id=2)登入拿 token,直接讀 admin 的那筆:
GET /users/1 → HTTP 200
{"id":1,"first_name":"John","email":"[email protected]",
"address":"Test street 123","city":"Utrecht",
"password":"9e2ed9cb4bf54a6b9dc4669a…"}換任何 id 都讀得到,而且回傳裡有 email 與資料庫存的 password hash。重現成立,可以往下走。
第 2 步:紅測試,然後撞到本篇的主題
要在 tests/Feature/UserTest.php 加一支會紅的測試,打開檔案卻發現它已經有一支:
public function testNonAdminUserCanRetrieveOtherUserInfo()
{
$otherUser = User::factory()->create();
$response = $this->getJson("/users/{$otherUser->id}", $this->headers($this->user));
$response->assertStatus(ResponseAlias::HTTP_OK);
}Can,斷言 200。這支測試把漏洞寫成了規格。修之前把整套跑過一次,113 支全綠,因為它們一致同意「任何人都能讀任何人的資料」是對的行為。
它是「改斷言去遷就產品的錯誤行為」的成品,只是這次不是我改的,它本來就長這樣。後果一樣:測試在替 bug 背書。
於是修這顆 bug 之前,得先修正這支寫錯的測試。不過,不能因為我覺得它錯,就把預期值改掉。這次拿來對照的是同一個 repo 的乾淨版 sprint5:它有一支測同樣行為的測試,產品碼也做了對應的權限檢查。
test('non admin user cannot retrieve other user info', function () {
$response = $this->getJson("/users/{$otherUser->id}", $this->headers($this->user));
$response->assertStatus(ResponseAlias::HTTP_NOT_FOUND);
});
Cannot,斷言 404。兩份 build 對同一條規格的說法互相矛盾,而乾淨版那份才是規格。所以改斷言的方向不是放寬到讓產品過得去,是對回上游的預期,而且是收緊:200 改成 404,再加兩條「回應裡不得出現他人的 email 與 password hash」。
判準仍是那一句,舊行為下重現紅、新行為下轉綠。跑一次,紅:
1) tests\Feature\UserTest::testNonAdminUserCannotRetrieveOtherUserInfo
Expected response status code [404] but received 200.紅在對的地方。不是 route 找不到、不是 token 過期,就是這顆 bug。
第 3 步:最小幅度改產品碼
根因在 UserController::show(),它只拿路徑參數查資料:
public function show($id)
{
Log::info('Show method called to retrieve user', ['user_id' => $id]);
$user = User::findOrFail($id);
return $this->preferredFormat($user);
}route 上的 auth middleware 只確認「你是登入使用者」,沒有確認「你有權讀這一筆」。乾淨版的做法是把檢查放在 UserService::getUserById(),條件是 role === "admin" || currentUserId == id。
但 sprint5-with-bugs 這份 build 根本沒有 UserService。照抄乾淨版,等於為了一個端點引進整個 service 層。最小幅度的意思是補檢查,不是補架構,所以檢查就寫在 controller 裡,十行:
$currentUser = Auth::user();
if ($currentUser->role != "admin" && $currentUser->id != $id) {
Log::warning('Unauthorized access to user info', [
'user_id' => $id, 'requested_by' => $currentUser->id,
]);
return response()->json(['error' => 'User not found.'], ResponseAlias::HTTP_NOT_FOUND);
}回 404 而不是 403,也是跟著上游。403 等於承認「這個 id 存在,只是你不能看」,404 什麼都不承認。
跑完之後
第 4 步:轉綠
| 範圍 | 結果 |
|---|---|
| 目標三支(讀自己/讀他人/admin 讀他人) | OK, 3 tests, 7 assertions |
UserTest.php 全檔 |
OK, 28 tests, 83 assertions |
| 整套 API 測試 | OK, 113 tests |
最後那一列本來想寫成「113 tests, 592 assertions」,抄那一次的輸出就好。幸好多跑了幾次:113 支穩定全過,斷言數卻在 584 到 594 之間跳。追下去是 ReportTest 那支自己在浮動(249、251 都出現過),跟這次的改動無關,UserTest 一直是 83。
差點就把一個會自己變的數字,當成「修好了」的證據寫進 PR。報告上的數字要先確認它穩定,才有資格當證據,這件事跟只跑一次就宣布綠燈是同一個毛病。
單元測試跑在 in-memory sqlite,跟真環境還是兩回事,所以回頭再打一次真的 API:
| 情境 | 修復前 | 修復後 |
|---|---|---|
顧客讀 /users/1(他人) |
200,完整個資 | 404 |
顧客讀 /users/2(自己) |
200 | 200 |
admin 讀 /users/2 |
200 | 200 |
後兩列跟第一列一樣重要,它們守住「不要修過頭」。只看第一列變 404,把整個端點鎖死也會通過。
第 5、6 步:開 PR,然後停
title 與 diff 摘要先列給人確認,確認過才推。PR 開在自己 fork 的 main,不往上游送,因為這顆 bug 是人家刻意種在教材裡的。
PR body 裡花最多篇幅的不是修法,是「我改了既有測試」那一節:原本的斷言錯在哪、上游的 oracle 是什麼、為什麼這是收緊不是放寬。改測試這件事一定要自己先講,等 reviewer 從 diff 裡發現,信任就沒了。

Open、+14 −2、Files changed 2、No reviews。這張圖的重點是它停在這個狀態,右邊那顆 merge 按鈕不歸 agent 按。
最後在 issue 留言把 PR 連過去,issue 保持 OPEN,label 不動。PR 沒被人 review 過,不該關。
一個沒預料到的副作用
裝依賴那道 chmod -R 777 把 162 個檔的 mode 從 644 翻成 755,git status 一片 modified,真正改到的只有 2 個檔。
處理方式不能是「順手把權限改回去」,那會弄壞容器要寫的目錄。改成設 repo 本地的 core.fileMode=false,讓 git 忽略權限位元,磁碟不動。
這件事本身就是第 3 步的考題。PR 裡多帶 162 個無關檔案,reviewer 看到的就只剩雜訊,而「不動無關檔案」這條,多數時候不是靠自制,是靠發現自己什麼時候動到了。
背後怎麼做
六個步驟,不得倒序
| # | 步驟 | 卡關時怎麼辦 |
|---|---|---|
| 1 | 照 Issue 的重現步驟自己跑一次 | 跑不出來就退回,不動手,在 Issue 留言說明 |
| 2 | 加一支現在就會紅的回歸測試 | 跑一次確認它紅,而且紅的原因就是這顆 bug |
| 3 | 最小幅度改產品碼 | 不順手重構、不動無關檔案 |
| 4 | 新測試轉綠,且既有測試沒被弄壞 | 弄壞了就回第 3 步 |
| 5 | 列出 title 與 diff 摘要給人確認,才開 PR | 對外副作用,一律先確認 |
| 6 | 回報 PR URL 與一行摘要 | 附測試檔、改了哪些產品檔、先紅後綠的證明 |
第 2 步的測試要驗使用者看到的行為,不是內部實作。理由跟 Day 14 寫 Bug Report 是同一條:綁實作的東西撐不過一次重構,綁行為的撐得過。
另外有一條範圍限制:影響面不明的只報不修。 元件狀態糾纏、改一處會牽動三處的那種,交回人判,不要因為看得懂就動手。
綠色作弊:禁項清單
這份清單一條都不准踩。
| 手法 | 它其實做了什麼 |
|---|---|
刪測試、加 .skip 或
.fixme、註解掉斷言 |
把覆蓋拿掉,當成問題解決了 |
| 放寬或刪弱斷言 | 讓測試對錯誤行為也點頭 |
加 retry 或 waitForTimeout 蓋掉時序問題 |
把 flake 率藏起來,不是降下來 |
| mock 掉真正要驗的呼叫 | 驗的變成 mock 自己,不是產品 |
| 改斷言去遷就產品的錯誤行為 | 把 bug 寫進規格 |
| 一直重跑到剛好過一次 | 拿重跑蓋掉 flaky |
改斷言之前,要先說清楚是哪一種情況。規格真的改了,經 oracle 確認後,測試要跟上新規格;如果像今天這樣,原本的測試就把錯誤行為當成正確答案,則要拿出其他依據,先把測試修正,再修產品。
今天這顆越權讀取資料的 bug,屬於後者。乾淨版的測試與產品權限檢查是對照依據;我們把預期值改成 404,先看到它在舊產品碼上失敗,補上權限檢查後才通過。不能把這件事說成「產品合法變更了」,更不能只為了讓畫面變綠就改答案。
配套的 green-cheating.md 當時只把「規格合法變更」列為例外,沒有完整說明今天這種「原測試就寫錯」的情況。這是案例與規則說明的缺口,跟著做時要先釐清,不能自行當成已獲准改斷言。
判準也只有一句:修完的測試要能在舊行為下重現紅、在新行為下轉綠。只綠不紅代表斷言可能已經失效,那不是修好,是改爛,退回重修。
最後還有授權範圍:merge_pr 放在 forbidden,跟 reset_shared_env、truncate_shared_db 同一層,代理人不得執行。這裡寫的是它必須遵守的規則;工具是否真的封鎖這些動作,還要看執行環境的權限,不能只憑一份 YAML 就說它「叫不動」。
今天學到什麼
今天補跑了一次真正的修復:先重現越權讀取的問題,修正原本寫錯的測試,看到它失敗,再補上產品的權限檢查,確認該擋的擋住、正常操作也沒被弄壞。最後開出 PR,停在等人 review 的地方。
最有意思的是,修之前整套測試就已經全綠了,因為其中一支把漏洞當成正確行為。綠燈要配著判斷依據看;需要改既有測試時,也得把為什麼改、憑什麼改寫進 PR,讓接手的人看得懂。
今天是先有 bug,再補測試抓住它。明天換個方向:從探索過的流程裡挑一段值得持續檢查的行為,把它寫成日後每次都能跑的自動化測試。
參考資料
- Kent Beck(2002)《Test-Driven Development: By Example》 - 先紅後綠的原始出處,本篇把它從開發手法轉用成修復的防作弊機制
- Richard A. DeMillo、Richard J. Lipton、Frederick G. Sayward(1978)《Hints on Test Data Selection: Help for the Practicing Programmer》,IEEE Computer 11(4) - 突變測試:故意改壞程式,看測試會不會紅,正是「只綠不紅代表斷言已失效」的方法論根據
- Laura Inozemtseva、Reid Holmes(2014)《Coverage Is Not Strongly Correlated with Test Suite Effectiveness》,ICSE 2014 - 覆蓋率高不代表測得好,說明為什麼綠燈與數字都不能當成修好的證明
- Marilyn Strathern(1997)《'Improving Ratings': Audit in the British University System》,European Review 5(3) - Goodhart 定律:指標一旦變成目標就失去意義,綠色作弊是它在測試上的具體形態
- testsmith-io/practice-software-testing - Toolshop 兩個版本的原始碼與 docker compose,自架的入口;授權限制見 repo 的 LICENSE
- 本專案
references/green-cheating.md- 禁項清單與唯一例外 - 本專案
skills/agents/bug-fixer/SKILL.md- 六步與鐵則 - 本專案
config/governance.yaml-merge_pr在 forbidden - 本專案
output/runs/2026-07-30.yaml- 原本記著prs_opened: 0與跳過 bug-fixer 的理由,補跑後仍然不動 - OWASP API Security Top 10:API1:2023 Broken Object Level Authorization - 本篇補跑修的那顆 bug 的標準分類,也是「每個物件層存取都要驗、預設拒絕」的出處
- 本篇補跑開出的 PR - 完整 diff、先紅後綠的原始輸出,以及「我改了既有測試」那一節實際怎麼寫給 reviewer 看