Test-Driven Development 解決了什麼問題?或是製造更多問題?

fcamel
fcamel的程式開發心得
9 min readSep 1, 2019

TL;DR 相較於 test-driven development,我們該在介面設計時投入更多心力在可測性(testability),一旦程式易於測試,事前事後加測試,並沒有差別。

《寫出容易測試的程式》裡,我用一個簡單的例子說明可測性 (testability) 的重要性。最近一年寫 Go,在新專案設計了新的軟體架構後,對可測性有進一步的理解。這篇用問答的方式從可測性的觀點重新思考介面設計。

為什麼要寫測試?

本文用測試 (test) 一詞含蓋 unit tests 和 narrow integration test。寫軟體的時間愈久,愈相信沒測過的程式有 bug。需要維護的時間愈久,手測愈沒效率,寫測試程式對個人和團隊都是好事。

為什麼測試很難寫?

一部份原因是練習不足,用任何工具或 design pattern 都需要經驗,寫測試也是一項技能,自然需要累積經驗。

另一個原因是技術債,設計不良的介面難以測試。例如,每測一個不同的參數,需要準備一份新的設定檔; 或是要準備 RPC Server,將資料轉成 RPC 用的格式溝通; 或是需要準備資料庫。

明明修正問題只是改幾行碼,準備工夫卻一大堆,自然沒人想寫測試了。

Test-Driven Development 解決了什麼問題?

Joshua Bloch《How To Design A Good API and Why it Matters》提到Service Provider Interface (SPI) 是一種 plugin interface,設計 SPI時,需要先寫 plugin 確保介面設計正確。

  • 如果只寫一個 plugin 就公佈 SPI,之後無法支援第二個 plugin,因為介面設計不良,不易使用。
  • 公佈 SPI 前寫兩個會好一些,之後可支援多個 plugin,但作起來會很困難。
  • 寫三個就沒問題了。

先寫測試對於可測性有相同的效果。寫的測試愈多,愈易於測試介面,日後發現 bug 想加 regression test,會容易許多。

我認為先寫測試最大的價值是讓介面易於測試。

Test-Driven Development 帶來什麼問題?

寫測試需要技術,經驗不足時會寫出爛測試,花時間寫又沒幫到忙,日後還增加維護成本。

即使經驗足夠寫出堪用的測試,測試仍有維護成本。寫太多幫助不大的測試 (例如 setter、getter),反而拖慢開發,增加日後修改成本。並不是無腦地先寫測試,結果就比較好。

對 TDD 刻版的印象,導致許多人不認同,像是 yinwang 對測試以及先寫測試都很感冒。yinwang 說的很好,推薦一讀,凡事都該因事制宜。

該為了可測性而更改介面設計嗎?

剛用 TDD 的時候,我很猶豫是否該為了方便測試,而修改原先預想的設計。例如,在介面上開洞方便控制內部行為。

如今我確信這是對的,甚至認為設計介面時是否會考慮可測性,是鑑別設計功力的重要指標。有無考慮可測性的架構差異很大。

回頭思考我們在解決什麼問題?最重要的是滿足客戶的需求。從這角度出發,必須確保軟體行為正確,還有日後易於維護。於是,我們需要好的可測性,才能進行更多測試,提升對程式碼的信心。日後需要修改時,可以放心修改而不會擔心未知副作用。

重視可測性時,會導出一些有趣的設計,像是過去聽聞 Mediator Pattern,但沒用過。反而在設計 blockchain 的軟體架構時,為了提升可測性,自然地用上 Mediator Pattern。讓我能和網路、儲存資料的結構、密碼學函式庫脫勾,有效率地測試共識協定。

設計介面時該考慮那些特性呢?

設計介面時需要考慮很多性質,像是低耦合、介面擴充性、可讀性、好除錯等。對我來說,不易判斷是否有作好這些性質。相較之下,寫個測試即可驗證可測性,有簡單的操作判斷。

另外,達成高可測性時,通常會兼顧其它性質,例如低耦合和介面彈性。不過可讀性稍有不同,提升可測性時,會增加介面抽象性,使得光讀程式碼不易了解細部行為,執行時看 backtrace 比較容易看懂用到什麼實作,還有整串呼叫路徑的細節。這會增加了解特定實作細節的成本。

是否該先寫測試?

先寫測試與否,其實是手段,更重要的是先考慮可測性。

如同團隊開發規章一樣,人多的時候,照著規章作,可以確保不同人有相似的產出。但也因此造成不必要的負擔。先寫測試可降低 over-engineering (寫測試很累,不會亂加功能),但可能會造成 over-testing。

依團隊成員的能力,可以用不同的方式:

  • 有人把關架構和模組介面可測性時,重要功能加必要測試即可。剩下的可等出 bug 時再補。只要程式易於測試,事先事後補並無差異。當然,時間足夠的情況,可在發現 bug 前先寫次重要的測試。
  • 對可測性架構掌握度不足時,需要適當地先寫測試檢驗可測性。
  • 更差的情況,需要先寫更多測試。但是不用全部測試都留下來,有些測試是用來輔助提升可測性,就像寫段小程式驗證某些性質,不一定會留下來。不好的測試會增加維護成本。

