改善單元測試的新方法
我們為什么要寫單元測試?
“滿足需求”是所有軟件存在的必要條件,單元測試一定是為它服務的。從這一點出發,我們可以總結出寫單元測試的兩個動機: 驅動(如:TDD)和驗證功能實現。 另外,軟件需求“易變”的特征決定了修改代碼成為必然,在這種情況下,單元測試能保護已有的功能不被破壞。
基于以上兩點共識,我們看看傳統的單元測試有什么特征?
基于用例的測試(By Example)
單元測試最常見的套路就是Given、When、Then三部曲。
- Given:初始狀態或前置條件
- When:行為發生
- Then:斷言結果
編寫時,我們會精心準備(Given)一組輸入數據,然后在調用行為后,斷言返回的結果與預期相符。這種基于用例的測試方式在開發(包括TDD)過程中十分好用。因為它清晰地定義了輸入輸出,而且大部分情況下體量都很小、容易理解。
但這樣的測試方式也有壞處。
- 第一點在于測試的意圖。用例太過具體,我們就很容易忽略自己的測試意圖。 比如我曾經看過有人在寫計算器kata程序的時候,將其中的一個測試命名為“return 3 when add 1 and 2”,這樣的命名其實掩蓋了測試用例背后的真實意圖——傳入兩個整型參數,調用add方法之后得到的結果應該是兩者之和。我們常說測試即文檔,既然是文檔就應該明確描述待測方法的行為,而不是陳述一個例子。
- 第二點在于測試完備性。因為省事省心并且回報率高,我們更樂于寫happy path的代碼。 盡管出于職業道德,我們也會找一個明顯的異常路徑進行測試,不過這還遠遠不夠。
為了輔助單元測試改善這兩點。我這里介紹另一種測試方式——生成式測試(Generative Testing,也稱Property-Based Testing)。這種測試方式會基于輸入假設輸出,并且生成許多可能的數據來驗證假設的正確性。
生成式測試
對于第一個問題,我們換種思路思考一下。假設我們不寫具體的測試用例,而是直接描述意圖,那么問題也就迎刃而解了。想法很美好,但如何實踐Given、When、Then呢?答案是讓程序自動生成入參并驗證結果。這也就引出“生成式測試”的概念——我們先聲明傳入數據可能的情況,然后使用生成器生成符合入參情況的數據,調用待測方法,最后進行驗證。
Given階段
Clojure 1.9(Alpha)新內置的Clojure.spec可以很輕松地做到這點:
;; 定義輸入參數的可能情況:兩個整型參數 (s/def ::add-operators (s/cat :a int? :b int?)) ;; 嘗試生成數據 (gen/generate (s/gen ::add-operators)) ;; 生成的數據 -> (1 -122)
首先,我們嘗試聲明兩個參數可能出現的情況或者稱為規格(specification),即參數a和b都是整數。然后調用生成器產生一對整數。整個分析和構造的過程中,都沒有涉及具體的數據,這樣會強制我們揣摩輸入數據可能的模樣,而且也能避免測試意圖被掩蓋掉——正如前面所說,return 3 when add 1 and 2并不代表什么,return the sum of two integers才具有普遍意義。
Then階段
數據是生成了,待測方法也可以調用,但是Then這個斷言階段又讓人頭疼了,因為我們根本沒法預知生成的數據,也就無法知道正確的結果,怎么斷言?
拿定義好的加法運算為例:
(defn add [a b] (+ a b))
我們嘗試把斷言改成一個全稱命題: 任取兩個整數a、b,a和b加起來的結果總是a、b之和。 借助test.check,我們在Clojure可以這樣表達:
(def test-add (prop/for-all [a (gen/int) b (gen/int)] (= (add a b) (+ a b))))
不過,我們把add方法的實現(+ a b)寫到了斷言里,這幾乎喪失了單元測試的基本意義。換一種斷言方式,我們使用加法的逆運算進行描述: 任取兩個整數,把a和b加起來的結果減去a總會得到b。
(def test-add (prop/for-all [a (gen/int) b (gen/int)] (= (- (add a b) a) b))))
我們通過程序陳述了一個已知的真命題。變換以后,就可以使用quick-check對多組生成的整數進行測試。
;; 隨機生成100組數據測試add方法 (tc/quick-check 100 test-add);; 測試結果 -> {:result true, :num-tests 100, :seed 1477285296502}</pre>
測試結果表明,剛才運行了100組測試,并且都通過了。理論上,程序可以生成無數的測試數據來驗證add方法的正確性。即便不能窮盡,我們也獲得一組統計上的數字,而不僅僅是幾個純手工挑選的用例。
至于第二個問題,首先得明確測試是無法做到完備的。很多指導方法保證使用較少的用例做到有效覆蓋,比如:等價類、邊界值、判定表、因果圖、pairwise等等。但是在實際使用過程當中,依然存在問題。舉個例子,假如我們有一個接收自然數并直接返回這個參數的方法identity-nat,那么對于輸入參數而言,全體自然數都互為等價類,其中的一個有效等價類可以是自然數1;假定入參被限定在整數范圍,我們很容易找到一個無效等價類,比如-1。 用Clojure測試代碼表現出來:
(deftest test-with-identity-nat (testing "identity of natural integers" (is (= 1 (identity-nat 1)))) (testing "throw exception for non-natural integers" (is (thrown? RuntimeException (identity-nat -1)))))不過如果有人修改了方法identity-nat的實現,單獨處理入參為0的情況,這個測試還是能夠照常通過。也就是說,實現發生改變,基于等價類的測試有可能起不到防護作用。當然你完全可以反駁:規則改變導致等價類也需要重新定義。道理確實如此,但是反過來想想,我們寫測試的目的不正是構建一張安全網嗎?我們信任測試能在代碼變動時給予警告,但此處它失信了,這就尷尬了。
如果使用生成式測試,我們規定:
任取一個自然數a,在其上調用identity-nat的結果總是返回a。
(def test-identity-nat (prop/for-all [a (s/gen nat-int?)] (= a (identity-nat a))))(tc/quick-check 100 test-identity-nat)
-> {:result false, :seed 1477362396044, :failing-size 0, :num-tests 1, :fail [0], :shrunk {:total-nodes-visited 0, :depth 0, :result false, :smallest [0]}}</pre>
這個測試嘗試對100組生成的自然數(nat-int?)進行測試,但首次運行就發現代碼發生過變動。失敗的數據是0,而且還給出了最小失敗集[0]。拿著這個最小失敗集,我們就可以快速地重現失敗用例,從而修正。
當然也存在這樣的可能:在一次運行中,我們的測試無法發現失敗的用例。但是,如果100個測試用例都通過了,至少表明我們程序對于100個隨機的自然數都是正確的,和基于用例的測試相比,這就如同編織出一道更加緊密的安全網——網孔越小,漏掉的情況也越少。
Clojure語言之父Rich Hickey推崇Simple Made Easy哲學,受其影響生成式測試在Clojure.spec中有更為簡約的表達。以上述為例:
(s/fdef identity-nat :args (s/cat :a nat-int?) ; 輸入參數的規格 :ret nat-int? ; 返回結果的規格 :fn #(= (:ret %) (-> % :args :a))) ; 入參和出參之間的約束(stest/check `identity-nat)</pre>
fdef宏定義了方法identity-nat的規格,默認情況下會基于參數的規格生成1000組數據進行生成式測試。除了這一好處,它還提供部分類型檢查的功能。
再談TDD
TDD(測試驅動開發)是一種驅動代碼實現和設計的過程。我們說要先有測試,再去實現;保證實現功能的前提下,重構代碼以達到較好的設計。整個過程就好比演繹推理,測試就是其中的證明步驟,而最終實現的功能則是證明的結果。
對于開發人員而言,基于用例的測試方式是友好的,因為它能簡單直接地表達實現的功能并保證其正確性。一旦進入紅、綠、重構的節(guai)奏(quan),開發人員根本停不下來,仿佛遁入一種心流狀態。只不過問題是,基于用例驅動出來的實現可能并不是恰好通過的。我們常常會發現,在寫完上組測試用例的實現之后,無需任何改動,下組測試照常能運行通過。換句話說,實現代碼可能做了多余的事情而我們卻渾然不知。在這種情況下,我們可以利用生成式測試準備大量符合規格的數據探測程序,以此檢查程序的健壯性,讓缺陷無處遁形。
凡是想到的情況都能測試,但是想不到情況也需要測試,這才是生成式測試的價值所在。有人把TDD概念化為“展示你的功能”(Show your work),而把生成式測試歸納為“檢查你的功能“(Check your work),我深以為然。
小結
回到我們寫單元測試的動機上:
1、驅動和驗證功能實現;
2、保護已有的功能不被破壞。
基于用例的單元測試和生成式測試在這兩點上是相輔相成的。我們可以借助它們盡可能早地發現更多的缺陷,避免它們逃逸到生產環境。 ThoughtWorks 2016年11月份的技術雷達把Clojure.spec移到了工具象限的評估環中,這表明值得我們對它作一番探究。另外,除了Clojure,其它語言也有相應的生成式測試的框架,你不妨在自己的項目中試一試。
來自:http://insights.thoughtworkers.org/improve-the-effectiveness-of-unit-testing/