Appearance
第 25 天|第一次修程式,才發現原本的測試也錯了
前言
我們的新同事終於學會的尋找問題並且將問題貼上 Github issue,目前總共有六個問題,但因為我們
昨天,我們把問題查過一遍,符合條件的才交出去開單。既然有些問題的範圍已經清楚,產品程式也改得到,接下來就可以請新同事試著修。
不過,修好不能只看測試有沒有變綠。把測試刪掉,畫面也會很綠,只是問題還在。今天要用的 bug-fixer,也就是「缺陷修復」技能,會先重現問題,再讓測試抓住錯誤,最後才改程式。
這次還真的遇到一個麻煩:修復前,113 支測試全部通過,卻有一支把漏洞當成正確答案。新同事還沒開始修產品,就得先確認測試到底在替誰把關。
開始之前
既然要動手改,我們先確認三件事:問題單已通過開單檢查、產品程式在本機可存取,而且測試跑得起來。還要核對問題單和檢查結果的專案、輪次、問題編號,避免修到另一筆。
這裡比前幾天多了一個限制。當時我們測的是公開網站,本機沒有產品程式,所以 7 月 30 日那輪沒有執行修復,紀錄如實填了 prs_opened: 0。光拿著網站網址,新同事再有幹勁,也不能隔空改人家的程式。
直到 8 月 10 日,我們把 Toolshop 架在本機,才補跑這次「一般顧客能讀取別人資料」的修復。它是另一輪實驗,記在 output/runs/2026-08-10.yaml;7 月 30 日的紀錄不改。後面第 27、28 天的案例則來自 8 月 2 日的公開站測試,日期和章節順序不完全相同。
如果你已經有能修改、能跑測試的產品專案,可以直接往下看提示詞。要跟著本篇架 Toolshop,則先完成下面的準備。
本機環境怎麼準備?
Toolshop 提供原始碼和 Docker Compose 設定。以下是本篇使用的啟動方式,版本差異請對照專案說明。
bash
git clone https://github.com/testsmith-io/practice-software-testing
cd practice-software-testing這次使用含有刻意植入缺陷的 sprint5-with-bugs。先在 .env 把 SPRINT 設為這個值,再啟動:
bash
docker compose up -d啟動後,前端在 http://localhost:4200,程式介面在 http://localhost:8091,介面文件在 http://localhost:8091/api/documentation。另外還有資料庫管理頁 http://localhost:8000 和測試郵件頁 http://localhost:1080。
若要清空本機練習資料庫、重新建立測試資料,可以執行下面這行。它會刪除現有資料,請先確認連的是自己這套練習環境。
bash
docker exec -it pst-laravel-api-1 php artisan migrate:fresh --seed範例顧客帳號是 customer@practicesoftwaretesting.com,密碼是 welcome01。這次所有重現與修復都在本機進行,程式介面也指向本機。
架在本機後,我們能修改程式、重置測資,也能對照介面文件;前面測公開站時遇到的 Cloudflare 防護,就不會混進這輪結果。使用範圍仍要依專案授權:它允許私人、內部且非商業用途,商業使用、公開託管、再散布或提供第三方服務需先取得書面許可。
當時啟動還遇到三個環境問題。以下保留排查方式,若你碰到相同錯誤,再逐項處理:
| 現象 | 當時原因與處理方式 |
|---|---|
| 程式介面回傳 500 | vendor/ 是空的,另執行 docker compose run --rm composer 安裝依賴 |
前端找不到 set-version.js | 那支檔案只在 sprint5,當時改成直接啟動開發伺服器,沒有往待測版本補檔 |
PHPUnit 找不到 Tests\TestCase | 安裝時用了 --no-dev,測試所需的開發依賴與自動載入設定未備齊,重新安裝包含開發依賴的版本 |
前端的替代啟動指令如下:
bash
docker compose run -d --rm --service-ports --name pst-ui-dev angular-ui \
bash -c "ng serve --host 0.0.0.0 --port 4200"先排除這些環境問題,再開始重現產品缺陷,否則修了半天,可能只是在修自己的測試環境。
動手試試
環境準備好後,我們回到負責安排開單與修復的對話,把問題交給助手。和前兩天一樣,可以直接貼這段提示詞:
text
用 bug-fixer 修剛才通過開單檢查的那張問題單。
先重現,再用會失敗的回歸測試抓住問題,接著做最小修改並驗證。
依現有授權處理;需要我確認的動作,先列出具體變更與目標。
完成後交回先失敗、後通過的測試證據,以及合併請求連結,不要合併。
若無法建立合併請求,就交回本地修改、驗證結果和原因。這段貼在助手的對話輸入框。它會依技能規則核對資料與權限,我們不必把整套修復流程再教一次。不過,助手得知道要修哪張單、產品程式放在哪裡;如果換了對話,就要把位置補上。
下面是具體路徑的交接示例,並非本篇實跑檔案。假設 sdet-skills 和產品專案放在同一個上層目錄,所有相對路徑都從 sdet-skills/ 算起。換你操作時,把位置改成自己的即可:
text
請用 bug-fixer 修這筆問題,只處理一般顧客讀取他人資料的權限缺口。
測試工作目錄:sdet-skills/
專案:toolshop
輪次:20260810_user-access
問題編號:F-001
問題單:output/issues/017-user-access.md
開單檢查結果:output/sessions/20260810_user-access/gate.yaml
證據清單:output/evidence/20260810-user-access/manifest.md
產品程式目錄:../practice-software-testing/
先確認這筆問題通過檢查,並核對產品目錄與合併請求的目標。
依 bug-fixer 流程先重現、先看到測試失敗,再改程式並確認測試通過。
若既有測試的預期有誤,先提出判斷依據,確認適用規則與授權後再改。
依既有授權執行;權限不足或無法建立合併請求時,交回本地成果與原因。
不要合併,也不要順手修其他問題。兩段提示詞擇一即可。問題單所在的專案和產品程式所在的專案可能不同,因此要分開核對。這次實驗就是這樣:問題記在測試專案,修復則送到自己的 Toolshop 分支,不能直接把問題單所在的儲存庫當成修復目標。
任務交出去後,我們先看它怎麼重現,再檢查測試是否真的抓到了問題。
跑完之後
重現成功,測試卻說沒問題
這次用一般顧客帳號(編號 2)登入,取得憑證後,讀取管理員那筆資料:
text
GET /users/1 → HTTP 200回應竟然包含對方的電子郵件、地址和資料庫裡的密碼雜湊。用自己的帳號,就能讀到別人的資料,問題確實重現了。
接著打開 tests/Feature/UserTest.php,準備補回歸測試,卻發現裡面已經有一支測相同行為:
php
public function testNonAdminUserCanRetrieveOtherUserInfo()
{
$otherUser = User::factory()->create();
$response = $this->getJson("/users/{$otherUser->id}", $this->headers($this->user));
$response->assertStatus(ResponseAlias::HTTP_OK);
}最後一行期待的是 200,也就是「成功讀取」。難怪修復前 113 支測試全部通過,這支測試根本在替漏洞鼓掌。
但我們也不能看它不順眼就改答案。要修正既有測試,得先找到依據。這次對照同專案的乾淨版 sprint5,確認它的測試期待 404,產品程式也只允許管理員或本人讀取資料。兩邊對得上,才有理由認定含缺陷版本的測試寫錯了。

