單元測試實戰(四種覆蓋詳解、測試例項)
理論部分
前言
單元測試,就是對某一段細粒度的Java程式碼的邏輯測試。程式碼塊一般指一個Java 方法本身,所有外部依賴都需要mock掉,僅關注程式碼邏輯本身。
需要注意,單測的一個大前提就是需要清楚的知道自己要測試的程式塊所預期的輸入輸出,然後根據這個預期和程式邏輯來書寫case。
(這裡需要注意的就是單測的預期結果 一定要針對需求/設計邏輯去寫,而不是針對實現去寫,否則單測將毫無意義,照著錯誤的實現設計出的case也很可能是錯的)
覆蓋型別
1、行覆蓋 Statement Coverage
行覆蓋(又叫語句覆蓋)就是通過設計一定量的測試用例,保證被測試的方法每一行程式碼都會被執行一遍。
路徑覆蓋是最弱的覆蓋方式。
例項:
public Integer fun3(Integer a, Integer b, Integer x) { if (a > 1 && b == 0) { x = x + a; } if (a == 2 || x > 1) { x += 1; } return x; }
本例僅需要一個case,即可實現行覆蓋。test case 如下:
|
a |
b |
x |
預期結果 |
TC1 |
2 |
0 |
3 |
6 |
@Test public void testFun3StatementCoverage(){ Integer res = demoService.fun3(2,0,3); Assert.assertEquals(6,res.intValue()); }
這個用例就可以保證所有的行都被執行。
但是僅僅有這一個用例的話,對這個方法的測試就是非常脆弱的。
舉個栗子,某RD接到了這個需求,理清了邏輯,寫好單測之後開始寫程式碼(或者寫好程式碼之後開始寫單測)。但是由於手抖,將第三行的 && 寫成了 ||:
public Integer fun4(Integer a, Integer b, Integer x) { if (a > 1 || b == 0) { x += a; } if (a == 2 || x > 1) { x += 1; } return x; }
然後跑一下單測,發現很順滑,一下就過了。
隨後該RD很高興的將程式碼釋出到了線上,結果就發生了嚴重的生產故障,於是該RD就被開除了。
行覆蓋是一個最基礎的覆蓋方式,但是也是最薄弱的,如果完全依賴行覆蓋,那不小心就會被開除。
2、判定覆蓋 / 分支覆蓋 (Decision Coverage/Branch Coverage)
public Integer fun3(Integer a, Integer b, Integer x) { if (a > 1 && b == 0) { x = x + a; } if (a == 2 || x > 1) { x += 1; } return x; }
判定覆蓋的含義就是程式碼裡每一個判定都要走一次true,一次false。依然用上面的程式碼,想要實現判定覆蓋,需要以下case
|
a |
b |
x |
預期結果 |
TC2 |
2 |
0 |
1 |
4 |
TC3 |
3 |
1 |
1 |
1 |
@Test public void testFun3DecisionCoverage(){ Integer res = demoService.fun3(2,0,1); Assert.assertEquals(4,res.intValue()); res = demoService.fun3(3,1,1); Assert.assertEquals(1,res.intValue()); }
這兩個用例可以保證判定 A: (a > 1 || b == 0) 和判定B: (a == 2 || x > 1) 分別都取一次true 和false:
tc2 時, A,B均為true;tc3時,A,B均為false。
可以看出分支覆蓋依然有明顯缺陷,並沒有覆蓋到 A: true B: false 和 A:false B:true的情況。
3、條件覆蓋 Condition Coverage
public Integer fun3(Integer a, Integer b, Integer x) { if (a > 1 && b == 0) { x = x + a; } if (a == 2 || x > 1) { x += 1; } return x; }
條件覆蓋和判定覆蓋類似,不過判定覆蓋著眼於整個判定語句,而條件覆蓋則著眼於某個判斷條件。
條件覆蓋需要保證每個判斷條件的true false都要覆蓋到,而不是整個判定語句。
例如,判定A (a > 1 || b == 0) ,只需要整個判定表示式分別取一次真假即可滿足判定覆蓋,而要滿足條件覆蓋,則需要判斷條件 (a>1) 和 (b==0) 分別都取一次true false才算滿足。
依然採用同樣的程式碼,要想實現條件覆蓋,則需要:
|
a |
b |
x |
預期結果 |
TC4 |
2 |
0 |
3 |
6 |
TC5 |
0 |
1 |
0 |
0 |
@Test public void testFun3ConditionCoverage(){ Integer res = demoService.fun3(2,0,3); Assert.assertEquals(6,res.intValue()); res = demoService.fun3(0,1,0); Assert.assertEquals(0,res.intValue()); }
這兩個用例可以保證 (a > 1) (b==0) (a == 2) (x > 1) 四個條件都分別取true false。
很明顯可以發現,這玩意兒依然是不全面的,這個例子裡條件覆蓋和判定覆蓋存在同樣的問題,覆蓋的不夠全面。
4、路徑覆蓋 Path Coverage
public Integer fun3(Integer a, Integer b, Integer x) { if (a > 1 && b == 0) { x = x + a; } if (a == 2 || x > 1) { x += 1; } return x; }
路徑覆蓋這個顧名思義就是覆蓋所有可能執行的路徑。
為了方便理解,這裡先把流程圖畫出來。
紅色代表一段路徑。
首先梳理所有路徑:
路徑1:1-->3-->5;
路徑2:1-->2-->5;
路徑3:1-->3-->4;
路徑4:1-->2-->4;
路徑覆蓋就是需要設計用例,將所有的路徑都走一遍。
設計以下用例:
|
a |
b |
x |
預期結果 |
經過路徑 |
TC6 |
0 |
1 |
0 |
0 |
1 |
TC7 |
3 |
0 |
-3 |
0 |
2 |
TC8 |
2 |
1 |
3 |
4 |
3 |
TC9 |
2 |
0 |
3 |
6 |
4 |
@Test public void testFun3PathCoverage(){ Integer res = demoService.fun3(0,1,0); Assert.assertEquals(0,res.intValue()); res = demoService.fun3(3,0,-3); Assert.assertEquals(0,res.intValue()); res = demoService.fun3(2,1,3); Assert.assertEquals(4,res.intValue()); res = demoService.fun3(2,0,3); Assert.assertEquals(6,res.intValue()); }
總結
這是最常見的幾種覆蓋型別,行覆蓋、判定覆蓋、條件覆蓋優缺點一致,都是較為簡單,方便構造用例,並且能對程式質量有一定保證作用。
路徑覆蓋最完善,但是在一些複雜的場景裡,會帶來測試程式碼指數級增長的副作用,這個對於絕大多數人都是無法接受的。
一般情況下,行覆蓋和判定覆蓋同時做到已經是比較良好的單測程式碼了,jacoco的覆蓋率統計也都是基於判定覆蓋的。
實戰部分(Mock)
mock是什麼
事實上,理論和實際總是有差距的,單測也同樣。
雖然單測的指導性理論知識非常完備,但是實際工作中往往遇到各種各樣的障礙。最常見的,莫過於各種外部依賴了,這些外部依賴總是會阻礙我們書寫自己的單測程式碼。這時候,我們就要用到mock了。
Mock這個單詞的意思就是假的,模擬的。mock就是用一些特殊的程式碼,模擬一下外部依賴,將我們的測試程式碼與外部依賴解耦。
最直接的mock,就是重新寫一個外部依賴的mock類,在裡面返回mock的資料。測試的時候,將外部依賴手動替換成mock類。但是這樣mock需要頻繁修改程式碼,基本沒什麼實際價值。
為了解決mock外部依賴的需求,業界也出現了各種各樣的mock框架。常見的有mockito、PowerMockito、Spock。
Spock:比較新的測試框架,基於groovy。優點:語法優雅清晰,缺點:groovy需要學習,沒有經歷時間的考驗。
Mockito:最常用,最可靠的測試框架,優點就是沒有什麼太大的缺點。
PowerMock:為了解決Mockito等基於cglib的框架無法mock 私有、靜態方法而產生的框架。
這裡都採用Mockito來作為使用的測試框架。
簡單Mock實戰
整體程式碼詳見:https://github.com/csonezp/mockdemo
這裡就寫一些關鍵程式碼。
service是最需要關注的地方,重要的邏輯一般都在這裡;同時又有諸多的外部依賴,用這一層做實際mock的例項是最合適的。
/** * @author zhangpeng34 * Created on 2019/1/18 下午9:15 **/ @Service public class UserServiceImpl implements UserService { @Autowired UserDao userDao; @Override public User findUserById(Long id) { return userDao.findById(id).orElse(null); } @Override public User addUser(String name) { if(StringUtils.isEmpty(name)){ return null; } User user = new User(); user.setName(name); return userDao.save(user); } }
這個service很簡單,這裡針對裡面的addUser方法寫一些對應的單測:
/** * * 單元測試,測試的目的是對java程式碼邏輯進行測試。 * 單純的邏輯測試,不應該載入外部依賴,所有的外部依賴應該mock掉,只關注本身邏輯。 * 例如,需要測試service層時,所依賴的dao等,應提前mock掉,設定好測試需要的輸入和輸出即可。 * dao層的邏輯應由dao層的測試保證,service層預設dao層是正確的。 **/ @RunWith(MockitoJUnitRunner.class) public class UserServiceTests { //mock註解建立一個被mock的例項 @Mock UserDao userDao; //InjectMocks代表建立一個例項,其他帶mock註解的示例將被注入到該例項用。 //可以用該註解建立要被測試的例項,將例項所需的依賴用mock註解建立,即可mock掉依賴 @InjectMocks UserServiceImpl UserServiceImpl; String addUserName = "testAddUser"; /** * 初始化時設定一些需要mock的方法和返回值 * 這裡的設定表示碰到userDao的save方法,且引數為任一User類的例項時,返回提前預設的值 */ @Before public void init(){ User user =new User(); user.setId(1L); user.setName(addUserName); Mockito.when(userDao.save(any(User.class))) .thenReturn(user); } //正向流程 @Test public void testAddUser(){ User user = UserServiceImpl.addUser(addUserName); Assert.assertEquals(addUserName,user.getName()); Assert.assertEquals(1L,user.getId().longValue()); } //異常分支,name為null @Test public void testAddUserNull(){ User user = UserServiceImpl.addUser(null); Assert.assertEquals(null,user); } //將各個分支都寫出test和assert //............ }
整合測試
上面所說的都是單元測試,但是實際開發中,我們往往不光需要單元測試(甚至不需要單元測試。。。。),還需要有整合測試,來測試我們程式的整體執行情況。
整合測試並不是聯調,整合測試用依然可以mock第三方依賴。
在我們的工程裡,一般只要實際啟動整個spring容器的測試程式碼,都是整合測試。
Controller層是較為適合整合測試的地方,這裡用Controller層來做整合測試的示例。
@RestController public class UserController { @Autowired UserService userService; @PostMapping(value = "/user") public Object register(String name) { return userService.addUser(name); } @GetMapping(value = "/user/{userId}") public Object getUserInfo(@PathVariable Long userId) { User user = userService.findUserById(userId); if (user != null) { return user; } return "fail"; } @PostMapping(value = "/user/login") public Object login(Long id, String pwd) { User user = userService.findUserById(id); if(user!=null){ return user; } return "fail"; } }
下面寫一個這次整合測試用的spring測試檔案,位置如下:
測試程式碼:
配置檔案如下:
spring.profiles=it server.port=9898 spring.h2.console.enabled=true spring.h2.console.path=/h2 spring.datasource.driver-class-name=org.h2.Driver spring.datasource.url=jdbc:h2:mem:db;DB_CLOSE_DELAY=-1 spring.datasource.username=sa spring.datasource.password=sa spring.datasource.max-wait=10000 spring.datasource.max-active=5 spring.datasource.test-on-borrow=true spring.datasource.test-while-idle = true spring.datasource.validation-query = SELECT 1 # jpa spring.jpa.hibernate.ddl-auto=update #spring.jpa.hibernate.ddl-auto= create-drop spring.jpa.hibernate.naming_strategy=org.hibernate.cfg.ImprovedNamingStrategy spring.jpa.show-sql=true spring.jpa.generate-ddl=true
這個配置檔案的主要作用就是將程式連線的DB換成一個記憶體資料庫h2,這樣就不用在測試時受DB的掣肘了。
@RunWith(SpringRunner.class) @SpringBootTest @ActiveProfiles("it") @AutoConfigureMockMvc public class UserControllerTests { @Autowired private MockMvc mvc; @Autowired UserDao userDao; Long userId; @Before public void init(){ User user = new User(); user.setName("111"); userId = userDao.save(user).getId(); System.out.println(userId); } /** * 測試/user/{userId} * @throws Exception */ @Test public void testGetUser() throws Exception { //success this.mvc.perform(get("/user/"+userId)).andExpect(status().isOk()) .andExpect(content().json("{ \"id\": "+userId+", \"name\": \"111\" }")); //fail this.mvc.perform(get("/user/"+(userId+100))).andExpect(status().isOk()) .andExpect(content().string("fail")); } /** * 測試login * @throws Exception */ @Test public void exampleTest2() throws Exception { //success this.mvc.perform(post("/user/login").param("id",userId.toString()).param("pwd","11")) .andExpect(status().isOk()) .andExpect(content().json("{ \"id\": "+userId+", \"name\": \"111\" }")); //fail this.mvc.perform(post("/user/login").param("id",userId.toString()+"11").param("pwd","11")) .andExpect(status().isOk()) .andExpect(content().string("fail")); } }
@ActiveProfiles("it")
這個註解就是制定本測試程式碼載入的配置檔案,”it“指 檔名 application-XX.properties
中間的xx,spring會自動根據名稱去載入對應的配置檔案。
init()
方法就是在記憶體資料庫中構造自己需要的資料,這是整合測試最常見的步驟。
後面的測試程式碼就不需要解釋太多