軟體測試基本觀念與有效測試實作
「測試到底有沒有用」這個問題,答案不在於有沒有寫測試,而在於寫的測試是不是有效。覆蓋率 95% 但全部測在 happy path 上、案例跑 5 分鐘卻抓不到上線的 bug、寫了一堆但下次需求一改就得整批重寫——這些情況,測試都寫了,但沒有用。
軟體測試到底在做什麼
軟體測試常見有 3 種講法,其實是同一件事的不同角度:
| 角度 | 定義 | 看重 |
|---|---|---|
| 找錯 | 為了證明程式有錯而執行程式、找出問題 | 抓 bug、預防上線出包 |
| 跑案例 | 根據規格設計一批測試案例,跑這些案例找錯 | 流程化、可重複、可審計 |
| 驗證需求 | 確認軟體有沒有滿足使用者顯性或隱性的需求 | 對使用者交代 |
3 種角度合起來才是完整的測試:有人專心找 bug、有流程確保覆蓋面,最後還要回到「使用者要的東西有沒有做到」。這 3 個角度在傳統軟體和 AI 系統都適用,差別只在 AI 系統裡「需求」的形狀比較模糊。
冒煙測試:先看會不會炸
「冒煙測試(Smoke Test)」這個名字來自硬體業,早年工程師會把電路板插電,看看會不會短路冒煙。如果冒煙了,後面複雜的測試就不用跑了。
軟體沿用這個比喻:每次 deploy 完,先跑一輪最基本的功能,確保系統還能啟動、登入、發 request 都不會直接 crash。冒煙過了,才做後續詳細測試。
| 系統類型 | 冒煙測試做什麼 |
|---|---|
| 一般 Web 服務 | process 起得來、首頁能打開、健康檢查回 200、最關鍵 API 不 500 |
| LLM / RAG 服務 | 模型 API 連得到、token 沒爆額度、空 prompt 不 crash、產出 1 個 token 都行 |
| 資料管線 | DB 連得上、必要 table 存在、最小一筆資料能寫進讀出 |
黑箱測試 vs 白箱測試
兩種測試的差別不在測什麼,而在測的人能不能看到程式碼:
黑箱測試
只看「輸入什麼進去、輸出什麼出來」,程式內部怎麼寫都不管。
程式碼怎麼重構都不影響測試
不會寫程式的人也能寫測試
QA / 測試人員主要做這個
白箱測試
看程式碼內部結構,確保「每一行 / 每個分支 / 每條路徑」都有被測到。
涵蓋程度由鬆到嚴:
| 涵蓋率 | 要求 |
|---|---|
| 語句涵蓋率 | 每行程式碼都跑過至少一次 |
| 分支涵蓋率 | 每個 if/else 的兩條分支都跑過 |
| 路徑涵蓋率 | 所有可能的執行路徑組合都跑過 |
實務上追求 80%+ 的分支涵蓋率算合理目標,而且兩種都要做:QA 做黑箱、開發寫單元測試(屬於白箱性質)。
單元測試與 Stub
「單元」= 一個函數、一個 class、一個小模組,只測這一塊的行為,不測它依賴的東西。
為什麼需要 Stub
直接拿真實依賴跑會有 4 個問題:跑很慢(連 DB)、依賴有 bug 影響測試、外部 API 不穩、結果不可重現。
解法是用「假的依賴」(Stub)取代真實依賴,回傳固定、預期的值。
// 真實版:會連 DB、跑很慢
int D(int x) { /* 連 DB、查表、計算... */ }
// Stub:直接回固定值
int StubD(int x) { return x + 5; }
測 B 時用 StubD,這樣:跑很快、依賴有 bug 不影響、結果穩定、出錯就 100% 是 B 的問題。
AI 時代的 Stub:mock 掉 LLM API
每次測試打真 LLM 太慢又貴又不穩。對應做法是 mock LLM 回固定 response:
# 真實:呼叫 LLM
def summarize(text):
return openai.chat.completions.create(...)
# 測試時:mock 掉
def stub_summarize(text):
return "這是 mock 出來的摘要"
這樣可以測「程式怎麼處理 LLM 回應」,例如「LLM 回空字串時要不要重試?」「LLM 回傳格式不合怎麼降級?」這些都是程式邏輯,跟 LLM 品質無關。
Mock vs Stub(補充)
| 名詞 | 做什麼 |
|---|---|
| Stub | 給假資料、讓被測函數能跑下去 |
| Mock | 給假資料 + 檢查「有沒有被正確呼叫」(次數、參數) |
多數情境用 Stub 就夠,需要驗證「呼叫了幾次、參數對嗎」才升級到 Mock。
整合測試:把幾個模組組起來測
單元測試只看「一個函數」,但實際系統是很多模組組起來跑。模組與模組之間最常出包:欄位名字對不上、null 處理不一致、SQL 寫錯、API 契約破裂。這些單元測試抓不到,需要整合測試。
整合測試 vs 單元測試的差別
| 對比點 | 單元測試 | 整合測試 |
|---|---|---|
| 範圍 | 1 個函數 | 多個模組組起來 |
| 依賴 | 全部用 Stub | 內部依賴用真的(DB、Redis、內部 service) |
| 速度 | 毫秒級 | 秒級 |
| 抓的 bug | 函數邏輯錯 | 模組之間的接縫錯 |
範例:使用者註冊流程
一個 POST /api/register 會經過:
API 層接收輸入
RegisterService呼叫驗證模組、建帳號模組、寄信模組寫進真實 DB
整合測試把這條路全部接起來跑,DB 用測試專用的 schema,跑完就清掉。
def test_register_creates_user_and_sends_email():
# Arrange:清空測試 DB
test_db.clear()
# Act:打真的 API
response = client.post("/api/register", json={
"email": "[email protected]",
"password": "Secret123"
})
# Assert:驗 API 回應、驗 DB 真的寫入、驗信被寄出
assert response.status_code == 201
assert test_db.query(User).filter_by(email="[email protected]").count() == 1
assert mock_email_sender.was_called_with(to="[email protected]")
哪些依賴要用真的、哪些可以 mock
| 依賴類型 | 做法 | 原因 |
|---|---|---|
| 自家 DB(PostgreSQL / Redis) | 用真的(測試 schema) | 抓 SQL 錯、schema 不符 |
| 自家內部 service | 用真的 | 抓模組契約不一致 |
| 第三方 API(金流、寄信、LLM) | mock 掉 | 不要每次測試都打到別人家、不要花錢 |
| 檔案系統 | 用真的(臨時目錄) | 抓路徑 / 權限問題 |
原則:自家的東西用真的,別人家的用假的。
整合測試專抓的 4 種 bug
欄位名字對不上:Service 傳
userId,Repository 期待user_idnull 處理不一致:A 層回
null,B 層假設一定有值,瞬間炸transaction 範圍錯:寄信寄完了,DB 卻 rollback
API 契約破裂:後端改了 response 格式,前端沒同步
這 4 種沒人會在單元測試裡看到,因為問題出在模組之間。
E2E 測試:模擬真實使用者
E2E(End-to-End,端到端)=從使用者點按鈕、到看到結果,整條路用真的跑一次。
E2E 測試在驗什麼
不是只看「API 回 200」,而是看使用者真的看到、摸到的東西:
按按鈕後,網址有沒有跳到
/home?畫面有沒有顯示「歡迎,王小明」?(DOM 真的長出來了沒)
DB 裡
last_login_at有沒有被更新?Cookie / localStorage 有沒有 token?
常見工具
| 工具 | 特色 |
|---|---|
| Playwright | Microsoft 出的,跨瀏覽器、API 設計現代、近兩年首選 |
| Cypress | 開發體驗最好、debug 介面強,但只支援 Chromium 系 |
| Selenium | 老牌、跨語言支援最廣,相對較重 |
| Puppeteer | Google 出的,Chromium 專用,輕量 |
E2E 的缺點
| 缺點 | 為什麼 |
|---|---|
| 跑得很慢 | 一個案例幾十秒到幾分鐘 |
| 容易 flaky | 網路延遲、動畫沒跑完、時序問題會偶發失敗 |
| 改 UI 就壞 | 改個按鈕的 selector,10 個測試紅燈 |
| Debug 困難 | 失敗了不知道是前端、後端、DB 哪一塊壞 |
適合用 E2E 測試的情境
不是每個流程都要寫 E2E,只挑真的不能壞的關鍵路徑寫:
登入 / 註冊
結帳 / 付款
跨多個系統的整合流程(不容易切片驗證)
「壞了客訴會爆」的路徑
其他流程(次要設定頁、後台管理)用單元 + 整合測試就夠。
對抗 flaky 的常見手段
用
waitForSelector等元素出現,不要寫死sleep重要案例給 retry 機制(連續失敗 3 次才算真的壞)
E2E 跑在獨立的 staging 環境,不要跟其他人的測試打架
失敗時自動截圖 + 錄影,方便事後 debug
怎麼做有效的測試
知道測試在做什麼之後,重點是怎麼寫出真正有效的測試。
一個好的測試長什麼樣:AAA 模式
「AAA」是 Arrange-Act-Assert 的縮寫,是業界公認最清楚的單元測試結構:
3 個階段
def test_shopping_cart_calculates_total_correctly():
# ① Arrange:準備好被測對象與輸入
user = create_user("Alice")
cart = ShoppingCart(user)
cart.add(book, quantity=2)
# ② Act:執行要測的那一個動作
total = cart.calculate_total()
# ③ Assert:檢查結果
assert total == 200
3 段用空行分開,看 5 秒就知道這個測試在驗什麼。
為什麼這樣寫有效
三段分開帶來三個好處。看 5 秒就懂測試在驗什麼,不必整段讀完才知道哪一行是重點。失敗時也容易定位:是 Arrange 出錯、Act 錯,還是 Assert 寫錯,三段分開一眼就能查。而且 Act 通常只有一行,等於強迫每個測試只聚焦單一行為,不會把兩件事混在一起測。
命名也是有效的關鍵
測試函數的名字要直接讀出測試意圖:
# ✗ 壞:看名字不知道在測什麼
def test_cart()
# ✓ 好:給定什麼、做什麼、預期什麼
def test_shopping_cart_with_2_books_returns_double_price()
# ✓ 也行:簡短但描述行為
def test_calculate_total_sums_all_items()
命名建議用 given_when_then 或 should_do_X_when_Y 格式,測試壞了,光看名字就知道是哪個行為壞了。
怎麼設計測試案例:等價類劃分+邊界值
寫測試的人常常卡在「我要測哪些輸入?」暴力測所有可能值不現實,要用等價類劃分和邊界值這兩個技巧:
等價類劃分
把「行為相同的輸入」分成一群,每群挑 1-2 個代表來測。
例:年齡欄位接受 18-65 歲:
等價類 A(太小):0、1、5、17 都會被拒絕,這群挑「10」當代表就好
等價類 B(合法):18、25、40、65 都會被接受,挑「30」當代表
等價類 C(太大):66、80、100 都會被拒絕,挑「80」當代表
從 100 個案例縮成 3 個,等於少做 97 個重複測試。
邊界值
Bug 最容易發生在「界線」上:「>= 18」誤寫成「> 18」、「<= 65」誤寫成「< 65」。
所以每個界線都要測「界線本身」+「界線 ±1」:
| 界線 | 要測的值 |
|---|---|
| 18(合法區下界) | 17(剛好不行)、18(剛好可以) |
| 65(合法區上界) | 65(剛好可以)、66(剛好不行) |
加上等價類的 3 個代表(10、30、80),總共 7 個案例就能涵蓋這個欄位的所有風險。
進階:決策表
當有多個欄位互相影響時,等價類不夠用,要用決策表列出所有條件組合。
例:折扣計算規則「VIP + 滿千 → 9 折;VIP + 未滿千 → 95 折;非 VIP + 滿千 → 95 折;非 VIP + 未滿千 → 不打折」。每個 row 就是一個測試案例,不會漏掉組合。
測試金字塔:什麼測試該多、什麼該少
不是「多寫測試」就好,寫對地方的測試才有效。業界共識是「測試金字塔」:
傳統金字塔
從底層大量、頂層少量:
| 層級 | 測什麼 | 速度 / 成本 | 比例 |
|---|---|---|---|
| 單元測試 | 單一函數,用 Stub 隔離依賴 | 毫秒級、極便宜 | 70% |
| 整合測試 | 幾個模組組起來測(含 DB / API) | 秒級、便宜 | 20% |
| E2E 測試 | 完整使用者流程、跑瀏覽器 | 分鐘級、貴 | 10% |
為什麼是金字塔形狀
下層的 100 個案例的成本 ≈ 頂層 1-2 個:單元測試跑 1000 個只要幾秒,E2E 跑 10 個就要幾分鐘
失敗訊號精準度倒過來:E2E 失敗你不知道是哪一塊壞了,單元測試失敗就 100% 知道是哪個函數
越底層越穩定:E2E 容易 flaky(隨機失敗),單元測試結果幾乎 100% 一致
常見反模式:冰淇淋甜筒
把金字塔倒過來,大量 E2E、手工測試堆在上面,底層幾乎沒有單元測試,這是業界最常見、也最痛的反模式:
特徵:
E2E 跑很久、常 flaky(偶發失敗)
改一行 code 要等 1 小時才知道有沒有壞
出錯時不知道是哪裡壞了
改個 button 樣式,10 個 E2E 紅燈
為什麼會掉進這個坑:
早期沒寫單元測試,靠 QA 點來補
E2E「看起來最像真的」、好說服老闆
程式碼沒設計成可單元測試 → 後來只能從外面整條路測
如果你的團隊長這樣,先從底層補單元測試開始,慢慢把上層的測試往下移。每補一層底,上層就能瘦一些。
AI 系統的金字塔
AI 系統的形狀類似,但每一層測的東西不同:
| 層級 | 測什麼 | 工具 |
|---|---|---|
| 底層(多) | 程式邏輯(mock LLM 後測「LLM 回空怎辦」「解析失敗怎辦」) | 一般 unit test + mock LLM |
| 中層(中) | LLM 答案的品質、faithfulness、相關性 | 金樣本 + rubric + LLM-as-Judge |
| 頂層(少) | 真實情境下使用者滿不滿意 | 人類專家 review、A/B 測試、👍/👎 |
3 層都不能少:只做底層會錯過 LLM 品質問題,只做頂層又跑得慢又貴。
把測試自動化進 CI/CD:沒接 CI 的測試等於沒寫
寫完測試如果不上 CI、不是每次 push 都跑,這些測試半年內就會跟現實脫節,變成廢紙。
5 個階段,每階段該跑什麼
| 階段 | 觸發 | 跑什麼 | 目標時間 |
|---|---|---|---|
| ① Commit / push | 每次提交 | Lint、單元測試、型別檢查 | 秒級 |
| ② PR 開出 / 更新 | 開 PR 或 push 更新 | 整合測試、API 契約測試 | 分鐘級 |
| ③ 合進 main | merge | E2E 測試、視覺迴歸 | 10 分鐘級 |
| ④ Pre-release | 標記要 release | 性能 / 安全掃描、跨瀏覽器、人類抽樣 | 小時級 |
| ⑤ 上線後 | 部署完成 | 冒煙測試、健康檢查、異常監控 | 持續 |
設計原則
越早期的階段要越快、越多;越後期要越完整,但跑得少。
理由是讓 bug 在離開發者最近的時間點被攔住:commit 時抓到,1 分鐘就能改完;上線後才抓到,要客服、緊急修補,可能還要賠錢。
接 CI 的 3 個基本要求
每次 push 自動跑:不能靠工程師自己記得跑
失敗就擋下來:unit test 不過、PR 不准 merge
結果要清楚:失敗時 1 秒內看出是哪個測試壞了、哪一行 assert 沒過
GitHub Actions、GitLab CI、Jenkins、CircleCI 都能做到。沒做到這 3 件,測試就只是寫好但沒人看的文件。
軟體測試的 6 大原則
業界共識的 6 條基本原則,多數來自 ISTQB 國際測試認證教材:
精簡版(詳細推理直接看圖):
| # | 原則 | 一句話 |
|---|---|---|
| 1 | 測試證明有錯、不證明沒錯 | 「測試都過」≠「沒 bug」 |
| 2 | 不可能 100% 測完 | 追求 good-enough,依風險決定 |
| 3 | 越早測越好 | 從需求分析就介入,越晚改越貴 |
| 4 | 80/20 缺陷群集 | bug 集中在少數模組,加倍測它們 |
| 5 | 殺蟲劑悖論 | 老案例抓不到新 bug,要持續更新 |
| 6 | 看情境而定 | 銀行 vs 遊戲 vs AI 系統的測試重點完全不同 |
這 6 條講的是心態和策略,不是技術。寫了 100 個測試卻忽略這 6 條,多半還是會出包。
AI 系統測試的新挑戰
前面的基本觀念跟做法,在傳統軟體和 AI 系統都適用。但 AI 系統有 4 個傳統軟體沒有的特性,讓「跑測試、看通不通」這套不太夠用:
確定性沒了
傳統軟體:f(2, 3) 永遠回 5。
AI 系統:同樣 prompt 跑兩次,答案可能不一樣。
對策:測試時 temperature = 0;多次取中位數而不是單次評分。
輸出空間爆炸
傳統軟體:輸出是 int / bool / JSON,assertEqual 就夠。
AI 系統:輸出是自由文字,正確答案有無數種。
對策:用 rubric(評分表) 從多個面向評答案的品質,配合 [LLM-as-Judge](../LLM/AI 評委打分:評分表怎麼設計.md)。
版本漂移
傳統軟體:升級套件 → 看 changelog。 AI 系統:模型升一次版本,同樣的輸入分數可能整組跑掉。
對策:釘死模型版本(用 gpt-4-0613 而非 gpt-4),每季用金樣本重跑對照。
失敗的長相
傳統軟體:throw exception → 一眼看出。 AI 系統:幻覺,講得頭頭是道,其實是錯的。
對策:建「金樣本」回歸測試集;對 RAG 系統檢查 faithfulness(每個說法都能對應原文)。
AI 系統測試對策三件套
濃縮成 3 件事:
| 動作 | 做什麼 | 為什麼 |
|---|---|---|
| 建金樣本 | 50-100 個有共識的 input + expected | 對抗模型漂移、能做迴歸測試 |
| rubric + LLM-as-Judge | 用 rubric 從多個面向評分,請另一個 AI 當評委 | 解決「自由文字怎麼評」 |
| 釘死模型版本 | 不要用 latest tag | 升級 → 重新跑金樣本 → 確認可接受才換 |
3 件都做到,AI 系統的測試就能做到可重現、可信、能被 CI 自動化,這正是傳統軟體測試原本就有的基本要求。
用 AI 幫忙寫測試?
近兩年另一個趨勢是用 AI 來寫測試(Copilot、Cursor、Claude Code 都很會這個),但要小心:
AI 寫測試的常見陷阱
陷阱 1:AI 寫的測試常常只是把「code 在做的事」變成 assertion。
例如某個函數有 bug,回傳的值是錯的。AI 看了 code,寫出來的測試卻是「assert 回傳值等於 code 算出來的值」,測試永遠會過,bug 永遠抓不到。
AI 看的是現在的程式碼,不是程式碼應該做什麼。需求面的 bug,它看不出來。
陷阱 2:測試覆蓋率高 ≠ 測試有用。
AI 很會把覆蓋率衝到 95%,但衝的都是 happy path,邊角案例沒測到。重要的失敗模式,像是同時併發、網路斷線、惡意輸入,AI 不會主動想到。
正確使用方式
把 AI 寫的測試當作起點,不是終點:
讓 AI 產一批基礎測試(happy path、obvious edge cases)
人類 review:補上 AI 沒想到的邊角案例、確認測試在驗證「規格」而非「現有 code」
對重要模組,人類主動寫幾個刁鑽案例
最佳組合:AI 寫 80% 的常規案例、人類寫 20% 的關鍵案例。
小結
「有效的測試」需要 3 件事,從具體到抽象:
形式正確:每個測試用 AAA 模式寫、命名說明意圖,用等價類加邊界值設計案例
比例正確:測試金字塔——底層大量單元測試、中層整合測試、頂層少量 E2E
流程正確:接 CI/CD、每次 push 自動跑、失敗就擋下來
3 件都做到,寫測試的時間才不會白費。
AI 時代多兩個技能:用 Stub mock 掉 LLM 來測程式邏輯,用 LLM-as-Judge、金樣本、釘死版本這三招來測 LLM 品質。
測試不能證明沒錯,只能證明目前測的情境沒錯;不可能測到 100%,時間要花在風險最高的地方;越早測越好,需求階段就該開始想「這要怎麼測」。把這幾句話放在心上,會比追求覆蓋率數字有用得多。