
聽說奧菲斯的歌聲和琴聲連亡魂都聽得入迷,連復仇女神都為他落淚。
他的妻子尤麗黛被毒蛇咬傷後,奧菲斯無法接受,於是便帶著琴一路闖入冥界,他向冥王與冥后唱出自己的悲傷,甚至表示若不能帶妻子回去,自己也不願再回到人間,結果連冥界的主人都被打動,終於願意讓尤麗黛跟他離開,但是有一個條件:
「尤麗黛會跟在後面,但真正走出冥界以前,奧菲斯都不能回頭看她。」
兩個人沿著又陡又暗的路往上走,奧菲斯看不到身後的妻子,也聽不到任何回應,他只能相信她還跟在後面。
眼看出口就在前方,他卻開始害怕尤麗黛是不是沒有跟上,最後忍不住回頭了,尤麗黛立即從他眼前消失,他伸手想抓住她,結果只抓到一團空氣,她也在那一刻再次被帶回冥界。
奧菲斯為什麼回頭?是忘了條件、太有自信、太過思念,還是命中注定?他才剛失去尤麗黛一次,好不容易終於要把她帶回來,卻得在完全無法掌握妻子的情況下走到出口,真正難熬的是一路上他都害怕自己還會再次失去妻子,這種感覺實在讓人心癢癢,焦躁不安。
在前幾天的程式碼範例中,我們總會先寫測試描述規格,讓我們可以有快速回饋的機制,而還沒有說到任何有關 TDD 的原因,主要是想讓各位在不被名詞的框架影響下去體驗不同的開發方式,而為了怕有些讀者沒有聽過,也好奇這種開發方式在幹嘛,我們就來稍微講述一下 TDD 是什麼,有用過,也總得知道它是什麼。
各位在前幾天會發現範例中,我們總習慣先寫測試再依序進入實作,綠燈後重構再繼續下一個功能這樣的循環當中,而這種開發方式有個名稱叫做 Test-Driven Development,測試驅動開發,簡稱 TDD。
不過 TDD 也不只是單純如其名「先寫測試,再開始實作」,它是一個很短的循環,先選一個我們想要實作的行為,寫出會因為這個行為尚未完成而失敗的測試,再用足夠小的修改讓它通過,最後在綠燈的保護下重構,然後重新下一個循環,重點是這樣的工作節奏,其實帶給我們很多好處。

先寫下一個具體需求範例,執行它,確認測試真的失敗。
重點不是測試是否真的紅燈,而是這個紅燈必須是因為我們預期中的原因而紅燈,若測試第一次執行就綠燈,通常要嘛不是之前測過了,要嘛就是多寫了還沒有被測試覆蓋的程式碼,再不然就是斷言本身寫錯了,這時候會建議稍微把程式碼改壞,確認這個測試有沒有辦法抓到,有紅燈才代表它真的抓得到問題。
我們會在這個階段,決定呼叫端要怎麼使用這個新功能,所以通常在這一步我花的時間通常是最多的。
接著用簡單而足夠的方式讓測試通過。
「最小修改」不是鼓勵我們亂寫,而是暫時不要同時處理多個還沒被證明的假設,一次改動如果太多太寬,我們就會很難馬上定位到準確的問題,而且我們其實都很花心,寫到一半覺得設計不好想要換的次數太多了,所以一次不要寫太多也是一種策略,不過一次要寫多少複雜程度的程式碼,還是得要看自己的習慣啦。
重構,同時保持所有測試通過。
如果每次做到 Green 就趕快交差,TDD 最後只會留下「有測試的凌亂程式」,重構通常不是有空才要做的,最好的時機點在於綠燈後馬上做,把前面為了取得回饋而留下的粗糙設計收斂乾淨,把測試本身若有重複或看不懂的地方,也在這一步一起整理。
我覺得 TDD 的開發模式帶給我最大的價值就是 快速回饋、小步快跑,以及可以讓我們可以有策略的降低交換成本。
新功能從零開始時,進入 TDD 循環的路徑很直覺,因為行為還不存在,第一個測試自然應該失敗。
不過 LegacyCode 已經有很多既有行為,只是我們不確定哪些是規格、哪些是歷史包袱,甚至有些看起來很像 Bug 的東西,可能也是很重要的業務需求,這時如果我們直接去改,風險其實很高。
那我們要怎麼在 Legacy Code 的基礎上進入 TDD 循環呢?如下圖是依照我自己的理解畫出來的圖,虛線則表示如果還有下一個行為或功能尚未完整時,才會再次回到 Red。

