單元測試開發的弱點與侷限

單元測試能解決的問題是:保持接口穩定,讓接口行為如同預期。

然而它能做的也就僅止於此,絕非提高程式穩定性與生產力的萬靈丹。

下面列出我對此問題的理解。包括:因為單元測試而引入的新問題,以及單元測試所無法覆蓋的層面。

需要大量程式碼與成本

可去 github 上面看看,許多專案的測試程式碼甚至比專案本身都大。舉例來說,各位可試著統計一下 eslint 專案中 tests 資料夾下的原碼行數,以及整個專案下其他地方的原碼行數,會得到一些有趣的數字。

因為需要撰寫大量測試程式碼,導致專案成本高。或是反過來說,專案成本不變,但更大比例的預算被分發到單元測試上,專案的涵蓋範圍必然變小。

穩定但犧牲功能範圍的專案不是壞事,有些專案本來就該穩定第一,功能其次。另外一個考量是——有些專案不穩定,會導致更多後續維護成本,比方說會涉及長期性資料的專案,如資料庫這種,要是程式故障,後續修復可修不完,還不如一開始就全力做測試。但,也並非每個專案都有這種特色。

許多開源專案要求貢獻代碼時要一併撰寫測試案例,這也提高了對貢獻代碼者的時間與技能上的要求,提高了貢獻門檻,也降低了貢獻意願。

有些東西很難測試

比方說一個有著特定外觀與動畫的網頁元件。要自動測試他的動畫在所有瀏覽器上都表現一樣,這就不太容易。

當然並不是做不到,然而這也會導致更大量的程式碼,與更高的成本與技能水平。

反封裝

大概所有人在學軟體工程時都會學會一個概念,那就是封裝。

然而單元測試期待開發者暴露內部接口,否則測試難度就會指數上升。

這邊舉個例子說明:假設專案中有 a(w), b(x), c(y), d(z) 四個函數,每個函數都可以輸入 TrueFalse,而回應也會基於輸入不同而有兩種,也就是兩種 input 對應兩種 output。

這四個函式都是內部函式,實際上是要在另一個大函式 F(w, x, y, z) 中使用的。定義可能類似於:

function F(w,x,y,z) {
return a(w) * (b(x) + c(y)) / d(z);
}

如果你分別對 a, b, c, d 寫單元測試,則每個函數有兩個狀況,要寫合計 2 * 4 = 8 個測試。但是如果你直接對 F() 寫單元測試,則輸入模式共有 2 ^ 4 = 16 種,要寫 16 個測試。8 與 16 的差異不重要,重要的是 *^ 的差異,測試複雜度會指數攀升。

這還只是個最簡單的例子,實務上,一個函數被包裹 4, 5 層,合計涉及到二三十個內部函式也不奇怪。單元測試面對這種狀況只有兩種解方:

  1. 暴露底層,讓測試簡化。(讓案例數從 2 ^ 4 變成 2 * 4
  2. 省略掉一些似乎可以不用測的案例。

一般來說兩者會合併使用,首先盡量直接曝露底層簡化複雜性,但是複雜性還是太高,所以省略一些好像不可能發生或開發者根本沒想過的測試案例。哪天哪個用戶突然發現某些邊際情況突破測試案例時,再趕快把新的測試案例加進去。現有進行單元測試的專案,全都是這麼做的,否則系統複雜度過高,根本不可行。

每多加一個案例,都能緩解問題發生的機率,這非常好;但無論寫了多少案例,它仍是不可靠的,這也是事實。除此之外,它還破壞了封裝,破壞了對外部的抽象與化簡、洩漏了內部細節,使得專案元件之間具有更高的耦合風險,並在某些時候導致一些奇怪的代碼——比方說明明應該是私有方法,卻被設成公有的,並造成後續開發者接口誤用——就為了測試。

反重構

單元測試會「測試輸入特定值時會不會得到特定的輸出」,換句話說,這是一種針對接口的測試。

有一種論點是,單元測試鼓勵重構,因為有單元測試確保你不會破壞現有接口,所以開發者可以在重構階段放心亂改,也不用怕改錯。

這話沒問題,但有個但書,那就是「你不能去改那個神聖的接口」——因為測試案例就是針對「現在的接口」寫的。你一改接口,原本的測試案例不就沒用,要重寫了嗎?

有些人認為「接口本來就不該輕易更動,所以這個問題應該絕少碰到」,這話也同樣正確。但是前面「反封裝」章節也說了,單元測試傾向於暴露原本根本不用暴露的底層接口,並針對這些接口來測試,換句話說,連內部私有函式的接口都難以改動。

程式本來就是函式套函式逐層套起來的,連內部函式的行為都碰不了,想重構……就算不是鬼扯蛋,也是束手束腳的——換言之,單元測試並不方便重構。他能讓你程式行為正確(如果沒有省略太多測試案例),但不方便重構。他能有效防止程式因不當修改而退化,但同樣阻擋了進化,對測試案例的每一分努力都會對前述兩者造成增強,兩者增強的程度本質上是等價的。

諷刺的是,具有單元測試的專案,越是傾向「省略掉一些似乎可以不用測的案例(換句話說,降低單元測試程度)」而非「暴露底層但完整測試」,在這方面遇到的麻煩也會越少。

我認為單元測試在維持重構穩定性這點上是有用的,但可能應該要在「想要重構」「已經有明確重構方針」的那時再獨立撰寫測試,如此就能邊重構邊測試向前相容性。而不是預先寫一堆,然後反而妨礙重構之路。

誰監視監視者

企圖維持程式可靠性,光靠單元測試是不足的,因為單元測試無法測試測試程式碼自身。

單元測試本身的缺陷,是無法被自動化測試的。無論是測試案例過少、測試案例選擇錯誤、或是把 is 測試誤寫成 equal 測試等等。如果測試程式碼有誤,測試結果也沒有意義。

這只能靠 review。雖然我們都期望自動化測試就能搞定一切,但只要這種問題存在,肉眼 review 就仍不可少。

這不是單元測試的缺點,只是他無法如許多人想像中那樣,承擔超出他所能承擔的責任。

單元測試的使用情境

考慮單元測試的目標:「保持接口穩定,接口行為如同預期」有些用例明顯值得為其撰寫測試:如網路 API、外部模塊接口、外部插件接口等。(儘管這些「單元」的複雜度或許會相當高)

但專案中絕大多數程式碼並非屬於這種狀況,對於除此之外的狀況,我認為單元測試應該被更加謹慎評估後,才導入使用,且也不應該對它有過大的期望。