Skip to content

第 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 防護,就不會混進這輪結果。使用範圍仍要依專案授權:它允許私人、內部且非商業用途,商業使用、公開託管、再散布或提供第三方服務需先取得書面許可。

當時啟動還遇到三個環境問題。以下保留排查方式,若你碰到相同錯誤,再逐項處理:

現象當時原因與處理方式
程式介面回傳 500vendor/ 是空的,另執行 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;兩邊測試都可能全數通過

因此,這次把預期從 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,也就是自己的資料200200
管理員讀 /users/2200200

後兩列也得通過,才能確認沒有修過頭。只要把整個功能關掉,第一列也能「成功擋住」,只是大家都不用看資料了。

把修改交出去,停在等人審查 ​

測試和實際操作都確認後,我們才整理合併請求(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 列為禁止,這一步留給人決定。設定檔寫了禁止,仍要配合執行環境的權限控制,不能只靠一行設定就認定工具已經封鎖。

今天學到什麼 ​

今天原本要請新同事修產品,結果先發現測試把漏洞當成正確答案。這也讓我們看到,拿到一份全綠的結果時,還得問它到底在驗什麼。

找到可靠的判準後,我們先修正測試,確認舊程式確實會失敗,再補上權限檢查。最後也驗了正常操作,才把修改交出去等人審查。

這次是先有問題,再用測試把它抓住。明天我們換個方向,從探索過的流程裡挑一段值得持續檢查的行為,寫成日後能反覆執行的自動化測試。


作者備忘錄 — 正式出版前應移除

章節大綱

  1. 前言:承接開單檢查,引出「測試全過,但測試本身寫錯」的修復案例。
  2. 開始之前:確認交接資料與可修改的環境;保留 7 月 30 日未執行、8 月 10 日補跑的差別,環境設定與排錯收進補充。
  3. 動手試試:提供延續對話的短提示詞,以及具體路徑的交接示例。
  4. 跑完之後:重現越權讀取、對照判準、測試先失敗、修復後通過,再交付待審查的合併請求。
  5. 背後怎麼做:六步修復流程、不能靠放寬測試過關、原測試寫錯時的規則缺口,以及分別核對授權。
  6. 今天學到什麼:測試全綠仍要看判準,銜接下一章的自動化測試。

編輯注意:歷史實跑與提示詞路徑示例分開;保留改既有測試的依據,不把規則缺口寫成已經自動獲准。

參考資料 ​

  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) - 古德哈特定律:指標一旦變成目標就失去意義,綠色作弊是它在測試上的具體形態
  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 看