1. 程式人生 > >使用Spock框架進行單元測試

使用Spock框架進行單元測試

1.摘要

最近一段時間接觸到了spock這個可以用於java和groovy專案的單元測試框架,寫了一段時間單測之後認為這個框架不錯,值得寫一篇文章推廣一下。

2.關於單元測試

很多人一談到單元測試就會想到xUnit框架。對於一些java新人來說,會用jUnit就是會寫單元測試,高階點的會搗鼓一下testng,然後就認為自己掌握了單元測試。

而實際上,很多人不怎麼會寫單元測試,甚至不知道單元測試究竟是幹什麼的。寫單元測試要比寫程式碼要難上許多,而這裡說的難度跟框架沒什麼關係。

所以,在開始介紹spock之前,需要先拋開框架,談談單元測試本身的事情。在理解了單元測試之後才能更清楚spock框架是什麼,以及它否能夠更優雅的解決你的問題。

2.1.1.單元測試是什麼

寫程式碼免不了要做測試,測試有很多種,對於java來說,最初級的就是寫個main函式執行一下看看結果,高階的可以用各種高大上的複雜的測試系統。每種測試都有它的關注點,比如測試功能是不是正確,或者執行狀態穩不穩定,或者能承受多少負載壓力,等等。

那麼所謂的單元測試是什麼?這裡直接引用維基百科上的詞條說明:

單元測試(又稱為模組測試, Unit Testing)是針對程式模組(軟體設計的最小單位)來進行正確性檢驗的測試工作。程式單元是應用的最小可測試部件。在過程化程式設計中,一個單元就是單個程式、函式、過程等;對於面向物件程式設計,最小單元就是方法,包括基類(超類)、抽象類、或者派生類(子類)中的方法。

所以,我眼中的“合格的”單元測試需要滿足幾個條件:

  1. 測試的是一個程式碼單元內部的邏輯,而不是各模組之間的互動。
  2. 無依賴,不需要實際執行環境就可以測試程式碼。
  3. 執行效率高,可以隨時執行。

2.1.2.單元測試的定位

瞭解了單元測試是什麼之後,第二個問題就是:單元測試是用來做什麼的?

很多人第一反應是“看看程式有沒有問題”,或者“確保沒有bug”。單元測試確實可以測試程式有沒有問題,但是,從我個人程式設計的經驗來看,大部分情況下只是使用單元測試來“看看程式有沒有問題”的話,效率反而不如把程式執行起來直接檢視結果。原因有兩個:

  1. 單元測試要寫額外的程式碼,而不寫單元測試,直接執行程式也可以測試程式有沒有問題。
  2. 即使通過了單元測試,程式在實際執行的時候仍然有可能出問題。

但是,很多時候直接啟動程式測試會比較慢,所以一些同學為了解決這個問題,採用了一個折中的辦法:只加載要測試的模組和它所有的依賴模組,比如在測試時只加載這個模組相關的spring的配置檔案。這時所謂的單元測試實際上是用xUnit框架執行的整合測試,並沒有體現“單元”的概念。

而關於“純粹的單元測試”在介紹語言或者框架的書裡很少被提起,反而是介紹重構或者敏捷開發的書裡經常會看到各種各樣的關於單元測試的介紹。

在這裡我總結了一下幾個比較常見的單元測試的幾個典型場景:

  1. 開發前寫單元測試,通過測試描述需求,由測試驅動開發。
  2. 在開發過程中及時得到反饋,提前發現問題。
  3. 應用於自動化構建或持續整合流程,對每次程式碼修改做迴歸測試。
  4. 作為重構的基礎,驗證重構是否可靠。

還有最重要的一點:編寫單元測試的難易程度能夠直接反應出程式碼的設計水平,能寫出單元測試和寫不出單元測試之間體現了程式設計能力上的巨大的鴻溝。無論是什麼樣的程式設計師,堅持編寫一段時間的單元測試之後,都會明顯感受到程式碼設計能力的巨大提升。

2.2.單元測試的痛點

對於新人來說,很容易在編寫單元測試的時候遇到這幾類問題:

2.2.1.單元測試的資料不夠全

這裡不夠全是相對於“編碼”來說的。介紹如何編碼、如何使用某個框架的書茫茫多,但是與編碼同樣重要的介紹單元測試的書卻不多,翻來覆去好的也不多,並且都有一定年頭了。(如果有這方面的好的資料,請推薦給我,多謝)