以我自己的情況來說,我會構想重要的測試情境,思考架構/模組/介面是否滿足測試情境。偶而寫點測試檢驗,但寫到足以證明架構的可測性後,會等必要時再補測試。愈快打通整個系統,風險愈小。這有點 Minimal Viable Product 的味道,不過是針對軟體架構而作。

Test Coverage 重要嗎?

測試重質不重量,如果 code review 時有把關好可測性,test coverage 幫助不大。挺多偶而檢驗一下是否有重要路徑沒走到。

另一方面,若怕成員修改介面時,不小心破壞可測性,可以適當增加 test coverage。如此一來,有人誤改介面破壞可測性時,會造成測試失敗,當事人或其他成員容易察覺可測性降低了。

如何判斷可測性的好壞?

對一個類別來說,以下的行為可提升可測性:

  • 不要存取全域變數。
  • 不要直接使用 I/O。像是透過類別 File 讀資料,而不是自己呼叫 open()開檔。比 File 更好的是透過介面 Reader 讀資料。網路也是如此。
  • 不要存取 config。理由和前兩點相似。
  • 不要自己建立用到的物件,而是透過外部傳入。這樣測試時才方便抽換相依物件,易於準備測試環境。
  • 不要自己取得系統時間。自己取得時間會造成測試用真實時間作判斷,因此拖慢執行速度。或是造成程式不確定性,讓測試不好驗證結果。應該要透過別的物件取得時間,才有機會用假時間加速測試。進一步說,通常需要的是 Timer,而不是時間。使用假的Timer可降低不確定性。
  • 不要使用亂數。有時需要的是 Selector,而不是亂數。改成傳入 Selector 後,除了易於測試外,還多了未來擴充功能的彈性。像是選擇隨機物件、或是 round-robin 方式選物件。

設計介面和思考實作時,可用上述的點檢驗是否好測。久而久之,會內化成習慣,在設計介面時就考慮這些事項,然後在 constructor 傳入輔助物件,抽象化需求,不會直接使用I/O、時間、亂數、全域設定等。

《Top 10 things which make your code hard to test》有相關討論。之後有機會另寫文章談細一些。

如何作到好的 Test-Driven Development?

如果你真的想執行 TDD 的話,留意TDD 的三個步驟如下:

  1. 的 test。
  2. code。
  3. 改成的 code。

在改爛 code 時不該動到 test,如果動到了,那就是爛 test。爛 test 配爛 code 是災難,可能也是多數人用 TDD 的第一印象,然後覺得這流程有問題。

留意一下教 TDD 的範例,會發現它們的測試碼變化不大,有清楚的脈絡。不像我們自己寫的時候,修改一大堆。這是因為教學範例的作者已想清楚需求為何,以及如何用測試表示這些需求而非實作。

寫好的 test 比寫好的 code 更難,除了必須想清楚使用需求外,開發者們在這方面經驗肯定比寫好 code 更少。所以關鍵在如何寫出好 test。我自己也會寫出爛 test,寫出爛 test 比例減少後,就提高了寫 test 的 C/P 值。

掌握可測性和「針對需求測試,而不是針對實作測試」這兩個要點,可以回答許多初學 TDD 的疑問:

  • 該測 setter / getter 嗎?
  • 該測 private methods 嗎?
  • 每個函式都該測試嗎?
  • 每個類別都該測試嗎?

藉由著重可測性和含蓋需求,可以指引什麼是好的測試。有好的測試,才會有好的 TDD 循環。

對我來說,TDD 更多的價值是指引我在早期思考可測性。練習 TDD 和練習 functional language 一樣,我最後不會完全用它,但練習的過程會給我程式設計的啟發。

結語

我們要在介面設計初期著重可測性而不是 test-driven development,避免 over-engineering 和 over-testing。

先寫測試是輔助思考可測性的好方法,初期可強迫走 test-driven development 流程,藉此轉換思路。然後漸漸地專注在可測性,藉由想像如何測試而不是實際寫測試,減少完成設計所需時間。

注意,初期練習會犯很多錯,反而花更多時間。需要長期累積經驗提升這方面的技能。這和學任何工具或技能是一樣的。不要以為開發者生來就會寫測試。

說了這麼多優點,談點其它事。test-driven development 有點像 AB test,是輔助的方法。如果心中沒有好主意,做再多 AB test 也不會有好產品,因為整個路線就是歪的,拉不回來。在構思軟體架構時也是如此,重點是心中要有好的架構,輔以先寫測試檢驗。

此外,無論是 TDD 還是設計架構時考慮可測性,都無法彌補缺乏特定領域知識的問題。比方說不熟網路程式,事先不會知道怎麼分層,才能在日後需要支援 multiplexing、prioritized message、resume session layer 等功能時,可以用最少的修改支援這些功能。

所以不要過度執著要有完美可測的架構,可能之後遇到沒想到的需求,仍需大改。保持 “Minimal Viable Testability” 比較務實。

相關文章

--

--