1 什么是單元測試(UT)?
最近(jin)工作(zuo)涉(she)及到給一(yi)些代碼(ma)(ma)寫單(dan)(dan)元測試,這(zhe)可謂(wei)是功能上線(xian)前評判代碼(ma)(ma)質(zhi)量(liang)極(ji)其重要的(de)一(yi)步,當然(ran)單(dan)(dan)元測試之后還有(you)系(xi)統測試、集成(cheng)測試等,這(zhe)些測試的(de)關系(xi)和意義不是本文的(de)重點內容,并且比(bi)較(jiao)好理解,這(zhe)里貼(tie)一(yi)個鏈接大家(jia)可以(yi)自行按需閱讀://blog.csdn.net/u013185349/article/details/123396943
總的來看單元(yuan)測試有三個核心要點:
1.測試范圍:代碼中的每一個小的單元(一般是類/方法)
單元(yuan)測(ce)(ce)試不測(ce)(ce)試模塊或系統。一般都(dou)是(shi)(shi)由開(kai)發(fa)工程(cheng)師而(er)非(fei)測(ce)(ce)試工程(cheng)師完(wan)成(cheng)的,是(shi)(shi)代碼(ma)層(ceng)面的測(ce)(ce)試,用于測(ce)(ce)試“自(zi)己”編寫的代碼(ma)的正確性。
2.測試依賴:不依賴于任何不可控組建
單元測試不依賴(lai)于任何不可(ke)控的組件(jian),即使代碼中依賴(lai)了其(qi)他這些(xie)不可(ke)控的組件(jian)(比如復(fu)雜(za)外部系統,復(fu)雜(za)模塊或類(lei)),也需(xu)要通(tong)過(guo) mock 的方式將其(qi)變成(cheng)可(ke)控。
3.測試意義:得到預期的輸入輸出
在你寫完一個功(gong)能代碼之后,怎(zen)么(me)保證你的(de)代碼運(yun)行正確(que)?在各種異(yi)(yi)常(數據異(yi)(yi)常、輸入異(yi)(yi)常、調用(yong)異(yi)(yi)常等(deng))情況下(xia),程序運(yun)行結果都符合(he)你預先設計的(de)預期,返回(hui)合(he)適的(de)報錯呢?這(zhe)個時(shi)候,單(dan)元測試(shi)就派上了用(yong)場(chang)
2 單元測試的工具
常用的單元測試(shi)工(gong)具有三種(zhong),在這里我們(men)都會(hui)介紹,從(cong)基(ji)礎到進階的用法慢(man)(man)慢(man)(man)都會(hui)寫到
JUnit
經典中的經典,相信哪怕是入門java的學習者,絕大部分也使用過JUnit的@Test注解來進行單元測試,但目前已經更新到JUnit5,很多屬性是有變化的,SpringBoot默認(ren)集(ji)成(cheng)的也是(shi)JUnit5版本,所以(yi)本文(wen)可能(neng)會與一些網(wang)上能(neng)找到的資料有所不同
Asserts
就(jiu)是我(wo)們常說的“斷言”,也(ye)是非常簡單好(hao)用(yong)的工具(ju),功能強悍,支持各種斷言方法
Mockito
Mock可能對于部分初學者來說比較陌生,所以這里多說一些:JUnit固然很簡單方便,但如果在單元測試的過程中嗎,需要構建請求頭和查詢參數、構建復雜的腳本等特殊需求,用標準的JUnit是無法實現的,這個時候我們就要用到MockMVC
什么是mock?
即mock object,模擬對象,是在OOP中模擬真實對象行為的假對象,在單元測試中有時無法使用真實對象,因此需要用到模擬對象來測試。
比如在以下情況可以采用模擬對象來替代真實對象:
1.真實對象的行為是不確定的 (例如,當前的時間或溫度) ;
2.真實對象很難搭建起來;
3.真實對象的行為很難觸發 (例如,網絡錯誤) ;
4.真實對象速度很慢(例如,一個完整的數據庫,在測試之前可能需要初始化) ;
5.真實的對象是用戶界面, 或包括用戶界面在內;
6.真實的對象使用了回調機制:
7.真實對象可能還不存在:
8.真實(shi)對象可能包含不能用(yong)作測試(而不是為實(shi)際工作)的信息和方法(fa)。
舉例:只做了一點簡單的更改,但是驗證需要重啟底層資源,一等就是五六分鐘;要模擬在某個操作系統/瀏覽器環境下的功能表現,總不能專門去搭一套環境吧
因此spring給我們提供了Mockito工具,即模擬對象的生成器,開發分為三個步驟:
1.模擬外部依賴,比如我們需要的底層數據、網絡請求頭等
2.執行具體的測試代碼
3.驗證(zheng)產生的結果(guo)與(yu)預期是(shi)否相符(fu)
spring-test包提供(gong)了一(yi)個核心(xin)的(de)對象——MockMVC,MockMvc是(shi)由spring-test包提供(gong),實現了對Htp請求的(de)模擬(ni),能夠(gou)直(zhi)接使用網絡(luo)的(de)形式,轉換(huan)到(dao)Controller的(de)調用,使得測試速度(du)快.不(bu)依(yi)賴(lai)網絡(luo)環境(jing)。同時提供(gong)了一(yi)套驗證的(de)工具, 結果(guo)的(de)驗證十分方便。
3 編寫單元測試的原則
一(yi)般來說,單元(yuan)測試需要遵循3A模式(shi)(Arrange/Act/Assert)來編(bian)寫(xie)具(ju)(ju)體(ti)的代(dai)碼,具(ju)(ju)體(ti)的概(gai)念可(ke)以(yi)參考://juejin.cn/post/7005448543192252423
實際上3A是一個簡單實用(yong)的概念,舉個簡單的例(li)子:
Arrange:創建測試所需要的實例
Act:運行你所需要測試的具體行動(方法)
Assert:判斷返回的是否與預期一樣
4 Junit 5 實現基礎單元測試
1.建立相應的包存儲測試代碼
實際(ji)上Spring Boot對Junit進行了(le)整合,我們(men)可(ke)以(yi)從工(gong)程(cheng)架(jia)構中看(kan)到,創(chuang)建一個(ge)(ge)spring工(gong)程(cheng)后(springinitializer),會(hui)自帶(dai)一個(ge)(ge)src下的(de)(de)(de)test包和一個(ge)(ge)默(mo)認測(ce)試的(de)(de)(de)代(dai)碼(ma)(如下圖),如果沒(mei)有的(de)(de)(de)話(hua),也可(ke)以(yi)自己創(chuang)建一個(ge)(ge)(因為有些開發者(zhe)可(ke)能創(chuang)建的(de)(de)(de)是maven工(gong)程(cheng))