很多關於程式設計的書籍中並沒有深入介紹如何進行單元測試,或者僅僅介紹了最基礎的assert、jUnit裡怎麼定義一個測試函式之類,就沒有然後了,給人的感覺是這樣:

2.2.2.單元測試難以理解和維護

測試程式碼不像普通的應用程式一樣有著很明確的作為“值”的輸入和輸出。舉個例子,假如一個普通的函式要做下面這件事情:

  • 接收一個user物件作為引數
  • 呼叫dao層的update方法更新使用者屬性
  • 返回true/false結果

那麼,只需要在函式中宣告一個引數、做一次呼叫、返回一個布林值就可以了。但如果要對這個函式做一個“純粹的”單元測試,那麼它的輸入和輸出會有很多情況,比如其中一個測試是這樣:

  • 假設呼叫dao層的update方法會返回true。
  • 程式去呼叫service層的update方法。
  • 驗證一下service是不是也返回了true。

無論是用什麼樣的單元測試框架,最後寫出來的單元測試程式碼量也比業務程式碼只多不少,我在寫程式碼過程中的經驗值是:要在不作弊的情況下維持比較高的單元測試覆蓋率,要有三倍於業務程式碼的單測程式碼。

更多的程式碼量,加上單測程式碼並不像業務程式碼那樣直觀,還有對單測程式碼可讀性不重視的壞習慣,導致最終呈現出來的單測程式碼難以閱讀,要維護更是難上加難。

同時,大部分單元測試的框架都有很強的程式碼侵入性。要理解單元測試,首先得學習他用的那個單元測試框架,這無形中又增加了單元測試理解和維護的難度。

2.2.3.單元測試難以去除依賴

就像之前說的,如果要寫一個純粹的、無依賴的單元測試往往很困難,比如依賴了資料庫、或者依賴了檔案系統、再或者依賴了其它模組。

所以很多人在寫單元測試時選擇依賴一部分資源,比如在本機啟動一個數據庫。這類所謂的“單元測試”往往很流行,但是對於多人合作的專案,這類測試卻經常容易造成混亂。

比如說要在本地讀個檔案,或者連線某個資料庫,其他修改程式碼的人(或者持續整合系統中)並沒有這些東西,所以測試也都沒法通過。最後大部分這類測試程式碼的下場都是用不了、也捨不得刪,只好被註釋掉,扔在那裡。

隨著開源專案逐漸發展,對外部資源的依賴問題開始可以通過一些測試輔助工具解決,比如使用記憶體型資料庫H2代替連線實際的測試資料庫,不過能替代的資源型別始終有限。

而實際工作過程中,還有一類難以處理的依賴問題:程式碼依賴。比如一個物件的方法中呼叫了其它物件的方法,其它物件又呼叫了更多物件,最後形成了一個無比巨大的呼叫樹。

很多比較舊的描述單元測試的書裡寫了一些傳統的辦法,這類方法基本上是先對耦合的部分做模擬,再對結果部分做斷言。例如可以通過繼承來自己做一個假的stub物件,最終用assert的方式驗證正確性。但是這相當於對於每種假設都要做一個假的物件,而且對結果進行驗證也比較複雜:比如我要驗證“更新”操作是否真的呼叫了dao層,那麼要自己在stub物件裡對呼叫進行計數,驗證時再對計數進行斷言,非常繁瑣。

後來出現了一些mock框架,比如java的JMockit、EasyMock,或者Mockito。利用這類框架可以相對比較輕鬆的通過mock方式去做假設和驗證,相對於之前的方式有了質的飛躍,但是即使用上這類框架,遇到複雜的業務程式碼往往也無能為力。

而往往新人的程式碼質量往往不高,尤其是對程式碼的拆分和邏輯的抽象還處於懵懂階段。要對這類程式碼寫單測,即使是工作了3,4年的高階碼農也是一個挑戰,對新人來說幾乎是不可能完成的任務。這也讓很多新人有了“寫單測很難”的感覺。

所以在這裡需要強調一個觀點,寫單元測試的難易程度跟程式碼的質量關係最大,並且是決定性的。專案裡無論用了哪個測試框架都不能解決程式碼本身難以測試的問題,所以如果你遇到的是“我的程式碼裡依賴的東西太多了所以寫不出來單測”這樣的問題的話,需要去看的是如何設計和重構程式碼,而不是這篇文章。

2.3.推薦閱讀

  • 重構-改善既有程式碼的設計
  • 修改程式碼的藝術
  • 敏捷軟體開發:原則、模式與實踐

