昨天,我們先用 SDD 把需求、預期行為和要完成的 Tasks 一步一步定清楚。
規格有了,接下來就真的要開始寫 Code 了。
如果說 SDD 是先把「系統應該怎麼運作」說清楚,那 TDD 就是把下一個「怎樣才算正確」先寫成測試,再開始寫 Code。
這就是今天要看的:
Test-Driven Development(TDD,測試驅動開發)。
我第一次接觸 TDD 的時候,反而覺得這個方法很有意思。
因為它跟我原本想像的順序完全相反。
以前直覺會覺得:
先把功能寫出來
↓
再寫測試確認它有沒有壞
但 TDD 卻要我在功能還沒真正出現以前,就先開始想:
「如果它真的存在,應該怎麼反應?」
對當時的我來說,很像要:
在寫正式 Code 前,先天馬行空幻想一下。
只是這個「幻想」不是亂猜實作方式。
而是先想像不同情境下,什麼結果才算正確。
Test-Driven Development 最核心的概念,可以先理解成:
讓測試一起驅動功能怎麼被開發出來。
它不是先把所有 Test Case 一次寫完,再一口氣開始實作。
比較典型的做法,是用一個很小的循環一直往前走:
RED
先寫下一個預期行為的測試
↓
執行測試
確認它真的失敗
↓
GREEN
寫剛好足夠的 Code
讓測試通過
↓
REFACTOR
在測試保持通過的情況下
整理 Code
↓
再進入下一個 RED
也就是很常看到的:
Red → Green → Refactor。
TDD 的第一步,不是想辦法讓畫面亮綠燈。
反而是:
先確認測試真的會失敗。
假設今天我要替甜點工作室做一個烤箱溫度警示功能。
我先決定其中一條規則:
超過安全溫度時,要跳出警示。
這時功能根本還沒寫。
所以我先替這個預期行為寫一個 Test,再執行它。
紅燈。
這其實才是正常的。
因為我要確認:
目前系統真的還沒有做到這件事,而且這個 Test 確實抓得到它。
如果功能根本沒寫,測試卻一開始就綠燈,我反而要回頭確認:
我真的有測到自己想測的東西嗎?
接下來才開始寫實作。
Green 階段的重點不是:
一次把架構做到最漂亮。
而是先寫:
足夠讓目前這個 Test 通過的 Code。
也就是先完成眼前已經定義清楚的行為。
像剛剛的烤箱例子,我現在只需要先讓:
超過安全溫度 → 出現警示
成立。
不用這一刻就先設計未來十種烤箱模式、二十種警示層級。
先讓目前這一題變成綠燈。
等測試綠燈之後,才進入 Refactor。
這時候可以開始看:
命名是不是清楚?
有沒有重複邏輯?
責任是不是放錯地方?
有沒有更容易理解的寫法?
Refactor 的重點是:
整理內部結構,但不要改掉原本已經成立的行為。
而前面的測試就會一路陪著這次整理。
如果重構途中不小心讓原本正常的行為被破壞,測試就有機會重新亮紅燈。
整理完,再挑下一個預期行為。
重新進入下一輪:
Red
↓
Green
↓
Refactor
所以 TDD 比較像很多個小循環,一步一步把功能長出來。
一開始學 TDD 時,我最喜歡的其實就是這個部分。
因為正式 Code 還沒寫,就要先想:
「如果這個功能真的存在,還可能遇到哪些情況?」
例如剛剛的烤箱警示。
第一個 Test Case 可能是:
溫度正常
→ 不顯示警示
接著又想到:
溫度超過安全值
→ 顯示警示
過程中如果又想到新的情況,可以繼續加進 Test List。
所以 TDD 並不是要求我第一天就預知所有未來狀況。
它比較像是:
先把下一個「系統應該做到什麼」想清楚,再用測試把這件事固定下來。
知道整個 TDD 循環之後,再往單一 Test Case 裡看,還有一個很常見的結構:
3A 原則。
Arrange
↓
Act
↓
Assert
先把這次測試需要的資料、物件和狀態準備好。
例如:
準備:
目前溫度 = 250°C
安全上限 = 220°C
真的執行這次想測試的行為。
例如:
執行溫度檢查
最後確認:
結果是不是跟原本預期的一樣?
例如:
期待:
顯示「溫度過高」警示
放在一起就是:
Arrange
準備超過安全值的溫度
↓
Act
執行溫度檢查
↓
Assert
確認警示有出現
所以這兩組東西是在看不同層次:
Red → Green → Refactor
= 整個開發循環怎麼往前走
Arrange → Act → Assert
= 單一 Test Case 裡怎麼組織
而 3A 本身也不是只有 TDD 才能使用。
它是一種讓測試更容易閱讀的結構。
我自己真的開始用 TDD 之後,一直有一個疑惑:
到底哪些功能才需要 TDD?
總不能今天只是改一個字體大小,也完整跑一輪 Red → Green → Refactor。
查完資料後,我才發現,其實沒有一張業界規定:
登入 → 一定 TDD
按鈕 → 不用 TDD
日曆 → 一定 TDD
比較實際的判斷方式,是看:
這次修改裡,有沒有值得先被明確定義、反覆驗證的行為。
我整理後,下面這幾種情況特別能發揮 TDD 的價值:
反過來,如果現在做的是還在探索方向的 Prototype,或只是很單純的純視覺微調,硬跑完整套 TDD 不一定划算。
所以我現在比較不會把它想成:
「這個功能到底配不配用 TDD?」
而是先看:
「這裡有沒有一個值得先講清楚,而且之後不希望被不小心改壞的行為?」
BuJo 的活動「情境一」,規則是固定日期。
也就是:
開始日期和結束日期必須是同一天。
當時前端其實已經把日期選擇器限制好了,使用者正常操作畫面時,不會選出跨日日期。
看起來好像已經沒問題了。
但後來往後端看才發現:
前端不讓使用者這樣選,不代表後端真的不接受這筆資料。
如果不經過畫面,直接送一筆:
startDate:10/1
endDate:10/2
當時的後端仍然可能把這筆資料收進去。
所以這次我們沒有直接先去改 Code,而是先把「什麼情況才算正確」寫成三個 Test Case:
1. endDate 和 startDate 不同
→ 應該回傳 400
2. endDate 沒有傳
→ 合法,沿用 startDate
3. endDate 和 startDate 相同
→ 合法
第一個 Test 跑下去,真的先紅了。
RED。
這也證明目前的後端確實還沒有守住這條規則。
接著才補上驗證邏輯,再重新執行測試。
這次,綠燈了。
我很喜歡這次的地方,是它把前面那個有點抽象的 Red → Green 流程突然變得很具體。
原本我看到「前端已經不能選跨日日期」,其實很容易直覺覺得這個功能就算處理好了。
但測試把另一個情況攤開來看之後,我才更清楚地感覺到:
畫面上看起來被擋住,跟系統本身真的守住這條規則,是兩件不同的事。
也因為先把這幾種情況寫成 Test,後面補 Code 的時候,反而會很清楚自己到底是在守哪一條行為。
假設今天我直接跟 Coding Agent 說:
「幫我在建立活動 API 加一個日期驗證。」
它可能很快就把 Code 改完。
畫面或 API 跑起來,看起來好像也沒問題。
但如果專案裡沒有留下清楚的行為測試,我很容易只看到:
「我剛剛試的這一條路有成功。」
其他原本也應該成立的情況,就可能這樣被忽略掉,直到哪天真的又出現 Bug,才發現前一次修改其實早就影響到它。
而如果專案本來就有 Tests,Coding Agent 在修改既有功能時,也多了一份可以參考的「既有行為紀錄」。
它可以從裡面看到目前有哪些預期情境、哪些結果應該成功或失敗,也能參考專案原本的測試寫法。
修改完成後,再重新執行這些 Tests,就多了一層機會提早發現:新的改動有沒有碰到原本正常的行為。
不過這時還有一件事很重要:
Test 變紅,不代表第一個反應就是去改 Test。
先要看的是,這次到底是「規格變了」,還是只是「Code 的寫法變了」。
SDD 定義的行為沒變
只是 Code 換一種寫法
→ 原本的行為 Test 應該繼續成立
需求/規格真的改了
→ 正確行為跟著改
→ Test 才需要一起更新
所以不能只是因為新的 Code 讓舊 Test 紅燈,就順手把 Test 改到會過。
真正要先確認的,是:
我們原本定義的「正確行為」,到底有沒有改變。
昨天的 SDD,是先把:
「系統應該怎麼運作」說清楚。
到了 TDD,則再往前一步:
先把其中一個預期行為寫成 Test,真的跑一次看看現在做不做得到。
我一開始喜歡 TDD,是因為它會逼我在 Code 還沒出現以前,先天馬行空想像:
「還有什麼情況?」
對剛開始學程式的我來說,這會讓整個邏輯脈絡變得比較清楚,也比較知道等一下 Code 要往哪裡寫。
但真的做過專案之後,我最有感的地方反而變了。
我開始喜歡的是:
有測試之後,我比較敢動原本的 Code。
因為專案越來越大之後,一次修改很少真的只停在眼前那一小塊。
Test 當然不能保證程式永遠不會壞,也不可能抓到所有沒有被覆蓋的情況。
但只要原本重要的行為有被留下來,新的修改不小心影響到它時,就多了一層機會可以提早發現。
所以我現在看到一整排綠燈時,安心的已經不只是:
「這次功能有做對。」
而是:
「好,我可以繼續往前改了。」
接下來,我們要進到 BuJo 裡一個前前後後居然改了三次的功能:
Google 登入。
它也是我第一個真正自己做的全端功能。
明明都是同一個登入功能,背後碰到的問題卻一次比一次更深。
下一篇,就從第一版開始,一層一層拆開 Google Login 背後到底發生了什麼。