2.引入依賴
當然要想使用這個測試,我們當然要引入相關的依賴,一般來說只要你創建了springboot項目,它是自動導入的。但是需要注意的是,我們通常需要排除junit-vintage-engine這個依賴,為什么呢?
如果不排除這個依賴,用(yong)(yong)的不會(hui)是(shi)(shi)spring整合的Junit,從(cong)而后(hou)面寫測試代(dai)碼的時候(hou)可能需要加(jia)RunWith注解來指(zhi)定是(shi)(shi)用(yong)(yong)哪一個Spring整合的Junit
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>org.junit.vintage</groupId>
<artifactId>junit-vintage-engine</artifactId>
</exclusion>
</exclusions>
</dependency>
3.編寫測試代碼
這(zhe)里我們可以參考給我們的(de)默認測(ce)試代碼來寫,默認代碼會(hui)是這(zhe)種格式(@SpringBootTest就(jiu)是spring整合了junit之(zhi)后的(de)一(yi)個(ge)注解):
package wy.springboot01;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
class Springboot01ApplicationTests {
@Test
void contextLoads() {
}
}
我(wo)們(men)這里簡單寫一下,新建一個(ge)User類,來(lai)讓測試代碼輸出User的某個(ge)參數
package wy.springboot01.domain;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
/**
* springboot項目啟動的時候,自動將application.yml內容加載到實體對象中
*/
@Data
//將實體類交給spring管理,自動掃描
@Component
public class User {
private Integer uid;
private String uname;
private String password;
private ArrayList<String> addrs;
public User() {
}
public User(Integer uid, String uname, String password, ArrayList<String> addrs) {
this.uid = uid;
this.uname = uname;
this.password = password;
this.addrs = addrs;
}
}
單元測試代碼:
package wy.springboot01;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import wy.springboot01.domain.User;
@SpringBootTest(classes = {User.class})
class Springboot01ApplicationTests {
@Autowired
private User user;
@Test
void contextLoads() {
System.out.println(user.getAddrs());
}
}
4.直接運行測試代碼,可能會失敗,常見錯誤是:Found multiple @SpringBootConfiguration annotated classes,這是因為之前在做其他代碼的時候,可能有多個文件用了@SpringBootConfiguration從而導致沖突,只需要注釋掉多余的只留一個即可,或者指定你要用哪個@SpringBootConfiguration來測試,因此我們這里用了:@SpringBootTest(classes = {User.class})
5.正常會輸出null,因(yin)為我(wo)們(men)并沒(mei)有(you)給Addrs這個參(can)數賦默認值(zhi),到這里我(wo)們(men)就完成了一個沒(mei)有(you)3A的最后(hou)一個A(斷(duan)言)的單元測試(shi)
6.但是我們在開發中通常不會只測試某一個類的參數,比如我們想測試在某個啟動類中(某個功能加載的過程中)User對象的值是否載入成功,那么也可以按如下來實現:
首先我們先寫一個yml文件來給User賦默(mo)認值,就比如叫做application.yml
user:
uid: 1998051110
uname: wuyu
password: wuyu1999
addrs:
- Beijing
- Sichuan
- Nanchang
然后給User類加上注解:
//加載配置內容,設定配置前綴,注意:prefix參數不支持小駝峰原則,必須全部小寫
@ConfigurationProperties(prefix = "user")
最后把(ba)測試的啟動類(lei)(lei)改(gai)成這(zhe)個工程(cheng)的默認啟動類(lei)(lei):
@SpringBootTest(classes = {Springboot01Application.class})
這樣啟動之后我們就(jiu)會得到如(ru)下結果,默認值加(jia)載成功(gong)了:
[Beijing, Sichuan, Nanchang]
7.那么如何給結果做一個斷言呢?換而言之我不需要用人眼去判斷結果是不是對的,系統就西東判斷了,也很簡單,這里我們就要介紹到我們的第二種測試工具:asserts
5 Asserts方法判斷返回值
實際(ji)上我們在日(ri)常寫代碼(ma)中有時候也會用到assert,比如判(pan)斷某個(ge)入參是否為(wei)空,直接使用:assert data.length != 0; 即可
那么回到(dao)單元測試(shi),我們(men)如(ru)何判斷返回值?
//判斷方法返回是否為Flase
Assertions.assertFalse(SomeClass.someMethod());
//判斷方法返回是否為True
Assertions.assertTure(SomeClass.someMethod());
//判斷方法返回是否與對應值相等
Assertions.assertTure("預期返回", SomeClass.someMethod());
同樣將剛(gang)剛(gang)的Juint判(pan)斷user的例子擴充一(yi)(yi)下,我(wo)們判(pan)斷用戶名是否與預期一(yi)(yi)致,可以寫(xie)成:
@SpringBootTest(classes = {Springboot01Application.class})
class Springboot01ApplicationTests {
@Autowired
private User user;
@Test
void contextLoads() {
Assertions.assertArrayEquals(String.join("","Beijing", "Sichuan", "Nanchang" ).toCharArray()
,String.join("",user.getAddrs() ).toCharArray());
}
}
最(zui)終運行結(jie)果(guo)如下圖,就實現(xian)了不由人眼(yan)判斷(duan)而是系(xi)統判斷(duan),非常方便:

6 MockMVC
剛剛說過,mock就是用來模擬一些不太好去創建的代碼外部依賴,最典型的就是對于controller的測試,傳統的方法是代碼起來之后使用postman,而單元測試要求我們不能有這種外部依賴,因此我們就用mock來模擬這樣的一個環境。
還是(shi)(shi)拿user類來(lai)舉例,比如(ru)我們有一個(ge)controller長這(zhe)個(ge)樣(yang)子,如(ru)果我們要驗證他的(de)(de)(de)返回是(shi)(shi)不是(shi)(shi)對(dui)的(de)(de)(de),按照常(chang)理我們應該(gai)去啟動spring并且用(yong)瀏覽器、postman、http腳(jiao)本等(deng)來(lai)看(kan)看(kan)它的(de)(de)(de)返回是(shi)(shi)什么,非常(chang)的(de)(de)(de)浪費(fei)時間
@GetMapping("/user")
public ArrayList<User> getUser(){
System.out.println("user get......");
ArrayList<User> users = new ArrayList<>();
users.add(new User(1001,"wu","1212",new ArrayList<>(Arrays.asList("nanchang", "sichuan", "beijing"))));
users.add(new User(1002,"du","1313",new ArrayList<>(Arrays.asList("chang", "sica", "beng"))));
return users;
}
那么用mock怎么去做呢?
首先我們先在test包下,創建(jian)一個與(yu)默(mo)認測試代碼平行的測試類,比如(ru)就(jiu)叫MockMVCTester,如(ru)下:
package wy.springboot01;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import org.springframework.test.web.servlet.result.MockMvcResultHandlers;
import org.springframework.test.web.servlet.result.MockMvcResultMatchers;
import javax.annotation.Resource;
//進行每一次mock模擬tomcat容器的時候,使用隨機端口啟動,這樣不會有端口占用的問題
@SpringBootTest(classes = {Springboot01Application.class}, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
//自動配置以及啟用mvc對象
@AutoConfigureMockMvc
public class MockMVCTester {
//注入MockMVC對象,它是springtest依賴中自帶的
@Resource
private MockMvc mockMvc;
@Test
public void testMock() throws Exception {
//獲取mock返回的對象
MvcResult result = mockMvc.perform(MockMvcRequestBuilders.get("/user"))//perform模擬一個http請求,這里是get方法
.andExpect(MockMvcResultMatchers.status().isOk())//添加預期,如果服務器返回的是200
.andDo(MockMvcResultHandlers.print())//那我們就把請求和響應的信息在控制臺中打印輸出
.andReturn();//將結果返回出來
}
}
啟動測試方(fang)法testMock,可以發現:

同時(shi)也會返回controller的信息,可以對照(zhao)是否符合預期,這樣我們就實現了不需要啟動瀏覽器、postman等即(ji)可測試controller

但我們還可以更加便利一些,直接在運行的時候就判斷它的返回是不是符合預期,本例的返回為:
[{"uid":1001,"uname":"wu","password":"1212","addrs":["nanchang","sichuan","beijing"]},{"uid":1002,"uname":"du","password":"1313","addrs":["chang","sica","beng"]}]
因此我們將測試(shi)代碼(ma)做一些改動
package wy.springboot01;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import org.springframework.test.web.servlet.result.MockMvcResultHandlers;
import org.springframework.test.web.servlet.result.MockMvcResultMatchers;
import javax.annotation.Resource;
//進行每一次mock模擬tomcat容器的時候,使用隨機端口啟動,這樣不會有端口占用的問題
@SpringBootTest(classes = {Springboot01Application.class}, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
//自動配置以及啟用mvc對象
@AutoConfigureMockMvc
public class MockMVCTester {
//注入MockMVC對象,它是springtest依賴中自帶的
@Resource
private MockMvc mockMvc;
@Test
public void testMock() throws Exception {
//獲取mock返回的對象
MvcResult result = mockMvc.perform(MockMvcRequestBuilders.get("/user"))//perform模擬一個http請求,這里是get方法
.andExpect(MockMvcResultMatchers.status().isOk())//添加預期,如果服務器返回的是200
.andDo(MockMvcResultHandlers.print())//那我們就把請求和響應的信息在控制臺中打印輸出
.andExpect(MockMvcResultMatchers.content().string("[{\"uid\":1001,\"uname\":\"wu\"," +
"\"password\":\"1212\",\"addrs\":[\"nanchang\",\"sichuan\",\"beijing\"]}," +
"{\"uid\":1002,\"uname\":\"du\",\"password\":\"1313\",\"addrs\"" +
":[\"chang\",\"sica\",\"beng\"]}]"))//content表示對于返回的請求體數據進行判斷,string表示進行比對
.andReturn();//將結果返回出來
}
}
再次啟動,運行(xing)依然成功(gong)說明比(bi)對(dui)正確,返(fan)回符合預(yu)期,如果這個(ge)時候我(wo)們改了(le)controller的邏(luo)輯,則返(fan)回“test fail”,非常好(hao)用,甚(shen)至會比(bi)對(dui)預(yu)期值和實(shi)際值

注:如果覺得testMock()看起來不舒服,可以在@Test下面加注解@DisplayName("get方法測試用例"),來自定義測試方法名稱
如果有入參(can)、且(qie)返回是一(yi)個json可以參(can)考如下(xia)案例:
@Test
@DisplayName("get方法+有入參+有json返回")
public void testMock1() throws Exception {
//mock返回的對象可以不獲取,因為單純的判斷對錯用不上
mockMvc.perform(MockMvcRequestBuilders.get("/user/para")//perform模擬一個http請求,這里是get方法
.header("token", "akakak")//請求頭
.param("id","wy")//請求參數
.param("password","asd"))//請求參數
.andExpect(MockMvcResultMatchers.status().isOk())//添加預期,如果服務器回的是200
.andDo(MockMvcResultHandlers.print())//那我們就把請求和響應的信息在控制臺中打印輸出
.andExpect(MockMvcResultMatchers.jsonPath("ak").value("asd"))//獲取返回的json并核對對應的值是否一樣
.andReturn();//將結果返回出來
}
如果方法為post,可以參考如下案例
@Test
@DisplayName("post方法測試用例")
public void testMock1() throws Exception {
//IoC
ClassPathXmlApplicationContext context = new ClassPathXmlApplicationContext("IoC.xml");
User user = context.getBean(User.class);
ObjectMapper mapper = new ObjectMapper();
user.setUname("wy");
//mock返回的對象可以不獲取,因為單純的判斷對錯用不上
mockMvc.perform(MockMvcRequestBuilders.post("/user")//perform模擬一個http請求,這里是get方法
.content(mapper.writeValueAsString(user))//用IoC建立一個User對象
.contentType(MediaType.APPLICATION_JSON_VALUE))//添加json類數據,轉化為入參
.andExpect(MockMvcResultMatchers.status().isOk())//添加預期,如果服務器回的是200
.andDo(MockMvcResultHandlers.print())//那我們就把請求和響應的信息在控制臺中打印輸出
.andExpect(MockMvcResultMatchers.jsonPath("uname").value("wy"))//獲取返回的json并核對對應的值是否一樣
.andReturn();//將結果返回出來
}