3.Spock是什麼

3.1.簡介

這裡引用官方的介紹:

Spock is a testing and specification framework for Java and Groovy applications. What makes it stand out from the crowd is its beautiful and highly expressive specification language. Thanks to its JUnit runner, Spock is compatible with most IDEs, build tools, and continuous integration servers. Spock is inspired from JUnit, jMock, RSpec, Groovy, Scala, Vulcans, and other fascinating life forms.

簡單地說,spock是一個測試框架,它的核心特性有以下幾個:

  • 可以應用於java或groovy應用的單元測試框架。
  • 測試程式碼使用基於groovy語言擴充套件而成的規範說明語言(specification language)。
  • 通過junit runner呼叫測試,相容絕大部分junit的執行場景(ide,構建工具,持續整合等)。
  • 框架的設計思路參考了JUnit,jMock,RSpec,Groovy,Scala,Vulcans……

要理解spock的幾個特性,還要理解幾個關鍵名詞:

3.1.1.groovy

引用維基百科上的介紹:

Groovy是Java平臺上設計的面向物件程式語言。這門動態語言擁有類似Python、Ruby和Smalltalk中的一些特性,可以作為Java平臺的指令碼語言使用。

Groovy的語法與Java非常相似,以至於多數的Java程式碼也是正確的Groovy程式碼。Groovy程式碼動態的被編譯器轉換成Java位元組碼。由於其執行在JVM上的特性,Groovy可以使用其他Java語言編寫的庫。

groovy是一門比較輕量,學習門檻也比較低的語言。對於只用過java語言的程式設計師來說,groovy是一個很不錯的開拓視野的機會。如果你沒有接觸過groovy,那麼可以參考這兩條:

  1. 可以用純java的語法寫groovy。
  2. 參考這篇快速入門。

我個人比較喜歡groovy語言,在一些小專案中經常使用它。引用一下R大在知乎的回覆:

Groovy比較討好來自Java的程式設計師的一點是:用它寫程式碼可以漸進的從接近Java的風格進化為接近Ruby的風格。使用接近Java風格寫Groovy時,程式碼幾乎跟Java一樣,容易上手;而學習過程中可以逐漸用上各種類似Ruby的方便功能。

3.1.2.specification language

如果接觸過不同語言型別的開源專案的話,就會發現有些專案中找不到測試目錄(test),取而代之的是一個叫“spec”的目錄,比如用ruby寫的專案gitlab。這裡的spec實際是specification的縮寫,它的背後是一種近些年來開始流行起來的程式設計思想:BDD(Behavior-driven development)。

關於BDD,同樣是引用維基百科上的介紹:

BDD:行為驅動開發是一種敏捷軟體開發的技術,它鼓勵軟體專案中的開發者、QA和非技術人員或商業參與者之間的協作。BDD最初是由Dan North在2003年命名,它包括驗收測試和客戶測試驅動等的極限程式設計的實踐,作為對測試驅動開發的迴應。

BDD的做法包括:

  • 確立不同利益相關者要實現的遠景目標
  • 使用特性注入方法繪製出達到這些目標所需要的特性
  • 通過由外及內的軟體開發方法,把涉及到的利益相關者融入到實現的過程中
  • 使用例子來描述應用程式的行為或程式碼的每個單元
  • 通過自動執行這些例子,提供快速反饋,進行迴歸測試
  • 使用“應當(should)”來描述軟體的行為,以幫助闡明程式碼的職責,以及回答對該軟體的功能性的質疑
  • 使用“確保(ensure)”來描述軟體的職責,以把程式碼本身的效用與其他單元(element)程式碼帶來的邊際效用中區分出來。
  • 使用mock作為還未編寫的相關程式碼模組的替身

BDD背後的程式設計思想超出了這篇文章的範圍,這裡就不再展開。上文說的specification language實際上是BDD其中一部分思想的實現手段:通過某種規範說明語言去描述程式“應該”做什麼,再通過一個測試框架讀取這些描述、並驗證應用程式是否符合預期。

3.1.3.單元測試的執行場景

測試只有被執行之後才會有價值,這裡就涉及一個“什麼時候執行單元測試”的問題。

1.被接觸最多的就是在IDE中執行單元測試,java程式設計師比較幸運,主流的java IDE都可以很好的集成了單元測試功能,單元測試程式碼自動生成、測試覆蓋率檢查等功能也都成了IDE的標配。這些功能都能讓程式設計師在編寫程式碼的時候直接可以執行單元測試得到反饋。

