24 April 2014

昨個 Ruby 巨頭 DHH 發了篇 TDD (Test Driven Development) 已死的宣言,引發各界討論。我的背景不是 Rails 所以也沒什麼可以著墨,不過這件事讓我想整理自己的 TDD 的經驗,以及目前測試的做法。

要不要執行 Test First?

這是個大哉問,DHH 說他不會再執行 Test First 了,但我目前的結論是 Test First 還是需要的。原因是它對我來說是件犀利的 武器。是的,我定位它是武器,而不是單單的工具。因為它可以讓我突破最險惡的難關:

  • Test First 可以確保測試的高涵蓋率,因為總是先有測試的程式,才有實際的成品
  • Test First 可以確保 API 的設計完善,因為你是先從 API 使用者角度的開始延伸
  • Test First 可以確保程式高模組化,因為如果不先規畫好模組,測試很難進行

上述的優點 不是 寫測試的好處,如果先寫實際程式碼,再回頭補測試是達不到同樣的效果的。我在遇到刁鑽的問題時,常會啟動 Test First 來幫我解決,例如:

  • 寫 parser 的時候,常會有很多 edge case 要處理,Test First 保證不會寫了 case B,而把 case A 給搞壞
  • 設計給別人使用的 library,尤其近來流行的 Fluent Style,設計的難度更高。library 一旦交給別人後就不能再改了,API 最好一開始就有好的完成度
  • asynchronous 的測試特別難寫,也因為難寫,它會強迫你將程式做好模式化,管理好各模組間的 thread 與資料交換。

這麼強的武器我怎麼可能輕易放棄呢?不過,跟 TDD 死忠派不同,我個人不會在所有狀況都行使 Test First。比例上大約是 7 成對 3 成吧?7成是 Test First。原因在於不是所有的程式都這麼難,要這麼嚴謹,很多程式其實很單調又無聊。又,有時候跟我 pair 的另一個開發者並沒有 Test First 的習慣,我會配合他的節奏來開發。

Test First 學習歷程

我個人已經執行 Test First 六、七年了,經歷不算久,但也不短了,我自己是經歷了下面五個階段:

  1. 一開始學習,有很大的阻力,常常會忘記要先寫測試
  2. 逐漸習慣後,開始會嘗到甜頭,並有了自信,建立正向循環
  3. 下一階段,如果沒先寫測試,就去寫實際程式碼,反而會有很重的罪惡感
  4. 對 Test First 已經相當熟悉,開始知道哪些地方沒先寫也沒事,從那份罪惡感解放
  5. 不用刻意實行 Test First,也能達到上面提到的三個效果。雖然沒實際寫出測試碼,但腦中已自行建模,滿足設計的需求。

我現在落在 3~4 點中間,有時可以飄到第五點。第五點當然是誇張了點,不過如果類似的需求重覆出現了好幾遍,要到達那個頂點也不是不可能,因為都成精了。

也許 DHH 已經成精了,所以他才會說 Test First 對他來說以死。但是!但是!還沒有接觸過 Test First 的開發者千萬不要傻傻的就放棄這個強大的武器。不管你的學習歷程如何,結論是否接受 Test First,如果你訓練自己走到了第三階段,它會內化為你開發思維的一部份,你會知道什麼樣的程式架構測試會好寫,而這樣的程式通常是設計優良的。放著這個練好內功的秘笈不練很可惜啊。

Evil Mock

在進行 Unit Test 時,使用 Mock 是很常見的技巧,它可以讓你專注在測試的主體,而不是那些外部的元件。例如一個 AccountService 提供註冊的功能,裡面會用到 AccountDao 來幫忙處理資料庫的操作,這時測試 AccountService 就可以使用假的 (mock) AccountDao,不用真的去寫資料庫。

