Day 25|第一次修 Bug,才發現原本的測試也錯了

分享
Day 25|第一次修 Bug,才發現原本的測試也錯了
Photo by Jacob McGowin / Unsplash

前言

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 篇仍會拿先前測公開站的結果來說明,別把兩批實驗混成同一次。

架起來一次解掉四件事:

  1. 修復那條線的前提成立了。 本機有產品碼,才重現得了、才釘得住測試、才改得動、才開得了 PR。
  2. Cloudflare 那個坑直接消失。 Day 27 到 28 那次 clean 紅兩次,根因是公開站的 bot 防護,自架之後不再有這個變因。
  3. migrate:fresh --seed 讓測資可以整份重置。 打公開站的時候,測資是所有人共用的,你動不得也清不掉。
  4. 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);
});
左右對照:with-bugs 那份的產品碼沒有授權檢查、實際回 200、測試斷言 HTTP_OK,整套 113 支仍然全綠;乾淨版的產品碼有 admin 或本人的檢查、回 404、測試斷言 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 裡發現,信任就沒了。

GitHub 上的 PR 頁面:狀態 Open、+14 −2、Files changed 2、標籤 bug 與 ready-for-review、Reviewers 顯示 No reviews,body 第一行是 This was generated by AI during bug-fixing.

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,再補測試抓住它。明天換個方向:從探索過的流程裡挑一段值得持續檢查的行為,把它寫成日後每次都能跑的自動化測試。


參考資料

  1. Kent Beck(2002)《Test-Driven Development: By Example》 - 先紅後綠的原始出處,本篇把它從開發手法轉用成修復的防作弊機制
  2. Richard A. DeMillo、Richard J. Lipton、Frederick G. Sayward(1978)《Hints on Test Data Selection: Help for the Practicing Programmer》,IEEE Computer 11(4) - 突變測試:故意改壞程式,看測試會不會紅,正是「只綠不紅代表斷言已失效」的方法論根據
  3. Laura Inozemtseva、Reid Holmes(2014)《Coverage Is Not Strongly Correlated with Test Suite Effectiveness》,ICSE 2014 - 覆蓋率高不代表測得好,說明為什麼綠燈與數字都不能當成修好的證明
  4. Marilyn Strathern(1997)《'Improving Ratings': Audit in the British University System》,European Review 5(3) - Goodhart 定律:指標一旦變成目標就失去意義,綠色作弊是它在測試上的具體形態
  5. testsmith-io/practice-software-testing - Toolshop 兩個版本的原始碼與 docker compose,自架的入口;授權限制見 repo 的 LICENSE
  6. 本專案 references/green-cheating.md - 禁項清單與唯一例外
  7. 本專案 skills/agents/bug-fixer/SKILL.md - 六步與鐵則
  8. 本專案 config/governance.yaml - merge_pr 在 forbidden
  9. 本專案 output/runs/2026-07-30.yaml - 原本記著 prs_opened: 0 與跳過 bug-fixer 的理由,補跑後仍然不動
  10. OWASP API Security Top 10:API1:2023 Broken Object Level Authorization - 本篇補跑修的那顆 bug 的標準分類,也是「每個物件層存取都要驗、預設拒絕」的出處
  11. 本篇補跑開出的 PR - 完整 diff、先紅後綠的原始輸出,以及「我改了既有測試」那一節實際怎麼寫給 reviewer 看