2.其次,主流的構建工具(如maven、gradle)中也都實現了執行單元測試的功能,在生成二進位制包之前可以對程式碼進行迴歸測試,這些構建工具都可以通過命令列呼叫,這是自動化構建的前提。

3.在此之上,依託於構建工具提供的自動化特性,在持續整合、持續部署的過程中可以執行自動化構建,在自動化構建的過程中通過構建工具執行單元測試,這是持續整合的流程中的重要步驟。

3.2.Spock與現有框架的對比

3.2.1.已有的java單元測試框架

就像剛才說的,有很多已有的單元測試框架,稍微老一點的如JMockit、EasyMock,新一點的類似Mockito和PowerMock。我之前一直在用testng+Mockito作為主要的單元測試框架,用它寫過大概上萬行單元測試,它的寫法相對來說比較易讀,功能也能滿足大多數場景。

但在使用mockito的過程中也總是有一些不是很方便的地方,比如程式碼的可讀性總還是差那麼一點,比如像這樣:

Java
12345678910 <ahref="http://www.jobbole.com/members/madao">@Test</a>publicvoidtestIsUserEnabled_userStatusIsClosed_returnFalse()throwsException{UserInfo userInfo=newUserInfo();userInfo.status=UserInfo.CLOSED;doReturn(userInfo).when(userDao).getUserInfo(anyLong());booleanisUserEnabled=userService.isUserEnabled(1l);Assert.assertFalse(isUserEnabled);}

雖然能讀懂,但是對於它所做的事情全來說感覺說了很多廢話,單元測試程式碼總是裡充斥著各種when(),anyXXX(),return()之類囉嗦的關鍵詞,加上java本身就是一個囉嗦的強型別的語言,這讓寫單測和讀單測成為了一種體力活。

其次是單測資料,大部分測試都要提供資料,比如“當輸入a的時候應該返回b”,如果只有一組資料那麼沒什麼問題,但是當需要測試很多邊界條件,需要多組資料的時候就會比較糾結。

用jUnit或者testng的dataprovider可以實現這個需求,但是無論是通過xml定義還是通過函式返回資料,都非常不方便。

最後,因為這些框架都只是一些獨立的函式,沒有告訴你“應該怎麼寫單測”,所以不同的人最終寫出來的單測也是五花八門:

  • 有不用assert而是用system.out.println的
  • 有單測一個函式寫了好幾百行的
  • 有直接把單測當成main函式寫的

最終,團隊要接受“雖然確實寫了單測,然而這並沒有什麼卵用”的結果。

3.2.2.為什麼使用spock

還是剛才的例子,如果用spock寫的話:

12345678910111213141516 def&quot;isUserEnabled should returntrueonly ifuser status isenabled&quot;(){given:UserInfo userInfo=newUserInfo(status:actualUserStatus);userDao.getUserInfo(_)&gt;&gt;userInfo;expect:userService.isUserEnabled(1l)==expectedEnabled;where:actualUserStatus|expectedEnabledUserInfo.ENABLED|trueUserInfo.INIT|falseUserInfo.CLOSED|false}

這段程式碼實際是3個測試:當getUserInfo返回的使用者狀態分別為ENABLED、INIT和CLOSED時,驗證各自isUserEnabled函式的返回是否符合期待。

我對於spock框架最直接的感受:

  • spock框架使用標籤分隔單元測試中不同的程式碼,更加規範,也符合實際寫單元測試的思路
  • 程式碼寫起來更簡潔、優雅、易於理解
  • 由於使用groovy語言,所以也可以享受到指令碼語言帶來的便利
  • 底層基於jUnit,不需要額外的執行框架
  • 已經發布了1.0版本,基本沒有比較嚴重的bug

3.2.3.為什麼不用spock

用了一段時間的spock後,我也總結了幾個不用spock的理由:

  • 框架相對比較新,IDE的支援(尤其是eclipse)不如其它成熟的框架
  • groovy語言本身的compiler更新比較快,偶爾有坑(版本不相容等)
  • 需要了解groovy語言
  • 與其它java的測試框架風格相差比較大,需要適應

當然,這些理由比起spock提供的易於開發和維護的單元測試程式碼來說,都是可以忽略的。

4.使用Spock

