作者:京東物流 秦彪
1. 什麼是單元測試
(1)單元測試環節:
測試過程按照階段劃分分為:單元測試、集成測試、系統測試、驗收測試等。相關含義如下:
1) 單元測試: 針對電腦程式模塊進行輸出正確性檢驗工作。
2) 集成測試: 在單元測試基礎上,整合各個模塊組成子系統,進行集成測試。
3) 系統測試: 將整個交付所涉及的協作內容都納入其中考慮,包含計算機硬體、軟體、接口、操作等等一系列作為一個整體,檢驗是否滿足軟體或需求說明。
4) 驗收測試: 在交付或者發布之前對所做的工作進行測試檢驗。
單元測試是階段性測試的首要環節,也是白盒測試的一種,該內容的編寫與實踐可以前置在研發完成,研發在編寫業務代碼的時候就需要生成對應代碼的單元測試。單元測試的發起人是程序設計者,受益人也是編寫程序的人,所以對於程式設計師,非常有必要形成自我約束力,完成基本的單元測試用例編寫。
(2)單元測試特徵:
由上可知,單元測試其實是針對軟體中最小的測試單元來進行驗證的。這裡的單元就是指相關的功能子集,比如一個方法、一個類等。值得注意的是作為最低級別的測試活動,單元測試驗證的對象僅限於當前測試內容,與程序其它部分內容相隔離,總結起來單元測試有以下特徵:
1) 主要功能是證明編寫的代碼內容與期望輸出一致。
2) 最小最低級的測試內容,由程式設計師自身發起,保證程序基本組件正常。
3) 單元測試儘量不要區分類與方法,主張以過程性的方法為測試單位,簡單實用高效為目標。
4) 不要偏離主題,專注於測試一小塊的代碼,保證基礎功能。
5) 剝離與外部接口、存儲之間的依賴,使單元測試可控。
6) 任何時間任何順序執行單元測試都需要是成功的。
2. 為什麼要單元測試
(1)單元測試意義:
程序代碼都是由基本單元不斷組合成複雜的系統,底層基本單元都無法保證輸入輸出正確性,層級遞增時,問題就會不斷放大,直到整個系統崩潰無法使用。所以單元測試的意義就在於保證基本功能是正常可用且穩定的。而對於接口、數據源等原因造成的不穩定因素,是外在原因,不在單元測試考慮範圍之內。
(2)使用main方法進行測試:
@PostMapping(value="/save")
public Map<String,Object> save(@RequestBody Student stu) {
studentService.save(stu);
Map<String,Object> params = new HashMap<>();
params.put("code",200);
params.put("message","保存成功");
return params;
}
假如要對上面的Controller進行測試,可以編寫如下的代碼示例,使用main方法進行測試的時候,先啟動整個工程應用,然後編寫main方法如下進行訪問,在單步調試代碼。
public static void main(String[] args) {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
String json = "{\"name\":\"張三\",\"className\":\"三年級一班\",\"age\":\"20\",\"sex\":\"男\"}";
HttpEntity<String> httpEntity = new HttpEntity<>(json, headers);
String url = "http://localhost:9092/student/save";
MainMethodTest test = new MainMethodTest();
ResponseEntity<Map> responseEntity = test.getRestTemplate().postForEntity(url, httpEntity, Map.class);
System.out.println(responseEntity.getBody());
}
(3)使用main方法進行測試的缺點:
1) 通過編寫大量的main方法針對每個內容做列印輸出到控制台枯燥繁瑣,不具備優雅性。
2) 測試方法不能一起運行,結果需要程式設計師自己判斷正確性。
3) 統一且重複性工作應該交給工具去完成。
3. 單元測試框架-Junit
3.1 junit簡介
JUnit官網:https://junit.org/。JUnit是一個用於編寫可重複測試的簡單框架。它是用於單元測試框架的xUnit體系結構的一個實例。
JUnit的特點:
(1) 針對於Java語言特定設計的單元測試框架,使用非常廣泛。
(2) 特定領域的標準測試框架。
(3) 能夠在多種IDE開發平台使用,包含Idea、Eclipse中進行集成。
(4) 能夠方便由Maven引入使用。
(5) 可以方便的編寫單元測試代碼,查看測試結果等。
JUnit的重要概念:
名稱 |
功能作用 |
Assert |
斷言方法集合 |
TestCase |
表示一個測試案例 |
TestSuite |
包含一組TestCase,構成一組測試 |
TestResult |
收集測試結果 |
JUnit的一些注意事項及規範:
(1) 測試方法必須使用@Test 修飾
(2) 測試方法必須使用public void 進行修飾,不能帶參數
(3) 測試代碼的包應該和被測試代碼包結構保持一致
(4) 測試單元中的每個方法必須可以獨立測試,方法間不能有任何依賴
(5) 測試類一般使用 Test作為類名的後綴
(6) 測試方法使一般用test 作為方法名的前綴
JUnit失敗結果說明:
(1) Failure:測試結果和預期結果不一致導致,表示測試不通過
(2) error:由異常代碼引起,它可以產生於測試代碼本身的錯誤,也可以是被測代碼的Bug
3.2 JUnit內容
(1) 斷言的API
斷言方法 |
斷言描述 |
assertNull(String message, Object object) |
檢查對象是否為空,不為空報錯 |
assertNotNull(String message, Object object) |
檢查對象是否不為空,為空報錯 |
assertEquals(String message, Object expected, Object actual) |
檢查對象值是否相等,不相等報錯 |
assertTrue(String message, boolean condition) |
檢查條件是否為真,不為真報錯 |
assertFalse(String message, boolean condition) |
檢查條件是否為假,為真報錯 |
assertSame(String message, Object expected, Object actual) |
檢查對象引用是否相等,不相等報錯 |
assertNotSame(String message, Object unexpected, Object actual) |
檢查對象引用是否不等,相等報錯 |
assertArrayEquals(String message, Object[] expecteds, Object[] actuals) |
檢查數組值是否相等,遍歷比較,不相等報錯 |
assertArrayEquals(String message, Object[] expecteds, Object[] actuals) |
檢查數組值是否相等,遍歷比較,不相等報錯 |
assertThat(String reason, T actual, Matcher<? super T> matcher) |
檢查對象是否滿足給定規則,不滿足報錯 |
(2) JUnit常用註解:
1) @Test: 定義一個測試方法 @Test(excepted=xx.class): xx.class 表示異常類,表示測試的方法拋出此異常時,認為是正常的測試通過的 @Test(timeout = 毫秒數) :測試方法執行時間是否符合預期。
2) @BeforeClass: 在所有的方法執行前被執行,static 方法全局只會執行一次,而且第一個運行。
3) @AfterClass:在所有的方法執行之後進行執行,static 方法全局只會執行一次,最後一個運行。
4) @Before:在每一個測試方法被運行前執行一次。
5) @After:在每一個測試方法運行後被執行一次。
6) @Ignore:所修飾的測試方法會被測試運行器忽略。
7) @RunWith:可以更改測試執行器使用junit測試執行器。
3.3 JUnit使用
3.3.1 Controller層單元測試
(1) Springboot中使用maven引入Junit非常簡單, 使用如下依賴即可引入:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>Spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
(2) 上面使用main方法案例可以使用如下的Junit代碼完成:
@RunWith(SpringRunner.class)
@SpringBootTest(classes = MainApplication.class)
public class StudentControllerTest {
// 注入Spring容器
@Autowired
private WebApplicationContext applicationContext;
// 模擬Http請求
private MockMvc mockMvc;
@Before
public void setupMockMvc(){
// 初始化MockMvc對象
mockMvc = MockMvcBuilders.webAppContextSetup(applicationContext).build();
}
/**
* 新增學生測試用例
* @throws Exception
*/
@Test
public void addStudent() throws Exception{
String json="{\"name\":\"張三\",\"className\":\"三年級一班\",\"age\":\"20\",\"sex\":\"男\"}";
mockMvc.perform(MockMvcRequestBuilders.post("/student/save") //構造一個post請求
// 發送端和接收端數據格式
.contentType(MediaType.APPLICATION_JSON_UTF8)
.accept(MediaType.APPLICATION_JSON_UTF8)
.content(json.getBytes())
)
// 斷言校驗返回的code編碼
.andExpect(MockMvcResultMatchers.status().isOk())
// 添加處理器列印返回結果
.anddo(MockMvcResultHandlers.print());
}
}
只需要在類或者指定方法上右鍵執行即可,可以直接充當postman工作訪問指定url,且不需要寫請求代碼,這些都由工具自動完成。
(3)案例中相關組件介紹
本案例中構造mockMVC對象時,也可以使用如下方式:
@Autowired
private StudentController studentController;
@Before
public void setupmockMvc(){
// 初始化MockMvc對象
mockMvc = MockMvcBuilders.standaloneSetup(studentController).build();
}
其中MockMVC是Spring測試框架提供的用於REST請求的工具,是對Http請求的模擬,無需啟動整個模塊就可以對Controller層進行調用,速度快且不依賴網絡環境。
使用MockMVC的基本步驟如下:
1) mockMvc.perform執行請求
2) MockMvcRequestBuilders.post或get構造請求
3) MockHttpServletRequestBuilder.param或content添加請求參數
4) MockMvcRequestBuilders.contentType添加請求類型
5) MockMvcRequestBuilders.accept添加響應類型
6) ResultActions.andExpect添加結果斷言
7) ResultActions.andDo添加返回結果後置處理
8) ResultActions.andReturn執行完成後返回相應結果
3.3.2 Service層單元測試
可以編寫如下代碼對Service層查詢方法進行單測:
@RunWith(SpringRunner.class)
@SpringBootTest
public class StudentServiceTest {
@Autowired
private StudentService studentService;
@Test
public void getOne() throws Exception {
Student stu = studentService.selectByKey(5);
Assert.assertThat(stu.getName(),CoreMatchers.is("張三"));
}
}
執行結果:
3.3.3 Dao層單元測試
可以編寫如下代碼對Dao層保存方法進行單測:
@RunWith(SpringRunner.class)
@SpringBootTest
public class StudentDaoTest {
@Autowired
private StudentMapper studentMapper;
@Test
@Rollback(value = true)
@Transactional
public void insertOne() throws Exception {
Student student = new Student();
student.setName("李四");
student.setMajor("計算機學院");
student.setAge(25);
student.setSex('男');
int count = studentMapper.insert(student);
Assert.assertEquals(1, count);
}
}
其中@Rollback(value = true) 可以執行單元測試之後回滾所新增的數據,保持資料庫不產生髒數據。
3.3.4 異常測試
(1) 在service層定義一個異常情況:
public void computeScore() {
int a = 10, b = 0;
}
(2) 在service的測試類中定義單元測試方法:
@Test(expected = ArithmeticException.class)
public void computeScoreTest() {
studentService.computeScore();
}
(3) 執行單元測試也會通過,原因是@Test註解中的定義了異常
3.3.5 測試套件測多個類
(1) 新建一個空的單元測試類
(2) 利用註解@RunWith(Suite.class)和@SuiteClasses標明要一起單元測試的類
@RunWith(Suite.class)
@Suite.SuiteClasses({ StudentServiceTest.class, StudentDaoTest.class})
public class AllTest {
}
運行結果:
3.3.6 idea中查看單元測試覆蓋率
(1) 單測覆蓋率
測試覆蓋率是衡量測試過程工作本身的有效性,提升測試效率和減少程序bug,提升產品可靠性與穩定性的指標。
統計單元測試覆蓋率的意義:
1) 可以洞察整個代碼中的基礎組件功能的所有盲點,發現相關問題。
2) 提高代碼質量,通常覆蓋率低表示代碼質量也不會太高,因為單測不通過本來就映射出考慮到各種情況不夠充分。
3) 從覆蓋率的達標上可以提高代碼的設計能力。
(2) 在idea中查看單元測試覆蓋率很簡單,只需按照圖中示例的圖標運行,或者在單元測試方法或類上右鍵Run 'xxx' with Coverage即可。執行結果是一個表格,列出了類、方法、行數、分支覆蓋情況。
(3) 在代碼中會標識出覆蓋情況,綠色的是已覆蓋的,紅色的是未覆蓋的。
(4) 如果想要導出單元測試的覆蓋率結果,可以使用如下圖所示的方式,勾選 Open generated HTML in browser
導出結果:
3.3.7 JUnit插件自動生成單測代碼
(1) 安裝插件,重啟idea生效
(2) 配置插件
(3) 使用插件
在需要生成單測代碼的類上右鍵generate...,如下圖所示。
生成結果:
4. 單元測試工具-Mockito
4.1 Mockito簡介
在單元測試過程中主張不要依賴特定的接口與數據來源,此時就涉及到對相關數據的模擬,比如Http和JDBC的返回結果等,可以使用虛擬對象即Mock對象進行模擬,使得單元測試不在耦合。
Mock過程的使用前提:
(1) 實際對象時很難被構造出來的
(2) 實際對象的特定行為很難被觸發
(3) 實際對象可能當前還不存在,比如依賴的接口還沒有開發完成等等。
Mockito官網:https://site.mockito.org 。Mockito和JUnit一樣是專門針對Java語言的mock數據框架,它與同類的EasyMock和jMock功能非常相似,但是該工具更加簡單易用。
Mockito的特點:
(1) 可以模擬類不僅僅是接口
(2) 通過註解方式簡單易懂
(3) 支持順序驗證
(4) 具備參數匹配器
4.2 Mockito使用
maven引入spring-boot-starter-test會自動將mockito引入到工程中。
4.2.1 使用案例
(1) 在之前的代碼中在定義一個BookService接口, 含義是借書接口,暫且不做實現
public interface BookService {
Book orderBook(String name);
}
(2) 在之前的StudentService類中新增一個orderBook方法,含義是學生預定書籍方法,其中實現內容調用上述的BookService的orderBook方法。
public Book orderBook(String name) {
return bookService.orderBook(name);
}
(3) 編寫單元測試方法,測試StudentService的orderBook方法
@Test
public void orderBookTest() {
Book expectBook = new Book(1L, "鋼鐵是怎樣煉成的", "書架A01");
Mockito.when(bookService.orderBook(any(String.class))).thenReturn(expectBook);
Book book = studentService.orderBook("");
System.out.println(book);
Assert.assertTrue("預定書籍不符", expectBook.equals(book));
}
(4) 執行結果:
(5) 結果解析
上述內容並沒有實現BookService接口的orderBook(String name)方法。但是使用mockito進行模擬數據之後,卻通過了單元測試,原因就在於Mockito替換了本來要在StudentService的orderBook方法中獲取的對象,此處就模擬了該對象很難獲取或當前無法獲取到,用模擬數據進行替代。
4.2.2 相關語法
常用API:
上述案例中用到了mockito的when、any、theWhen等語法。接下來介紹下都有哪些常用的API:
1) mock:模擬一個需要的對象
2) when:一般配合thenXXX一起使用,表示當執行什麼操作之後怎樣。
3) any: 返回一個特定對象的預設值,上例中標識可以填寫任何String類型的數據。
4) theReturn: 在執行特定操作後返回指定結果。
5) spy:創造一個監控對象。
6) verify:驗證特定的行為。
7) doReturn:返回結果。
8) doThrow:拋出特定異常。
9) doAnswer:做一個自定義響應。
10) times:操作執行次數。
11) atLeastOnce:操作至少要執行一次。
12) atLeast:操作至少執行指定的次數。
13) atMost:操作至多執行指定的次數。
14) atMostOnce:操作至多執行一次。
15) doNothing:不做任何的處理。
16) doReturn:返回一個結果。
17) doThrow:拋出一個指定異常。
18) doAnswer:指定一個特定操作。
19) doCallRealMethod:用於監控對象返回一個真實結果。
4.2.3 使用要點
(1) 打樁
Mockito中有Stub,所謂存根或者叫打樁的概念,上面案例中的Mockito.when(bookService.orderBook(any(String.class))).thenReturn(expectBook);就是打樁的含義,先定義好如果按照既定的方式調用了什麼,結果就輸出什麼。然後在使用Book book = studentService.orderBook(""); 即按照指定存根輸出指定結果。
@Test
public void verifyTest() {
List mockedList = mock(List.class);
mockedList.add("one");
verify(mockedList).add("one"); // 驗證通過,因為前面定義了這個樁
verify(mockedList).add("two"); // 驗證失敗,因為前面沒有定義了這個樁
}
(2) 參數匹配
上例StudentService的orderBook方法中的any(String.class) 即為參數匹配器,可以匹配任何此處定義的String類型的數據。
(3) 次數驗證
@Test
public void timesTest() {
List mockedList = mock(List.class);
when(mockedList.get(anyInt())).thenReturn(1000);
System.out.println(mockedList.get(1));
System.out.println(mockedList.get(1));
System.out.println(mockedList.get(1));
System.out.println(mockedList.get(2));
// 驗證通過:get(1)被調用3次
verify(mockedList, times(3)).get(1);
// 驗證通過:get(1)至少被調用1次
verify(mockedList, atLeastOnce()).get(1);
// 驗證通過:get(1)至少被調用3次
verify(mockedList, atLeast(3)).get(1);
}
(4) 順序驗證
@Test
public void orderBookTest1() {
String json = "{\"id\":12,\"location\":\"書架A12\",\"name\":\"三國演義\"}";
String json1 = "{\"id\":21,\"location\":\"書架A21\",\"name\":\"水滸傳\"}";
String json2 = "{\"id\":22,\"location\":\"書架A22\",\"name\":\"紅樓夢\"}";
String json3 = "{\"id\":23,\"location\":\"書架A23\",\"name\":\"西遊記\"}";
when(bookService.orderBook("")).thenReturn(JSON.parseObject(json, Book.class));
Book book = bookService.orderBook("");
Assert.assertTrue("預定書籍有誤", "三國演義".equals(book.getName()));
when(bookService.orderBook("")).thenReturn(JSON.parseObject(json1, Book.class)).
thenReturn(JSON.parseObject(json2, Book.class)).
thenReturn(JSON.parseObject(json3, Book.class));
Book book1 = bookService.orderBook("");
Book book2 = bookService.orderBook("");
Book book3 = bookService.orderBook("");
Book book4 = bookService.orderBook("");
Book book5 = bookService.orderBook("");
// 全部驗證通過,按順序最後打樁打了3次,大於3次按照最後對象輸出
Assert.assertTrue("預定書籍有誤", "水滸傳".equals(book1.getName()));
Assert.assertTrue("預定書籍有誤", "紅樓夢".equals(book2.getName()));
Assert.assertTrue("預定書籍有誤", "西遊記".equals(book3.getName()));
Assert.assertTrue("預定書籍有誤", "西遊記".equals(book4.getName()));
Assert.assertTrue("預定書籍有誤", "西遊記".equals(book5.getName()));
}
(5) 異常驗證
@Test(expected = RuntimeException.class)
public void exceptionTest() {
List mockedList = mock(List.class);
doThrow(new RuntimeException()).when(mockedList).add(1);
// 驗證通過
mockedList.add(1);
}