依照圖示,我們會先做 特徵測試(Characterization Test),這個測試會先記錄系統當前的行為應該為何,這個行為應該要是綠燈的,因為我們還沒動到程式碼,如果現在紅燈只能代表是我們對行為的假設和實際不符,也就是有可能測試寫錯了,所以在這個階段就會運用到許多怎麼找出 Seam 的方式,並且最後讓測試成為有意義的紅燈,以此來進入 TDD 循環。
而另一種類型,如果舊行為仍然必須保留,就不要修改保護它的測試,保留原本就應該有的綠燈,再為新行為增加另一支紅燈測試,在這個階段我們就會區分出這次要保護些什麼,而什麼又是我們要修改的。
要注意的是如果 Characterization Test 綠燈代表的是「我們對這段程式碼當前行為的觀察是對的」,但並不代表「這段流程是正確的」,還是得要跟團隊做討論,跟現有規格做對齊喔!
因為比起寫完產品程式碼才補上測試,先寫測試的好處就是可以比較快的獲得回饋,從「整個功能做完才驗一次」變成「每一個小行為即時被確認」,這個頻率上的差異累積起來,對心理負擔的影響比想像中大很多,尤其是修 Legacy Code 的時候,每一次改動都不確定是不是安全的,能快速知道「這一步有沒有壞什麼」這件事本身就是必須的,不是「有測試就好」,是「每一步都能快速確認」這件事讓整個工作節奏不一樣了。
先寫測試的另一個好處,是我們會被迫從呼叫端的角度去思考設計,因為測試就是第一個使用這段程式碼的人,所以在寫測試的時候就要決定這個方法叫什麼名字、接什麼參數、回傳什麼型別,這些決定比「這段程式碼能不能跑」更早發生,如果建構子很難湊齊、方法名看不懂、回傳型別很奇怪,我們就可以更快的改變設計,這樣比進了生產環境才發現介面有問題所帶來的成本便宜很多。
這裡的交換成本指的是開發過程中被打斷再切回來的代價,不管是被 PM 叫走、臨時 hotfix,每次被迫切換,腦袋裡裝著的上下文就得重建一次,而 TDD 的短循環可以把每次工作切成夠小的塊,在每個綠燈節點上,我們可以相對安心地把這一步的上下文放下,知道下次切回來可以從哪個穩定的地方繼續,不用每次切走都留著「我到底改到哪裡了」的焦慮,切換的成本和切回來的成本都比較低。
TDD 有很多好處,但不代表任何團隊、任何問題套上去都會得到那麼好的結果,代價也是有的。
因為 TDD 的學習曲線算陡峭,把需求切成小例子、判斷下一步要測什麼、建立測試環境,這些能力都需要時間培養,剛開始速度確實會慢,而且慢的不只是寫測試的時間,還有「不知道下一步該做什麼」的思考卡頓。
而測試本身也需要維護,如果測試綁死了私有方法或內部資料結構,正式程式稍微一重構,測試就整排被破壞,這種情況一旦發生幾次,很多團隊就覺得測試是負擔了,尤其習慣這件事情本來很難馬上就轉變過來,又給我們惹麻煩,這不是火上加油嗎?所以團隊成員跟氣氛真的很重要!
TDD 的效果會受到熟練度、任務類型、循環大小、測試設計與團隊環境影響。
TDD 給我們的,說白了就是把複雜的路徑切短,讓每一個小步驟都能即時被確認,不用走完整個功能才發現方向走偏了,修 Legacy Code 的時候這件事尤其有感,當我們熟悉這種開發方式,會發現整個節奏不一樣了,好像來到新世界一樣。
當然也不是免費的,TDD 需要時間練習、測試本身也要維護,剛開始確實會感到力不從心,不過我覺得這些投資都是很有價值的,真香。
明天我們繼續來探討:各位最熟悉的朋友 Null