在以前的關於swagger文章裏提到過,程序員最討厭的兩件事,一件是別人不寫文檔,另外一件就是本身寫文檔。這裏若是把文檔換成單元測試也一樣成立。
每一個開發人員都明白單元測試的做用,也都知道代碼覆蓋率越高越好。高覆蓋率的代碼,相對來講出現 BUG 的機率就越低,在線上運行就越穩定,接的鍋也就越少,就也不會懼怕測試同事忽然的關心。
既然這麼多好處,爲何還會討厭他呢?至少在我看來,單測有以下幾點讓我喜歡不起來的理由。
第一,要額外寫不少不少的代碼,一個高覆蓋率的單測代碼,每每比你要測試的,真正開發的業務代碼要多,甚至是業務代碼的好幾倍。這讓人以爲難以接受,你想一想開發 5 分鐘,單測 2 小時是什麼樣的心情。並且並非單測寫完就沒事了,後面業務要是變動了,你所寫的單測代碼也要同步維護。
第二,即便你有那個耐心去寫單測,可是在當前這個拼速度擠時間的大環境下,會給你那麼多寫單測的時間嗎?寫一個單測的時間能夠實現一個需求,你會如何去選?
第三,寫單測一般是一件很無趣的事,由於他比較死,主要目的就是爲了驗證,相比之下他更像是個體力活,沒有真正寫業務代碼那種創造的成就感。寫出來,驗證不出bug很失落,白寫了,驗證出bug又感到本身是在打本身臉。css
因此獲得的結論就是不寫單測?那麼問題又來了,出來混早晚是要還的,上線出了問題,最終責任人是誰?不是提需求的產品、不是沒發現問題的測試同窗,他們頂多就是連帶責任。最該負責的確定是寫這段代碼的你。特別是對於那些從事金融、交易、電商等息息相關業務的開發人員,跟每行代碼打交通的都是真金白銀。每次明星搞事,微博就掛,已經被傳爲笑談,畢竟只是娛樂相關,若是掛的是支付寶、微信,那用戶就沒有那麼大的包容度了。這些業務若是出現嚴重問題,輕則掃地出門,而後整個職業生涯揹負這個污點,重則直接從面向對象開發變成面向監獄開發。因此單元測試保護的不只僅是程序,更保護的是寫程序的你。
最後得出了一個迫不得已的結論,單測是個讓人又愛又恨的東西,是不想作但又不得不作的事情。雖然咱們沒辦法改變要寫單測這件事,可是咱們能夠改變怎麼去寫單元測試這件事。html
固然,本文不是教你用旁門左道的方法提升代碼覆蓋率。而是經過一個神奇的框架 spock 去提升你編寫單元測試的效率。spock 這名稱來源,我的猜想是由於《星際迷航》的同名人物(封面圖)。那麼spock 是如何提升編寫單測的效率呢?我以爲有如下幾點:
第一,他能夠用更少的代碼去實現單元測試,讓你能夠更加專一於去驗證結果而不是寫單測代碼的過程。那麼他又是如何作到少寫代碼這件事呢?原來他使用一種叫作 groovy 的魔法。
groovy 實際上是一門基於 jvm 的動態語言。能夠簡單的理解成跑在 jvm 上的 python 或 js。說到這裏,可能沒有接觸過動態語言的同窗,對它們都會有一個比較刻板的印象,太過於靈活,很容易出現問題,且可維護性差,因此有了那一句『動態一時爽,全家 xxx』的梗。首先,這些的確是他的問題,嚴格的說是使用不當時才帶來的問題。因此主要仍是看使用的人。好比安卓領域的官方依賴管理工具 gradle 就是基於 groovy 開發的。
另外不要誤覺得我學這門框架,還要多學一門語言,成本太大。其實大可沒必要擔憂,你若是會 groovy 固然更好,若是不會也沒有關係。由於 groovy 是基於 java 的,因此徹底能夠放心大膽的使用 java 的語法,某些要用到的 groovy 獨有的語法不多,並且後面都會告訴你。
第二,他有更好的語義化,讓你的單測代碼可讀性更高。
語義化這個詞可能不太好理解。舉兩個例子來講吧,第一個是語義化比較好的語言 -- HTML。他的語法特色就是標籤,不一樣的類型放在不一樣的標籤裏。好比 head 就是頭部的信息,body 是主體內容的信息,table 就是表格的信息,對於沒有編程經驗的人來講,也能夠很容易理解。第二個是語義化比較差的語言 -- 正則。他能夠說基本上沒有語義這種東西,由此致使的直接問題就是,即便是你本身的寫的正則,幾天以後你都不知道當時寫的是什麼。好比下面這個正則,你能猜出他是什麼意思嗎?(能夠留言回覆)java
((?:(?:25[0-5]|2[0-4]\d|[01]?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d?\d))
<!--若是沒有使得 spring boot,如下包能夠省略--> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> <!--引入spock 核心包--> <dependency> <groupId>org.spockframework</groupId> <artifactId>spock-core</artifactId> <version>1.3-groovy-2.5</version> <scope>test</scope> </dependency> <!--引入spock 與 spring 集成包--> <dependency> <groupId>org.spockframework</groupId> <artifactId>spock-spring</artifactId> <version>1.3-groovy-2.5</version> <scope>test</scope> </dependency> <!--引入 groovy 依賴--> <dependency> <groupId>org.codehaus.groovy</groupId> <artifactId>groovy-all</artifactId> <version>2.5.7</version> <scope>test</scope> </dependency>
註釋已經標明,第一個包是 spring boot 項目須要使用的,若是你只是想使用 spock,只要最下面 3 個便可。其中第一個包 spock-core 提供了 spock 的核心功能,第二個包 spock-spring 提供了與 spring 的集成(不用 spring 的狀況下也能夠不引入)。 注意這兩個包的版本號 -> 1.3-groovy-2.5。第一個版本號 1.3 其實表明是 spock 的版本,第二個版本號表明的是 spock 所要依賴的 groovy 環境的版本。最後一個包就是咱們要依賴的 groovy 。python
/* * * * * blog.coder4j.cn * * Copyright (C) 2016-2019 All Rights Reserved. * */ package cn.coder4j.study.example.spock; /** * @author buhao * @version Calculator.java, v 0.1 2019-10-30 10:34 buhao */ public class Calculator { /** * 加操做 * * @param num1 * @param num2 * @return */ public static int add(int num1, int num2) { return num1 + num2; } /** * 整型除操做 * * @param num1 * @param num2 * @return */ public static int divideInt(int num1, int num2) { return num1 / num2; } /** * 浮點型操做 * @param num1 * @param num2 * @return */ public static double divideDouble(double num1, double num2){ return num1 / num2; } }
這是一個很簡單的計算器類。只寫了三個方法,一個是加法的操做、一個整型的除法操做、一個浮點類型的除法操做。git
class CalculatorTest extends Specification { }
這裏必定要注意,以前咱們已經說了 spock 是基於 groovy 。因此單測類的後綴不是 .java 而 .groovy。千萬不要建立成普通 java 類了。不然建立沒有問題,可是寫一些 groovy 語法會報錯。若是你用的是 IDEA 能夠經過以下方式建立,之前建立 Java 類咱們都是選擇第一個選項,如今咱們選擇第三個 Groovy Class 就能夠了。
另外就是 spock 的測試類須要繼承 spock.lang.Specification 類。程序員
def "test add"(){ expect: Calculator.add(1, 1) == 2 }
def 是 groovy 的關鍵字,能夠用來定義變量跟方法名。後面 "test add" 是你單元測試的名稱,也能夠用中文。最後重點說明的是 expect 這個關鍵字。
expect 字面上的意思是指望,咱們指望什麼樣的事情發生。在使用其它單測框架時,與之相似的是 assert 。好比 _Assert.assertEquals(_Calculator.add(_1 + 1), 2) _這樣,表示咱們斷言加操做傳入1 與 1 相加結果爲 2。若是結果是這樣則用例經過,若是不是則用例失敗。這與咱們上面的代碼功能上完成一致。
expect 的語法意義就是在 expect 的塊內,全部表達式成立則驗證經過,反之有任一個不成立則驗證失敗。這裏引入了一個塊的概念。怎麼理解 spock 的塊呢?咱們上面說 spock 有良好的語義化及更好的閱讀性就是由於這個塊的做用。能夠類比成 html 中的標籤。html 的標籤的範圍是兩個標籤之間,而 spock 更簡潔一點,從這個標籤開始到下一個標籤開始或代碼結束的地方,就是他的範圍。咱們只要看到 expect 這個標籤就明白,他的範圍內都是咱們預期要獲得的結果。github
這裏代碼比較簡單,參數我只使用了一次,因此直接寫死。若是想複用,我就得把這些參數抽成變量。這個時候可使用 spock 的 given 塊。given 的語法意義至關因而一個初始化的代碼塊。spring
def "test add with given"(){ given: def num1 = 1 def num2 = 1 def result = 2 expect: Calculator.add(num1, num2) == result }
固然你也能夠像下面這樣寫,可是嚴重不推薦,由於雖然能夠達到一樣的效果,可是不符合 spock 的語義。就像咱們通常是在 head 裏面引入 js、css,可是你在 body 或者任何標籤裏均可以引入,語法沒有問題可是破壞了語義,不便理解與維護。數據庫
// 反倒 def "test add with given"(){ expect: def num1 = 1 def num2 = 1 def result = 2 Calculator.add(num1, num2) == result }
若是你還想讓語義更好一點,咱們能夠把參數與結果分開定義,這個時候可使用 and 塊。它的語法功能能夠理解成同他上面最近的一個標籤。編程
def "test add with given and"(){ given: def num1 = 1 def num2 = 1 and: def result = 2 expect: Calculator.add(num1, num2) == result }
看了上面例子,可能以爲 spock 只是語義比較好,可是沒有少寫幾行代碼呀。別急,下面咱們就來看 spock 的一大殺器 where。
def "test add with expect where"(){ expect: Calculator.add(num1, num2) == result where: num1 | num2 | result 1 | 1 | 2 1 | 2 | 3 1 | 3 | 4 }
where 塊能夠理解成準備測試數據的地方,他能夠跟 expect 組合使用。上面代碼裏 expect 塊裏面定義了三個變量 num一、num二、result。這些數據咱們能夠在 where 塊裏定義。where 塊使用了一種很像 markdown 中表格的定義方法。第一行或者說表頭,列出了咱們要傳數據的變量名稱,這裏要與 expect 中對應,不能少可是能夠多。其它行都是數據行,與表頭同樣都是經過 『 | 』 號分隔。經過這樣,spock 就會跑 3 次用例,分別是 1 + 2 = 二、1 + 2 = 三、1 + 3 = 4 這些用例。怎麼樣?是否是很方便,後面再擴充用例只要再加一行數據就能夠了。
上面這些用例都是正常能夠跑通的,若是是 IDEA 跑完以後會以下所示:
那麼如今咱們看看若是有用例不經過會怎麼樣,把上面代碼的最後一個 4 改爲 5
def "test add with expect where"(){ expect: Calculator.add(num1, num2) == result where: num1 | num2 | result 1 | 1 | 2 1 | 2 | 3 1 | 3 | 5 }
再跑一次,IDEA 會出現以下顯示
左邊標註出來的是用例執行結果,能夠看出來雖然有 3 條數據,其中 2 條數據是成功,可是隻會顯示總體的成功與否,因此顯示未經過。可是 3 條數據,我怎麼知道哪條沒經過呢?
右邊標註出來的是 spock 打印的的錯誤日誌。能夠很清楚的看到,在 num1 爲 1,num2 爲 3,result 爲 5 而且 他們之間的判斷關係爲 == 的結果是 false 纔是正確的。 spock 的這個日誌打印的是至關歷害,若是是比較字符串,還會計算異常字符串與正確字符串之間的匹配度,有興趣的同窗,能夠自行測試。
嗯,雖然能夠經過日誌知道哪一個用例沒經過,可是仍是以爲有點麻煩。spock 也知道這一點。因此他還同時提供了一個 @Unroll 註解。咱們在上面的代碼上再加上這個註解:
@Unroll def "test add with expect where unroll"(){ expect: Calculator.add(num1, num2) == result where: num1 | num2 | result 1 | 1 | 2 1 | 2 | 3 1 | 3 | 5 }
運行結果以下:
經過添加 @Unroll 註解,spock 自動把上面的代碼拆分紅了 3 個獨立的單測測試,分別運行,運行結果更清晰了。
那麼還能更清晰嗎?固然能夠,咱們發現 spock 拆分後,每一個用例的名稱其實都是你寫的單測方法的名稱,而後後面加一個數組下標,不是很直觀。咱們能夠經過 groovy 的字符串語法,把變量放入用例名稱中,代碼以下:
@Unroll def "test add with expect where unroll by #num1 + #num2 = #result"(){ expect: Calculator.add(num1, num2) == result where: num1 | num2 | result 1 | 1 | 2 1 | 2 | 3 1 | 3 | 5 }
如上,咱們在方法名後加了一句 #num1 + #num2 = #result。這裏有點相似咱們在 mybatis 或者一些模板引擎中使用的方法。# 號拼接聲明的變量就能夠了,執行後結果以下。
這下更清晰了。
另一點,就是 where 默認使用的是表格的這種形式:
where: num1 | num2 | result 1 | 1 | 2 1 | 2 | 3 1 | 3 | 5
很直觀,可是這種形式有一個弊端。上面 『 | 』 號對的這麼整齊。都是我一個空格一個 TAG 按出來的。雖然語法不要求對齊,可是逼死強迫症。不過,好在還能夠有另外一種形式:
@Unroll def "test add with expect where unroll arr by #num1 + #num2 = #result"(){ expect: Calculator.add(num1, num2) == result where: num1 << [1, 1, 2] num2 << [1, 2, 3] result << [1, 3, 4] }
能夠經過 『<<』 符(注意方向),把一個數組賦給變量,等同於上面的數據表格,沒有表格直觀,可是比較簡潔也不用考慮對齊問題,這兩種形式看我的喜愛了。
咱們都知道一個整數除以0 會有拋出一個『/ by zero』異常,那麼若是斷言這個異常呢。用上面 expect 不太好操做,咱們可使用另外一個相似的塊 when ... then。
@Unroll def "test int divide zero exception"(){ when: Calculator.divideInt(1, 0) then: def ex = thrown(ArithmeticException) ex.message == "/ by zero" }
when ... then 一般是成對出現的,它表明着當執行了 when 塊中的操做,會出現 then 塊中的指望。好比上面的代碼說明了,當執行了 _Calculator.divideInt(1, 0) 的操做,就必定會拋出 _ArithmeticException 異常,而且異常信息是 _/ by zero_。
上面咱們已經學會了 spock 的基礎用法,下面咱們將學習與 spring 整合的知識,首先建立幾個用於測試的demo 類
/* * * * * blog.coder4j.cn * * Copyright (C) 2016-2019 All Rights Reserved. * */ package cn.coder4j.study.example.spock.model; import java.util.Objects; /** * @author buhao * @version User.java, v 0.1 2019-10-30 16:23 buhao */ public class User { private String name; private Integer age; private String passwd; public User(String name, Integer age, String passwd) { this.name = name; this.age = age; this.passwd = passwd; } /** * Getter method for property <tt>passwd</tt>. * * @return property value of passwd */ public String getPasswd() { return passwd; } /** * Setter method for property <tt>passwd</tt>. * * @param passwd value to be assigned to property passwd */ public void setPasswd(String passwd) { this.passwd = passwd; } /** * Getter method for property <tt>name</tt>. * * @return property value of name */ public String getName() { return name; } /** * Setter method for property <tt>name</tt>. * * @param name value to be assigned to property name */ public void setName(String name) { this.name = name; } /** * Getter method for property <tt>age</tt>. * * @return property value of age */ public Integer getAge() { return age; } /** * Setter method for property <tt>age</tt>. * * @param age value to be assigned to property age */ public void setAge(Integer age) { this.age = age; } public User() { } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; User user = (User) o; return Objects.equals(name, user.name) && Objects.equals(age, user.age) && Objects.equals(passwd, user.passwd); } @Override public int hashCode() { return Objects.hash(name, age, passwd); } }
/* * * * * blog.coder4j.cn * * Copyright (C) 2016-2019 All Rights Reserved. * */ package cn.coder4j.study.example.spock.dao; import cn.coder4j.study.example.spock.model.User; import org.springframework.stereotype.Component; import java.util.HashMap; import java.util.Map; /** * @author buhao * @version UserDao.java, v 0.1 2019-10-30 16:24 buhao */ @Component public class UserDao { /** * 模擬數據庫 */ private static Map<String, User> userMap = new HashMap<>(); static { userMap.put("k",new User("k", 1, "123")); userMap.put("i",new User("i", 2, "456")); userMap.put("w",new User("w", 3, "789")); } /** * 經過用戶名查詢用戶 * @param name * @return */ public User findByName(String name){ return userMap.get(name); } }
/* * * * * blog.coder4j.cn * * Copyright (C) 2016-2019 All Rights Reserved. * */ package cn.coder4j.study.example.spock.service; import cn.coder4j.study.example.spock.dao.UserDao; import cn.coder4j.study.example.spock.model.User; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; /** * @author buhao * @version UserService.java, v 0.1 2019-10-30 16:29 buhao */ @Service public class UserService { @Autowired private UserDao userDao; public User findByName(String name){ return userDao.findByName(name); } public void loginAfter(){ System.out.println("登陸成功"); } public void login(String name, String passwd){ User user = findByName(name); if (user == null){ throw new RuntimeException(name + "不存在"); } if (!user.getPasswd().equals(passwd)){ throw new RuntimeException(name + "密碼輸入錯誤"); } loginAfter(); } }
/* * * * * blog.coder4j.cn * * Copyright (C) 2016-2019 All Rights Reserved. * */ package cn.coder4j.study.example.spock; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication public class Application { public static void main(String[] args) { SpringApplication.run(Application.class, args); } }
/* * * * * blog.coder4j.cn * * Copyright (C) 2016-2019 All Rights Reserved. * */ package cn.coder4j.study.example.spock.service import cn.coder4j.study.example.spock.model.User import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.context.SpringBootTest import spock.lang.Specification import spock.lang.Unroll @SpringBootTest class UserServiceFunctionTest extends Specification { @Autowired UserService userService @Unroll def "test findByName with input #name return #result"() { expect: userService.findByName(name) == result where: name << ["k", "i", "kk"] result << [new User("k", 1, "123"), new User("i", 2, "456"), null] } @Unroll def "test login with input #name and #passwd throw #errMsg"() { when: userService.login(name, passwd) then: def e = thrown(Exception) e.message == errMsg where: name | passwd | errMsg "kd" | "1" | "${name}不存在" "k" | "1" | "${name}密碼輸入錯誤" } }
spock 與 spring 集成特別的簡單,只要你加入了開頭所說的 spock-spring 和 spring-boot-starter-test。再於測試代碼的類上加上 @SpringBootTest 註解就能夠了。想用的類直接注入進來就能夠了,可是要注意的是這裏只能算功能測試或集成測試,由於在跑用例時是會啓動 spring 容器的,外部依賴也必須有。很耗時,並且有時候外部依賴本地也跑不了,因此咱們一般都是經過 mock 來完成單元測試。
/* * * * * blog.coder4j.cn * * Copyright (C) 2016-2019 All Rights Reserved. * */ package cn.coder4j.study.example.spock.service import cn.coder4j.study.example.spock.dao.UserDao import cn.coder4j.study.example.spock.model.User import spock.lang.Specification import spock.lang.Unroll class UserServiceUnitTest extends Specification { UserService userService = new UserService() UserDao userDao = Mock(UserDao) def setup(){ userService.userDao = userDao } def "test login with success"(){ when: userService.login("k", "p") then: 1 * userDao.findByName("k") >> new User("k", 12,"p") } def "test login with error"(){ given: def name = "k" def passwd = "p" when: userService.login(name, passwd) then: 1 * userDao.findByName(name) >> null then: def e = thrown(RuntimeException) e.message == "${name}不存在" } @Unroll def "test login with "(){ when: userService.login(name, passwd) then: userDao.findByName("k") >> null userDao.findByName("k1") >> new User("k1", 12, "p") then: def e = thrown(RuntimeException) e.message == errMsg where: name | passwd | errMsg "k" | "k" | "${name}不存在" "k1" | "p1" | "${name}密碼輸入錯誤" } }
spock 使用 mock 也很簡單,直接使用 Mock(類) 就能夠了。如上代碼 _UserDao userDao = Mock(UserDao) 。_上面寫的例子中有幾點要說明一下,以以下這個方法爲例:
def "test login with error"(){ given: def name = "k" def passwd = "p" when: userService.login(name, passwd) then: 1 * userDao.findByName(name) >> null then: def e = thrown(RuntimeException) e.message == "${name}不存在" }
given、when、then 不用說了,你們已經很熟悉了,可是第一個 then 裏面的 _1 * userDao.findByName(name) >> null_ 是什麼鬼?
首先,咱們能夠知道的是,一個用例中能夠有多個 then 塊,對於多個指望能夠分別放在多個 then 中。
第二, 1 xx 表示 指望 xx 操做執行了 1 次。_1 userDao.findByName(name)_ 就表現當執行 _userService.login(name, passwd) 時我指望執行 1 次 userDao.findByName(name) 方法。若是指望不執行這個方法就是_0 * xx_,這在條件代碼的驗證中頗有用,而後 _>> null 又是什麼意思?他表明當執行了 _userDao.findByName(name) 方法後,我讓他結果返回 null_。由於 userDao 這個對象是咱們 mock 出來的,他就是一個假對象,爲了讓後續流程按咱們的想法進行,我能夠經過『 >>』 讓 spock 模擬返回指定數據。
第三,要注意第二個 then 代碼塊使用 ${name} 引用變量,跟標題的 #name 是不一樣的。
方法名 | 做用 |
---|---|
setup() | 每一個方法執行前調用 |
cleanup() | 每一個方法執行後調用 |
setupSpec() | 每一個方法類加載前調用一次 |
cleanupSpec() | 每一個方法類執行完調用一次 |
這些方法一般用於測試開始前的一些初始化操做,和測試完成後的清理操做,以下:
def setup() { println "方法開始前初始化" } def cleanup() { println "方法執行完清理" } def setupSpec() { println "類加載前開始前初始化" } def cleanupSpec() { println "因此方法執行完清理" }
對於某些方法,須要規定他的時間,若是運行時間超過了指定時間就算失敗,這時可使用 timeout 註解
@Timeout(value = 900, unit = TimeUnit.MILLISECONDS) def "test timeout"(){ expect: Thread.sleep(1000) 1 == 1 }
註解有兩個值,一個是 value 咱們設置的數值,unit 是數值的單位。
def "test findByName by verity"() { given: def userDao = Mock(UserDao) when: userDao.findByName("kk") >> new User("kk", 12, "33") then: def user = userDao.findByName("kk") with(user) { name == "kk" age == 12 passwd == "33" } }
with 算是一個語法糖,沒有他以前咱們要判斷對象的值只能,user.getXxx() == xx。若是屬性過多也是挺麻煩的,用 with 包裹以後,只要在花括號內直接寫屬性名稱便可,如上代碼所示。
由於篇幅有限,沒法貼完全部代碼,完整代碼已上傳 github。
本文在瞻仰了以下博主的精彩博文後,再加上自身的學習總結加工而來,若是本文在看的時候有不明白的地方能夠看一下下方連接。