因此,這次把預期從 200 改成 404,並加上回應不得含有他人電子郵件與密碼雜湊的檢查。先保留原本的產品程式跑一次,測試失敗:
text
1) tests\Feature\UserTest::testNonAdminUserCannotRetrieveOtherUserInfo
Expected response status code [404] but received 200.這次失敗的原因正是我們要修的權限缺口,而不是路徑打錯或憑證過期。到這裡,測試才真的有抓住問題。
補上檢查,再確認正常操作沒被擋住
有了會失敗的測試,接著才改產品程式。根因在 UserController::show():
php
public function show($id)
{
Log::info('Show method called to retrieve user', ['user_id' => $id]);
$user = User::findOrFail($id);
return $this->preferredFormat($user);
}原本的登入檢查只確認「你登入了」,這個方法卻沒有再確認「你能不能看這筆資料」。乾淨版把權限檢查放在 UserService::getUserById(),但含缺陷的版本沒有這一層。我們只需要補上同樣的限制,不必為了一個問題搬進整套架構,所以在控制器讀取資料前加入:
php
$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,避免透過這個回應透露該使用者是否存在。修完再跑,結果如下:
| 驗證範圍 | 結果 |
|---|---|
| 讀自己、讀他人、管理員讀他人 | 3 支測試、7 個檢查全部通過 |
UserTest.php 全檔 | 28 支測試、83 個檢查全部通過 |
| 整套程式介面測試 | 113 支全部通過 |
整套測試的檢查數曾在 584 到 594 之間變動,追查後發現來自 ReportTest,UserTest 則一直是 83。因此,報告保留「113 支通過」,並交代數量浮動,沒有把某次的 592 當成固定結果。
不過,這批測試用的是記憶體內的 SQLite,還得回到本機服務再驗一次:
| 情境 | 修復前 | 修復後 |
|---|---|---|
顧客讀 /users/1,也就是他人資料 | 200,含他人資料 | 404 |
顧客讀 /users/2,也就是自己的資料 | 200 | 200 |
管理員讀 /users/2 | 200 | 200 |
後兩列也得通過,才能確認沒有修過頭。只要把整個功能關掉,第一列也能「成功擋住」,只是大家都不用看資料了。
把修改交出去,停在等人審查
測試和實際操作都確認後,我們才整理合併請求(PR)。這次先列出標題與修改摘要,經確認後推送,目標是自己的 Toolshop 副本 main 分支。上游刻意把這些缺陷留作教材,因此沒有把修復送回上游。
說明裡除了根因、修法和測試結果,還特別交代「為什麼改了既有測試」:原本期待 200 的依據哪裡有問題,乾淨版怎麼做,以及修正後如何先失敗、再通過。接手審查的人看到這些,才知道我們沒有為了過關而改答案。完整內容可看這次的合併請求。

