如果程式設計的世界,一切都是沒有副作用的純函式,事情也許會變得比較簡單一些,特別是在測試這一塊,不過實際上,程式中不可能只有無副作用的函式,也正是副作用讓程式得以與現實世界溝通。

然而,受過抽象化訓練的開發者,在試圖測試具有副作用的程式時,總會感覺到細節一點一滴在滲漏而不安,這或許不是件壞事!

測試副作用的困難

呼叫無副作用函式時,一組輸入對應一筆輸出,因此在定義測試案例時,相對簡單,也較容易針對介面來做測試,而不用涉及實作細節,寫程式時,也就容易做出有良好封裝、良好抽象的設計。

然而,如果呼叫的是有副作用的函式(方法或模組),由於程式的執行結果,有一部份是輸出至另一個執行環境,像是螢幕、檔案、網路等裝置,而不是在執行過後,將結果自動返回呼叫方的手上,為了取得這脫離控制的輸出,就必須想方設法地將輸出撈回來,或者定義另一執行環境若產生了何種變化,就算是程式執行符合預期,而通過測試。

如果使用的工具、程式庫或框架,提供測試用的掛勾,可以撈到副作用輸出,那會省不少功夫,像是django.test的TestCase,就提供assertTemplateUsed,可以斷言回應(response)物件中,是否包含預期之結果或是assertTemplateUsed,可以斷言是否使用了指定的範本等。

在自行定義另一個執行環境變化這部份,最常見的例子則是新增了資料到資料庫之後,斷言資料庫中會有幾筆資料產生,這通常會是再查詢一次資料庫來確認,有時也得撈出每個欄位來檢查一遍。

不過,這就得接觸到一些實作細節了,而且並非每個副作用都可以很容易地撈回或定義其變化,而使得測試產生困難,這特別容易發生在接近使用者介面的副作用,多少都得接觸一些細節,像是測試網頁時,不得不接觸到DOM。

而最極端的例子之一,可能就是繪圖結果的呈現。舉例來說,肉眼確認畫出來的模型是否符合預期,在多數的情況下,會比事先定義輸入輸出,使用程式來判斷測試結果是否符合預期來得簡單,而且,事先算出想要的模型上每個網線(mesh)的頂點,並不是不可能,只是太沒效率。

有時為了要測試副作用,就不得不針對內部實作做測試,或者為了測試而更改設計,有些時候,為了測試,可能會引導到好的設計,然而並非每次都會如此。最大的疑慮之一,就是有可能破壞封裝,面對有副作用程式,有時總不得不將手穿過介面的另一端,觸碰到一些實作細節。

介面不是抽象

在設計程式庫或框架時,會定義相關的介面,也就是客戶端呼叫程式庫時會使用的函式、模組、類別等公開的部份;程式撰寫時,也有「針對介面而不是針對實作」的原則,感覺介面就是一層抽象,所有的細節都隱藏這層抽象之後,僅透過介面的名稱、參數與傳回值,透露出其表面的意涵,呼叫者不用理會底層規則。

然而,〈抽象滲漏法則〉中告訴我們:「所有重大的抽象機制在某種程式上都是有漏洞的」,實際上,不用等到程式層層堆疊累積出越來越多的抽象滲漏,當想要透過某個介面來測試其實作行為時,特別是針對一個有副作用的介面測試時,就會開始察覺到細節的滲漏了。

那麼,就別把介面當抽象,只當做一種溝通的管道如何?如果過程中需要透露點細節,那麼就考慮讓它暴露出來,當然,不需要客戶端知曉的細節,還是應該隱藏在介面之下,這是設計的基本原則,只不過若把介面完全當成是抽象,試圖阻止一切滲漏,可能就是進入了誤區,以為介面就是抽象,在〈Interfaces are not abstractions〉(https://goo.gl/NFeV3S)就談到了:介面與抽象就像兩個集合,只有一部份是重疊的。

擺脫了「介面就是抽象」的想法,測試有副作用的函式或模組等,就會浮現一些可行的思路了,因為不把介面當抽象,只當做一種溝通的管道,設計上就應該告知客戶端,在這介面下我會幫你處理掉哪些細節,至於輸入與輸出各代表什麼,你還是需要知道這些細節。

有了這樣的想法,在測試時就算要進入到程式碼內部,直接使用assert來做斷言,或者建立一些Stub以便測試,也不用覺得這是在滲漏什麼實作細節,或者因為針對實作而測試而感到不安。

也許為了程式碼的可讀性,將函式或模組中某個流程抽取出來,而成為一個private的API,如果覺得它需要測試,以作為日後維護時的防線,那麼,就測試吧!

無副作用就不會滲漏?

利用寫測試來察覺抽象滲漏,以及決定哪些可以滲漏,除了可以去除介面就是抽象的迷思,另一個好處就是,利用測試作為一種文件形式,以記載需要溝通的細節。

實際上,無副作用的函式,也不代表不會有抽象滲漏的問題,像是想設計一個傳回曲線座標點的無副作用函式時,無論再怎麼隱藏細節,控制點的輸入與點座標的密度設定,可能就暴露這函式實作了貝茲曲線,既然如此,何不一開始就表明這實作了貝茲曲線?為了方便,點座標的密度設定等參數上會提供預設值,然而必要時也可以自行調整,以控制更多的細節。

在測試時,也會有測試覆蓋率高低的問題,如果想要有更好的覆蓋率高低,就得洩漏更多細節了。

因此,無論是副作用或無副作用函式,實際上都會有抽象滲漏的問題,單純地呼叫使用這些函式也許不容易察覺,當試著對它們進行測試時就會發現,不觸及這些細節,測試有時就會難以進行。

無論信奉哪種測試方法論,有測試總是就有個後盾,即使Rails創建者David Heinemeier Hansson發表了挑起戰火的〈TDD is dead. Long live testing.〉(https://goo.gl/ci3xzO),他也只是反對測試驅動,而不是在反對測試。

如果測試時發現一些本來就沒辦法隱藏的細節,又何必勉強遮遮掩掩,不如考慮將滲漏的細節明確公開,以測試案例明確地記載下來,除了令程式變得可測之外,還能更進一步地,減少未來因為抽象滲漏而帶來的問題。

單元測試是抽象滲漏

將測試用來作為抽象滲漏的偵測的這個想法,在其他地方也見得到,通常會是指單元測試。

例如,許多地方都會看到「單元測試是一種白箱測試」的說法(實際上,還是要看測試案例的建立方式而定),而在〈The Fallacy of Unit Testing〉(https://goo.gl/qY09Dl)中,就直接指出:「單元測試是抽象滲漏,任何單元測試幫忙檢查或撐起的複雜度,實際上並沒有被隱藏起來」。

〈The Fallacy of Unit Testing〉的論述情境,是設定在建立更符合客戶需求的架構,因此建議撰寫更多功能測試(Functional test)之類的黑箱測試,並減少單元測試,因為撰寫過多的單元測試,會導入更多以開發者為主的契約,而使得最後架構偏離了實際的客戶需求。

然而,與其下結論,認為使用測試揭露抽象滲漏是一把雙面刃,不如說是多一種思考的工具,因為測試時若揭露出某種程度的抽象滲漏,就給了一次思考的機會,想想「這個測試是必要的嗎?」、「這個揭露是可行的嗎?」或者「最好將之隱藏起來呢?」

與其事後才察覺到有重大抽象滲漏而措手不及,不如能針對被揭露的抽象滲漏先做一次思考,事先決定好將之隱藏、揭露,或是更改設計。

 

專欄作者

熱門新聞

Advertisement