單元測試優化的實踐Generative Testing

首先爲何要寫單元測試?

「知足需求」是全部軟件存在的必要條件,單元測試必定是爲它服務的。從這一點出發,咱們能夠總結出寫單元測試的兩個動機:驅動(如:TDD)和驗證功能實現。另外,軟件需求「易變」的特徵決定了修改代碼成爲必然,在這種狀況下,單元測試能保護已有的功能不被破壞。node

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

基於用例的測試(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呢?答案是讓程序自動生成入參並驗證結果。這也就引出「生成式測試」的概念——咱們先聲明傳入數據可能的狀況,而後使用生成器生成符合入參狀況的數據,調用待測方法,最後進行驗證。bash

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才具備廣泛意義。app

Then階段

數據是生成了,待測方法也能夠調用,可是Then這個斷言階段又讓人頭疼了,由於咱們根本無法預知生成的數據,也就沒法知道正確的結果,怎麼斷言?框架

拿定義好的加法運算爲例:ide

(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}

複製代碼

測試結果代表,剛纔運行了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]}}
複製代碼

這個測試嘗試對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)
複製代碼

fdef宏定義了方法identity-nat的規格,默認狀況下會基於參數的規格生成1000組數據進行生成式測試。除了這一好處,它還提供部分類型檢查的功能。

再談TDD

若是對軟件測試、接口測試、自動化測試、性能測試、LR腳本開發、面試經驗交流。感興趣能夠175317069,羣內會有不按期的發放免費的資料連接,這些資料都是從各個技術網站蒐集、整理出來的,若是你有好的學習資料能夠私聊發我,我會註明出處以後分享給你們。

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

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

凡是想到的狀況都能測試,可是想不到狀況也須要測試,這纔是生成式測試的價值所在。有人把TDD概念化爲「展現你的功能」(Show your work),而把生成式測試概括爲「檢查你的功能「(Check your work),我深覺得然。

小結

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

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

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

基於用例的單元測試和生成式測試在這兩點上是相輔相成的。咱們能夠藉助它們儘量早地發現更多的缺陷,避免它們逃逸到生產環境。

Clojure.spec是Clojure內置的一個新特性,它容許開發人員將數據結構用類型和其餘驗證條件(例如容許的取值範圍)進行封裝。這種數據結構一旦創建,Clojure就能利用這種規格來爲程序員提供大量的便利:自動生成的測試代碼、合法性驗證、析構數據結構等等。Clojure.spec提供方法頗有前景,它可讓開發者在須要的時候,就能從類型和取值範圍中獲益。

另外,除了Clojure,其它語言也有相應的生成式測試的框架,你不妨在本身的項目中試一試。

相關文章
相關標籤/搜索