不過,我的經驗告訴我,Mock 用越多,測試就越沒效。而沒有用的測試佔據大部份的程式碼,只會徒增維護的成本,讓你更不想寫測試。Mock 用很多的測試碼,會長的跟實際程式碼很像,簡直是一對一的複製。程式碼兩邊長一樣還算有效的測試嗎?只能算多 copy 一份而已。每次改需求時還要多改一份,這開什麼玩笑!

我個人吃過幾次虧:

  • 在某一專案完全使用 Mock,盡可能的用,好處是整個專案的測試一下就跑完了,爽的很。但我發現我開始會抗拒修改功能,因為我下意識的知道每次改都要大改測試的程式碼
  • 使用 Mock 的測試,很少抓到 bug,都是換寫成實際依賴物件後才恍然大悟哪出錯了
  • 以前測試 DAO 時,曾採用 In memory DB (hypersonic) 來測試,想說比較快 (真的資料庫是 Oracle)。後來上線後才出了很多問題,用不同的資料庫做測試根本都是測假的。資料庫的測試很難寫的,好不容易寫好了,還抓不到 bug,嘔死了!

吃過虧就會學乖,我現在只會套一部份依賴物件為 Mock 。

要單元,還是整合測試?

用例子來討論吧,就前面的帳戶註冊的例子:

class AccountService {
   EmailSender emailSender;
   AmazonS3Uploader s3Uploader;
   AccountDao accountDao;
   void register(String name, File avatar, String email) {
      s3Uploader.upload(avatar);
      accountDao.saveUsername(name);
      emailSender.send(email, 'Welcome Cubie!');
   }
}

這個例子裡,我們要測試 register() ,它會上傳用戶的大頭照到 amazon s3,儲存用戶名字到資料庫,最後再寄一份認證信給用戶確認。如果是你,在測試中你會套哪幾個依賴物件為 Mock ?

我想根據每個人的經驗會有不同的答案。我的答案是 accountDao 用真的,而且是存到與線上同一版的的資料庫裡;s3Uploader 也會真的上傳到 s3 ,assert 時是真的下載來確認。而最後的 emailSender 才會選 Mock,原因是 Email 太難確認它的 side effect。不過我也不會這麼快放棄,我會想辦法去找可以內嵌在程式中的 email server,這樣就可以真的確認寄信成功。

這樣的測試老實說已經不算 Unit 了,算整合測試這層了。不過該測試還是可以在 JUnit/IDE 下跑,符合開發的節奏,我個人還能接受,只是跑起來慢了一點。我個人看重的是測試能不能帶給我 安全感,有安全感的測試才算是有效,才會心甘情願地投入時間去維護。

不過也是有例外啦,像是如果 s3Uploader 自己有個非常完整的測試,那偶而 Mock 一下它也是無妨。Mock 與否的判斷是需要一點經驗的。

最後,既然 register() 這個測試我選擇都玩真的,那麼像是 accountDao.saveUsername() 還寫不寫自己的 Unit Test 呢?一般的情況下我就選擇不寫了,因為已經有測試涵蓋到它的使用,不需要再重覆測,除非它本身還有很多種狀況,這時才會追加一個 AccountDaoTest 的 class 來測。

Over Testing

過度測試通常不會發生,比較常發生的是測試不夠。不過我有時候會故意寫過度測試,例如同個功能上,不斷出現 bug (但又不是同一個),這時候就是需要 refactor 的徵兆。為了避免錯誤一而再的發生,我會特別去寫大量的集中測試,涵蓋率破表。然後 bug 就完全消滅了,我還沒遇過瘋狂加測試後還有漏 bug 的。

well,我不會說你該用這技巧,我只是介紹也有這種瘋狂的寫法...

小結

我會運用 Test First 技巧來幫助我過難關,而不是放棄;我偏好整合型的測試,而非滿坑滿谷 Mock 虛假物件。這是我累積經驗後的答案,給各位一個參考和借鏡。TDD 已經發展的相當成熟了,但除非有更好的,更巨大的變革出現,現階段仍然是我最佳的選擇。