寫到這裡,還是要聚焦一下這篇文章要討論的問題:如何用spock框架編寫單元測試,在此之前再強調一下:

  • 單元測試不一定非要使用spock,但是其它框架寫出的單元測試程式碼遠沒有用spock框架優雅。
  • spock框架並不只能寫單元測試,它也可以寫整合測試,甚至效能測試,但是後兩者spock相對於其它框架來說沒有什麼優勢。

4.1.關於開發環境

在使用spock框架時,我比較推薦的ide是IDEA,推薦的構建工具是gradle。

就算不使用spock框架,IDEA的順手程度也比eclipse好太多,對新技術的響應速度快,也沒有那麼多莫名其妙的嚴重bug,社群版免費但主要功能都有,沒有什麼理由不試用一下。

而gradle相對於maven來說配置簡化了很多,可定製的功能也更強,與其迷失在maven複雜的xml和一層套一層的依賴關係中,我寧願把時間做一些更有意思的事情。

由於IDE基本可以自由選擇,但構建工具大部分是由團隊決定的,而maven現在還是處於構建工具的領導地位,所以這篇文章裡的步驟都是基於IDEA+maven,當前的IDEA已經支援spock,不需要做什麼特殊配置。

  • 如果你的團隊應用了gradle,spock官網中對於gradle如何配置說的比較完整,可以直接參考官網。
  • 如果你執迷不悟非要使用eclipse,我在eclipse下也跑通了整個流程。需要安裝最新的groovy-eclipse外掛和附加包(安裝時選擇groovy2.4版以上的compiler),地址:https://github.com/groovy/groovy-eclipse/wiki

4.2.hello spock

前面做了那麼多鋪墊,終於到了真正編寫一個hello world的時候。

到這裡,我假設你是一位java開發者,並且已經瞭解基本的IDE及構建工具的使用。

1.建立一個空白專案:hello_spock,選擇maven工程。

2.在pom.xml中增加依賴:

123456789101112131415161718192021222324252627282930313233343536 &lt;?xml version=&quot;1.0&quot; encoding=&quot;UTF-8&quot;?&gt;&lt;project xmlns=&quot;http://maven.apache.org/POM/4.0.0&quot;xmlns:xsi=&quot;http://www.w3.org/2001/XMLSchema-instance&quot;xsi:schemaLocation=&quot;http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd&quot;&gt;    &lt;modelVersion&gt;4.0.0&lt;/modelVersion&gt;    &lt;groupId&gt;hello&lt;/groupId&gt;    &lt;artifactId&gt;hello_spock&lt;/artifactId&gt;    &lt;version&gt;1.0-SNAPSHOT&lt;/version&gt;    &lt;dependencies&gt;        &lt;!-- Mandatory dependencies for using Spock --&gt;        &lt;dependency&gt;             &lt;groupId&gt;org.spockframework&lt;/groupId&gt;            &lt;artifactId&gt;spock-core&lt;/artifactId&gt;            &lt;version&gt;1.0-groovy-2.4&lt;/version&gt;            &lt;scope&gt;test&lt;/scope&gt;        &lt;/dependency&gt;        &lt;!-- Optional dependencies for using Spock --&gt;        &lt;dependency&gt; &lt;!-- use a specific Groovy version rather than the one specified by spock-core --&gt;           &lt;groupId&gt;org.codehaus.groovy&lt;/groupId&gt;            &lt;artifactId&gt;groovy-all&lt;/artifactId&gt;            &lt;version&gt;2.4.3&lt;/version&gt;        &lt;/dependency&gt;        &lt;dependency&gt; &lt;!-- enables mocking of classes (in addition to interfaces) --&gt;            &lt;groupId&gt;cglib&lt;/groupId&gt;            &lt;artifactId&gt;cglib-nodep&lt;/artifactId&gt;            &lt;version&gt;3.1&lt;/version&gt;            &lt;scope&gt;test&lt;/scope&gt;        &lt;/dependency&gt;        &lt;dependency&gt;&lt;!-- enables mocking of classes without default constructor (together with CGLIB) --&gt;            &lt;groupId&gt;org.objenesis&lt;/groupId&gt;            &lt;artifactId&gt;objenesis&lt;/artifactId&gt;            &lt;version&gt;2.1&lt;/version&gt;           &lt;scope&gt;test&lt;/scope&gt;          &lt;/dependency&gt;    &lt;/dependencies&gt;&lt;/project&gt;

3.由於spock是基於groovy語言的,所以需要建立groovy的測試原始碼目錄:首先在test目錄下建立名為groovy的目錄,之後將它設為測試原始碼目錄。

4.建立一個簡單的類:

Java
12345