1. 程式人生 > >改善單元測試的新方法

改善單元測試的新方法

我們為什麼要寫單元測試?

“滿足需求”是所有軟體存在的必要條件,單元測試一定是為它服務的。從這一點出發,我們可以總結出寫單元測試的兩個動機:驅動(如:TDD)和驗證功能實現。另外,軟體需求“易變”的特徵決定了修改程式碼成為必然,在這種情況下,單元測試能保護已有的功能不被破壞。

基於以上兩點共識,我們看看傳統的單元測試有什麼特徵?

基於用例的測試(By Example)

單元測試最常見的套路就是Given、When、Then三部曲。

  • Given:初始狀態或前置條件
  • When:行為發生
  • Then:斷言結果

編寫時,我們會精心準備(Given)一組輸入資料,然後在呼叫行為後,斷言返回的結果與預期相符。這種基於用例的測試方式在開發(包括TDD)過程中十分好用。因為它清晰地定義了輸入輸出,而且大部分情況下體量都很小、容易理解。

但這樣的測試方式也有壞處。

  • 第一點在於測試的意圖。用例太過具體,我們就很容易忽略自己的測試意圖。比如我曾經看過有人在寫計算器kata程式的時候,將其中的一個測試命名為“return 3 when add 1 and 2”,這樣的命名其實掩蓋了測試用例背後的真實意圖——傳入兩個整型引數,呼叫add方法之後得到的結果應該是兩者之和。我們常說測試即文件,既然是文件就應該明確描述待測方法的行為,而不是陳述一個例子。
  • 第二點在於測試完備性。因為省事省心並且回報率高,我們更樂於寫happy path的程式碼。儘管出於職業道德,我們也會找一個明顯的異常路徑進行測試,不過這還遠遠不夠。

LC_Times_TheMathsOfWire_VF

為了輔助單元測試改善這兩點。我這裡介紹另一種測試方式——生成式測試(Generative Testing,也稱Property-Based Testing)。這種測試方式會基於輸入假設輸出,並且生成許多可能的資料來驗證假設的正確性。

生成式測試

對於第一個問題,我們換種思路思考一下。假設我們不寫具體的測試用例,而是直接描述意圖,那麼問題也就迎刃而解了。想法很美好,但如何實踐Given、When、Then呢?答案是讓程式自動生成入參並驗證結果。這也就引出“生成式測試”的概念——我們先宣告傳入資料可能的情況,然後使用生成器生成符合入參情況的資料,呼叫待測方法,最後進行驗證。

Given階段

Clojure 1.9(Alpha)新內建的Clojure.spec可以很輕鬆地做到這點:

123456 ;;定義輸入引數的可能情況:兩個整型引數(s/def::add-operators(s/cat:aint?:bint?));;嘗試生成資料(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這個斷言階段又讓人頭疼了,因為我們根本沒法預知生成的資料,也就無法知道正確的結果,怎麼斷言?

拿定義好的加法運算為例:

12 (defn add[ab](+ab))

我們嘗試把斷言改成一個全稱命題: 任取兩個整數a、b,a和b加起來的結果總是a、b之和。 藉助test.check,我們在Clojure可以這樣表達:

1234 (def test-add(prop/for-all[a(gen/int)b(gen/int)](=(addab)(+ab))))

不過,我們把add方法的實現(+ a b)寫到了斷言裡,這幾乎喪失了單元測試的基本意義。換一種斷言方式,我們使用加法的逆運算進行描述: 任取兩個整數,把a和b加起來的結果減去a總會得到b。

1234 (def test-add(prop/for-all[a(gen/int)b(gen/int)](=(-(addab)a)b))))

我們通過程式陳述了一個已知的真命題。變換以後,就可以使用quick-check對多組生成的整數進行測試。

123456 ;;隨機生成100組資料測試add方法(tc/quick-check100test-add);;測試結果->{:result true,:num-tests100,:seed1477285296502}

測試結果表明,剛才運行了100組測試,並且都通過了。理論上,程式可以生成無數的測試資料來驗證add方法的正確性。即便不能窮盡,我們也獲得一組統計上的數字,而不僅僅是幾個純手工挑選的用例。

至於第二個問題,首先得明確測試是無法做到完備的。很多指導方法保證使用較少的用例做到有效覆蓋,比如:等價類、邊界值、判定表、因果圖、pairwise等等。但是在實際使用過程當中,依然存在問題。舉個例子,假如我們有一個接收自然數並直接返回這個引數的方法identity-nat,那麼對於輸入引數而言,全體自然數都互為等價類,其中的一個有效等價類可以是自然數1;假定入參被限定在整數範圍,我們很容易找到一個無效等價類,比如-1。 用Clojure測試程式碼表現出來:

12345 (deftest test-with-identity-nat(testing"identity of natural integers"(is(=1(identity-nat1))))(testing"throw exception for non-natural integers"(is(thrown?RuntimeException(identity-nat-1)))))

不過如果有人修改了方法identity-nat的實現,單獨處理入參為0的情況,這個測試還是能夠照常通過。也就是說,實現發生改變,基於等價類的測試有可能起不到防護作用。當然你完全可以反駁:規則改變導致等價類也需要重新定義。道理確實如此,但是反過來想想,我們寫測試的目的不正是構建一張安全網嗎?我們信任測試能在程式碼變動時給予警告,但此處它失信了,這就尷尬了。

如果使用生成式測試,我們規定:

任取一個自然數a,在其上呼叫identity-nat的結果總是返回a。

1234567891011121314151617 (def test-identity-nat(prop/for-all[a(s/gen nat-int?)](=a(identity-nata))))(tc/quick-check100test-identity-nat)->{:result false,:seed1477362396044,:failing-size0,:num-tests1,:fail[0],:shrunk{:total-nodes-visited0,:depth0,:result false,:smallest[0]}}

這個測試嘗試對100組生成的自然數(nat-int?)進行測試,但首次執行就發現程式碼發生過變動。失敗的資料是0,而且還給出了最小失敗集[0]。拿著這個最小失敗集,我們就可以快速地重現失敗用例,從而修正。

當然也存在這樣的可能:在一次執行中,我們的測試無法發現失敗的用例。但是,如果100個測試用例都通過了,至少表明我們程式對於100個隨機的自然數都是正確的,和基於用例的測試相比,這就如同編織出一道更加緊密的安全網——網孔越小,漏掉的情況也越少。

Clojure語言之父Rich Hickey推崇Simple Made Easy哲學,受其影響生成式測試在Clojure.spec中有更為簡約的表達。以上述為例:

1234567 (s/fdef identity-nat:args(s/cat:anat-int?);輸入引數的規格:ret nat-int?;返回結果的規格:fn#(= (:ret %) (-> % :args :a))) ; 入參和出參之間的約束(stest/check`identity-nat)

fdef巨集定義了方法identity-nat的規格,預設情況下會基於引數的規格生成1000組資料進行生成式測試。除了這一好處,它還提供部分型別檢查的功能。

再談TDD2-gwt

TDD(測試驅動開發)是一種驅動程式碼實現和設計的過程。我們說要先有測試,再去實現;保證實現功能的前提下,重構程式碼以達到較好的設計。整個過程就好比演繹推理,測試就是其中的證明步驟,而最終實現的功能則是證明的結果。

對於開發人員而言,基於用例的測試方式是友好的,因為它能簡單直接地表達實現的功能並保證其正確性。一旦進入紅、綠、重構的節(guai)奏(quan),開發人員根本停不下來,彷彿遁入一種心流狀態。只不過問題是,基於用例驅動出來的實現可能並不是恰好通過的。我們常常會發現,在寫完上組測試用例的實現之後,無需任何改動,下組測試照常能執行通過。換句話說,實現程式碼可能做了多餘的事情而我們卻渾然不知。在這種情況下,我們可以利用生成式測試準備大量符合規格的資料探測程式,以此檢查程式的健壯性,讓缺陷無處遁形。

凡是想到的情況都能測試,但是想不到情況也需要測試,這才是生成式測試的價值所在。有人把TDD概念化為“展示你的功能”(Show your work),而把生成式測試歸納為“檢查你的功能“(Check your work),我深以為然。

小結

回到我們寫單元測試的動機上:

1、驅動和驗證功能實現;

2、保護已有的功能不被破壞。

基於用例的單元測試和生成式測試在這兩點上是相輔相成的。我們可以藉助它們儘可能早地發現更多的缺陷,避免它們逃逸到生產環境。 ThoughtWorks 2016年11月份的技術雷達把Clojure.spec移到了工具象限的評估環中,這表明值得我們對它作一番探究。

3-tech-radar

Clojure.spec是Clojure內建的一個新特性,它允許開發人員將資料結構用型別和其他驗證條件(例如允許的取值範圍)進行封裝。這種資料結構一旦建立,Clojure就能利用這種規格來為程式設計師提供大量的便利:自動生成的測試程式碼、合法性驗證、析構資料結構等等。Clojure.spec提供方法很有前景,它可以讓開發者在需要的時候,就能從型別和取值範圍中獲益。

另外,除了Clojure,其它語言也有相應的生成式測試的框架,你不妨在自己的專案中試一試。