
特洛伊戰爭打了十年,希臘聯軍始終攻不下特洛伊城,繼續硬打不是辦法,於是奧德修斯想出了一個計畫,打造一匹巨大的木馬,讓一批士兵躲進中空的馬腹裡,其餘軍隊則假裝撤退,把船開到看不見的地方躲起來。
隔天一早,特洛伊人打開城門,只看到空蕩蕩的海灘和一匹木馬,有人認為那是希臘人獻給神明的祭品,只要把它搬進城裡,就能得到神明的保佑。
祭司拉奧孔卻覺得事情不對,他拿起長矛刺向馬腹,裡面傳出低沉的聲響...他警告眾人,不要輕易相信希臘人留下的禮物,沒想到這時兩條巨蛇從海上出現,纏住了他和兩個兒子!!!
特洛伊人把這件事當成神明降下的懲罰,於是再也沒有人敢反對,最後還是把木馬拉進了城裡。
到了晚上,城裡的人大肆慶祝,以為長達十年的戰爭終於結束,等四周安靜下來,藏在木馬裡的士兵悄悄爬出來打開城門,讓原本躲在外面的希臘軍隊攻進城內,特洛伊城就此淪陷...
曾幾何時我們對「單元測試」的認知其實就是:
把每個 method 都測一次。
替每一行程式碼補上一個測試。
聽起來很合理,但只做到這些,就能叫作單元測試嗎?今天我們就來聊聊,單元測試真正重要的是什麼。
修改程式碼,大致可以分成兩種做法。
也是最常見的做法。仔細讀 code、想清楚要改哪裡、動手修改,再把系統跑起來看看有沒有壞,最後默默祈禱自己沒有踩到地雷。
這個過程聽起來其實很專業,畢竟我們有認真看、有仔細想,也有打開畫面測過,但問題是,再怎麼仔細都不能保證不出錯。
Michael Feathers 在《Working Effectively with Legacy Code》說過:
我們不會只因為一位外科醫生「動作很小心」,就放心讓他拿奶油刀替我們開刀。
PS: 超會比喻的...什麼時候才能寫文章到這種程度啊!
在動手之前,先用測試把目前的行為保護起來,修改之後再由測試告訴我們原本的行為有沒有被破壞。
這兩種派別的差別不在於「有沒有用心、有沒有花時間」,而在於我們有沒有把感覺換成回饋,如果每次改動都有一個人或工具能快速地給我們回饋,我們就能少走很多冤枉路,可惜不可能有人每天都在旁邊看你寫 Code,寫錯就打你一下,所以提供這個快速回饋的工具,就是單元測試。
Feathers 也把測試形容成 Software Vise,也就是「軟體虎鉗」。

圖片擷取自網路
虎鉗是木工或金工常用的工具,它會用兩塊夾片固定住工件,讓他在加工時不會亂跑。
當一段 code 被測試覆蓋,它的行為就被「夾」在那裡,動的時候只有需要加工的那一小塊會變動,其他部分不會無聲無息地溜走,除非我們夾得不夠緊(測試沒有保護到的地方)。
這也是為什麼接手 Legacy Code 時,我們特別需要單元測試。不是因為寫了測試就能保證永遠不出錯,而是我們需要一套夠快、可以頻繁執行的回饋機制,否則一個看似不起眼的誤差,都可能讓其他零件跟著錯位,最後影響整個結構的運作。
提到這,就不能不一起談談大名鼎鼎的測試金字塔。