當時只改了兩個檔案,新增 14 行、刪除 2 行,狀態停在待審查。問題單則留言連回這張合併請求,保持開啟、標籤不動;這次修復流程不自行合併,也不因測試通過就把問題單關掉。
送出前還發現一個小插曲:安裝依賴時的 chmod -R 777,讓 162 個檔案的權限從 644 變成 755,修改清單突然多出一大串。最後採用儲存庫本地的 core.fileMode=false,讓 Git 忽略這批權限差異,保留容器需要的磁碟權限。這是當時的環境處理方式;整理自己的修改時,仍要確認有沒有真正需要保留的可執行權限變更。
背後怎麼做
回頭看這一輪,真正重要的是順序:先確認問題存在,再確認測試抓得到,最後才讓修復通過測試。bug-fixer 把這件事拆成六步,收到前面的提示詞後,就照這個順序做:
| 步驟 | 要做到什麼 | 卡住時怎麼辦 |
|---|---|---|
| 重現 | 照問題單的步驟自己操作、留證據 | 跑不出來就回報,不硬改 |
| 補測試 | 先看到測試因這個缺陷而失敗 | 失敗原因不對,先修正測試或環境 |
| 改程式 | 只做修復所需的修改 | 影響範圍不明,就交回人判斷 |
| 驗證 | 新測試通過,既有測試也沒被弄壞 | 有其他測試失敗,就繼續查原因 |
| 建立合併請求 | 整理問題連結、根因、修改與測試證據 | 缺少目標或授權,就交付本地成果並說明 |
| 回報 | 附上連結、改動檔案和先失敗後通過的證據 | 不宣稱未完成的動作已完成 |
這套流程也要求測試檢查使用者能觀察到的行為,避免綁死內部寫法。一張問題單對應一張合併請求,每次只修一件事;順手重構和無關檔案,都留到別的工作處理。
改測試時,要說清楚憑什麼改
這次最容易誤會的地方,是我們真的改了既有測試。但改測試有兩種不同情況:規格合法變更了,測試要跟著更新;或像這次一樣,原本的測試就寫錯,得拿其他依據來修正。兩種都不能只靠「這樣比較容易通過」。
配套的 references/green-cheating.md 目前只列出「規格合法變更」這個例外,沒有完整涵蓋原測試寫錯的情況。因此,照本篇操作時,要先提出乾淨版的測試與產品檢查作為依據,釐清適用規則和授權後再改,不能把本篇當成任意改答案的許可。
判斷有沒有修對,就看修正後的測試能否在舊程式上失敗、在新程式上通過。下面這些做法都不能拿來交差:
- 刪掉測試、跳過執行,或拿掉關鍵檢查。
- 放寬預期,讓錯誤行為也能通過。
- 用固定等待、增加重試,或一直重跑,直到碰巧成功。
- 用替身取代真正要驗的呼叫,只測到替身自己。
每個動作,都要在授權範圍內
既然要讓助手動到檔案和遠端專案,修改測試、修改產品、推送分支、建立合併請求和留言,就各自需要對應授權。已有涵蓋目標與範圍的授權可以沿用;不足時,先備好具體變更再請人確認。開單檢查通過,不會自動授予這些權限。
助手也要先讀交接、設定和授權規則,分清楚產品儲存庫與問題單儲存庫。需要使用 GitHub 時,再檢查登入狀態;沒有可用的合併請求後端,就先交付本地修改和驗證結果。
合併請求建立時,依技能規定標明由助手產生,並加上待審查標籤。至於合併,config/governance.yaml 將 merge_pr 列為禁止,這一步留給人決定。設定檔寫了禁止,仍要配合執行環境的權限控制,不能只靠一行設定就認定工具已經封鎖。
今天學到什麼
今天原本要請新同事修產品,結果先發現測試把漏洞當成正確答案。這也讓我們看到,拿到一份全綠的結果時,還得問它到底在驗什麼。
找到可靠的判準後,我們先修正測試,確認舊程式確實會失敗,再補上權限檢查。最後也驗了正常操作,才把修改交出去等人審查。
這次是先有問題,再用測試把它抓住。明天我們換個方向,從探索過的流程裡挑一段值得持續檢查的行為,寫成日後能反覆執行的自動化測試。
作者備忘錄 — 正式出版前應移除
章節大綱
- 前言:承接開單檢查,引出「測試全過,但測試本身寫錯」的修復案例。
- 開始之前:確認交接資料與可修改的環境;保留 7 月 30 日未執行、8 月 10 日補跑的差別,環境設定與排錯收進補充。
- 動手試試:提供延續對話的短提示詞,以及具體路徑的交接示例。
- 跑完之後:重現越權讀取、對照判準、測試先失敗、修復後通過,再交付待審查的合併請求。
- 背後怎麼做:六步修復流程、不能靠放寬測試過關、原測試寫錯時的規則缺口,以及分別核對授權。
- 今天學到什麼:測試全綠仍要看判準,銜接下一章的自動化測試。
編輯注意:歷史實跑與提示詞路徑示例分開;保留改既有測試的依據,不把規則缺口寫成已經自動獲准。
參考資料
- 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) - 古德哈特定律:指標一旦變成目標就失去意義,綠色作弊是它在測試上的具體形態
- 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 看