由於有許多 IDE 或 mock framework 在各種語言中提供了黑魔法等級的作假方式(例如 Visual Studio 2010 提供的 accessor 功能,但在 VS2012 這功能被移除了),能針對直接相依的 static 的 API 作假,或是能直接存取 private 的 API 以便測試。
因此有蠻多人為了測試方便,就將原本 SUT 的待測程式抽了幾個 private function,並直接透過這類 API 的內容撰寫測試,因為顆粒度很小,就誤以為這叫做「單元測試」。
透過這篇文章來闡述一下,為什麼我不建議你「直接針對 private function 」進行測試。
單元測試的意義
一言以蔽之,「單元測試就是用來模擬外部如何使用測試目標物件,驗證其行為是否符合預期」。
因此,有個重點是:外部如何使用測試目標物件。
讓我們回到物件導向設計的封裝原則,封裝的用意在於:
- 隔離出物件的內部與外部。也就是定義「物件的邊界」,以及定義「外部可視部分」。
- 將外部使用端,不需要了解物件的內部資訊,封裝起來。也就是「封裝細節」。
- 將物件內部的變化,封裝起來。也就是「封裝變化」。
有了對單元測試與封裝的認知後,接下來說明,為什麼單元測試只需要針對測試目標物件 public 的行為,進行測試即可。
只測試 Public 行為?
根據單元測試的意義,以及封裝的用意,代表著「外部使用者原本就不需要了解,也根本不了解,測試目標物件非 public 的行為」。單元測試既然是模擬外部使用端的動作,那當然只針對測試目標物件 public 的行為進行模擬與驗證。
但一些朋友肯定有些疑惑,那非 public 的 method 該怎麼辦?不測嗎?那 code coverage 怎麼提昇?要怎麼知道這些非 public 的行為有沒如同預期般運作呢?
有這些疑問是正常的,因為我一開始也是有一模一樣的疑問,但開始接觸 TDD 之後,反而更加了解了 Unit Test 的本質。
所謂的非 public 的行為,其存在的原因,一定是因為某一些 public 的行為會用到這些 private 或 protected 的 method,如果物件中存在著跟 public method 無關的 private 或 protected method,那在設計上就是個問題,這些非 public 的 method 根本就沒有存在的意義。因為外部使用測試目標物件時,完全不會用到這些 method,就像宣告了變數卻不去使用它一樣,沒有意義。
而當 private 或 protected method 與 public method有關時,那針對 public method 的 Unit Test 便會涵蓋到這些 private 或 protected method,它們就是 public method 的一部分,對外部使用者來說,根本分辨不出來什麼是 private 或 protected,因為只關注在物件外部可視行為上。
所以,在實作單元測試上,倘若測試物件一個 public method 中,涵蓋了一個 private method,而 private method 中與外部物件或服務相依,那麼在測這個 public method 時,要連 private method 中相依的 interface ,都要撰寫 stub object 來模擬才行。但還是得強調一次,外部使用者是無法分清楚哪一部分是 public method 內容,哪一部分是非 public method。
總結上面的說法,非 public method 的測試涵蓋率,是依據 public method 呼叫時的 input 來決定。
有沒有可能,當 public method 該測的都測了,甚至 public method 主體內容涵蓋率都 100% 了,非 public 的部分涵蓋率卻很低?當然有可能,這時就該釐清一下,沒有被涵蓋到的部份,是屬於什麼樣的程式碼。
如果在非 public method 中,沒被測試覆蓋的部份,是防呆、斷言之類的程式碼,那麼是屬於正常的情況。因為可能在呼叫非 public method 之前,就已經先防呆了,導致非 public method 中的防呆永遠不會發生。但,因為系統的健壯性考量,該斷言、防呆、驗證的部份,還是不能少。因為不會知道未來其他方法呼叫前,有沒做好防呆的部份。
那麼,在 private 或 protected method 中,非防呆、斷言的程式碼,卻又沒被涵蓋到部分呢?這是個警訊,代表著這些程式碼可能是 over design,或是根本沒有用處。因為這個物件所有對外的行為,所有的可能性,都模擬過一次了,卻都不會用到這些沒被涵蓋到的程式碼,這不就代表「這些程式碼目前用不到」嗎?YAGNI 原則就是在說這件事:「You ain’t gonna need it !」
只要 public 的行為如同預期,即使 private 或 protected 的 method 是 hard-code,是很沒彈性,是很愚蠢的寫法,對外部使用來說,根本就不在乎,因為無感。
這也是 TDD 所提倡的精神,如果所有使用行為都符合預期,就代表功能完成了。而且依據測試來撰寫的 production code,幾乎不會出現測試涵蓋不到的 code,因為 production code 是為了滿足測試而撰寫的。不需要存在用不到的 production code,因此,也可以避免 over design 的情況。
※ 對於 Code Coverage 更多面向的討論,請參考:Code Coverage 使用方式
針對非 public 行為測試又如何?
上面那一段的說明,肯定還是無法說服所有人,「為什麼要把已經存在的功能移除?」
不用 accessor 的人大可不用,但已經在用,或真的得用的人,還是希望可以在 VS2012 中繼續使用。
回到封裝的用意上,「封裝變化」一直是物件導向設計中很重要的設計原則。那些針對 private 與 protected 進行單元測試的朋友,有沒有過「因為一些需求異動,導致單元測試程式就需要跟著重新調整、設計或修改,而且頻率與範圍導致測試的維護成本增加不少」的經驗。如果有,這就是為什麼不希望 developer 去針對非 public method 寫單元測試的原因。
著重在非 public method 的單元測試,說穿了只是寫給 developer 爽而已。因為要封裝變化,才會把這些內容變成 private 或 protected,以期望變化時對外部使用者來說,呈現無感,也就是降低耦合,也就是最小知識原則。
現在單元測試卻透過某些機制,來存取這些封裝起來的行為,不是自討苦吃嗎?原本就知道,這些東西很可能會一直變化,卻又去存取它,測試它,導致單元測試因此維護與異動頻率增加,這不就違背了封裝的用意?
對使用物件的角度來說,使用端根本不關心這些變化,卻因為單元測試用髒方法硬幹到這些不公開的行為,導致測試成本增加,進而導致一些不明就裡的 developer 喊出「測試很花成本,時間增加很多,很難維護」。我只想說:「這不是南北拳的問題,是你的問題。」
小結
說真的,剛知道 Visual Studio 2012 把 accessor 功能拿掉,我也一整個相當吃驚,覺得要強迫 developer 用 TDD 方式開發,也不用做到這麼絕吧。
但將物件導向的原則、TDD 的精神、單元測試的基本意義結合起來後,有了上述的思考歷程,就覺得只測試 public method,不建議測試 private 與 protected method,是一件正確且重要的事。
所以將這樣的思考與推論過程,分享給各位朋友參考,不一定完全符合 Visual Studio 2012 移除 accessor 的原因,這只是我自己的理解與想法而已,但從我一開始接觸單元測試,怎麼測 private method 就一直困擾我很久,雖說腦袋中有點輪廓,卻一直無法明確釐清。
可以的話,後面幾篇文章,會再針對 production code 的可測試性,來說明如何透過單元測試以及程式碼的可測試性,來檢驗與提昇程式碼的品質。