| 層級 | 主要目的 | 速度 | 執行時機 |
|---|---|---|---|
| 單元 | 驗證「商業邏輯或行為」 | 毫秒級 | 每改一行都該能跑 |
| 整合 | 驗證「邏輯跟外部系統能否正確合作」 | 秒~分鐘級 | 每天跑、CI、合併前或部署前跑 |
| E2E | 驗證「使用者能否從頭到尾完成完整流程」 | 分鐘級以上 | 部署至測試環境後、正式上線前跑 |
測試金字塔提供了一個安排自動化測試的方向:
越靠近程式碼底層的測試,數量通常越多
越接近完整使用情境的測試,數量通常越少
原因不難理解,底層測試通常跑得快,失敗時也比較容易找到問題,越高層的測試雖然更接近真實使用情境,執行和維護的成本通常也越高,而且更容易受到資料、網路與環境設定影響。
不是說 整合測試 和 E2E測試 沒用,它們測的是真正的組合行為,這個層面的測試很有價值,問題是它們的成本比較高,跑得慢、環境要配好、資料要準備,一個環境問題也許就能讓它紅燈,而紅燈跟業務邏輯可能完全無關,不過其實在現代都有工具可以幫我們解決這些問題,在後面的章節我們會提到。
那麼,到底什麼才算單元測試?
「單元」的意義其實有不同流派的看法,不一定只能是一個 method 或一個 class,與其說明什麼是單元測試,不如我們先來看看幾個反定義:
聽起來很苛刻吧?我第一次看到的時候也想說:「不會太極端嗎?我這個測試明明就是在驗證 BLL 的行為啊,只是順便連了一下 DB……」
關鍵不在於它驗證的是什麼,而在於我們希望單元測試具備兩個特性:
我們需要的是快速而明確的回饋,當測試連到資料庫,它通常會跑得比較慢,而紅燈出現時,也得繼續確認到底是 DB 連線、測試資料,還是業務邏輯出了問題。
捫心自問一下,我們真的會在每次小幅修改後,都願意跑一次這麼慢的測試嗎?
不會的,我們會「再改個幾次再跑」、「下班前跑」、「明天再說」,然後測試就慢慢變成擺設,再也沒人在意它紅了還是綠了。
試想一下,如果每次改完 Code,準備要來跑測試了,但是每次跑都要 5 分鐘左右,你會不會很煩躁?好吧,也許可以趁這段時間去泡杯咖啡,回來剛好跑完,然後我們就會忘記剛剛到底做了什麼,也就是強制被斷心流了!好吧,也許我們的 Context 超大,很肯定自己不會被斷心流,但當興致沖沖的又修改完一段邏輯的時候,不就還要再跑一次嗎,難道還要再去泡一杯咖啡嗎?對吧!
我們都不喜歡乾等待,會讓我們的耐心漸漸消逝,直到最後這些測試就會被我們收起來,再也不會拿出來,跟每次驗收、部署前都會跑的整合測試不同,這是兩件截然不同的事情,最好的情況下,我們希望當下立刻就能知道狀況,這才是單元測試,因為當我們養成工作習慣是改個兩三步驟就跑一下測試,小步快跑如其名就是要快,更準確的說是回饋要快,那麼在面對 Legacy Code 時很多錯誤都會在這一步就被我們攔截並且定位住。
假設需求是「會員累積消費滿一萬元,就升等為金卡會員。」
[Fact]
public void GetMemberTier_當客戶累積消費滿一萬元_應回傳金卡()
{
// 連到開發環境的資料庫
var conn = new SqlConnection("Server=dev-db;Database=HelloShop;...");
var loyalty = new LoyaltyService(conn);
// 執行測試前,先新增會員與消費紀錄
InsertCustomerForTest(id: 1);
InsertPurchaseHistory(customerId: 1, totalAmount: 12000);
var profile = loyalty.GetMemberTier(customerId: 1);
Assert.Equal("Gold", profile.Tier);
}
這段測試確實有價值,因為它能驗證 LoyaltyService 和資料庫接起來後的行為。但是依照前面我們對於「單元測試」的標準,它同時具備三個特徵:
dev-db
因此,更準確地說,這是一個整合測試,而不是單元測試。
要,只是要分清楚它們各自在解決什麼問題。
設計良好的高層級測試,確實能涵蓋許多單元測試也會驗證到的行為,而且更接近系統真正運作的方式,但這不代表它能完全取代單元測試,兩者不是互相競爭,而是分工合作。
所以我會採取層層堆疊的方式來構築測試,其實什麼測試都需要,討論的場景不同而已。
互補但不互換,無法在下層驗證的重要行為,就需要往上層測
每個專案的測試策略都不同,會依照每個地方的重要程度,考量出錯了會對客戶帶來多大的影響程度,而選用適當的測試策略及數量,因為如果都不用權衡的話那就全部都 E2E 就好了啊,最貼近使用者了您說是吧?那我們都知道後果將會是大災難對吧 XD

圖片擷取自網路
再次強調,我不是什麼反金字塔頂端的奇怪份子,我們不是要排斥測試金字塔上層的測試,該驗證的還是要驗證,只是當我們接手複雜的 Legacy Code,需要頻繁修改、快速確認結果時,單元測試通常是極其重要的工具。
所以本系列文章大多都會以單元測試做為範例,後續章節才會談到些許整合測試的部分。
明天我們就要進入實戰篇了,就先來看看:為什麼感覺不到程式碼?