隨行付微服務測試之單元測試

本分類文章,與「隨行付研究院」微信號文章同步,第一時間接收公衆號推送,請關注「隨行付研究院」公衆號。

背景

單元測試爲代碼質量保駕護航,是提高業務質量的最直接手段,實踐證明,非常多的缺陷完全可以通過單元測試來發現,測試金字塔提出者Martin Fowler 強調如果一個高層測試失敗了,不僅僅表明功能代碼中存在bug,還意味着單元測試的欠缺。因此,無論何時修復失敗的端到端測試,都應該同時添加相應的單元測試。 而越早發現發現Bug,造成的浪費就會越小,單元測試本身就能夠提供了快速反饋的機制。另外,單元測試是一個優秀的開發工程師必備技能之一,優秀的單元測試是業務快速投產的加速器。

微服務架構下開展單元測試的意義

雖然對於100%的單元測試覆蓋率我們持有保留態度,但在一個微服務架構基礎設施還不完善、開發人員能力參差不齊、DDD(領域驅動設計)能力不足以應對複雜業務的情況下,單元測試是性價比最高的實踐。單元測試可以充當一個設計工具,它有助於開發人員去思考代碼結構的設計,讓代碼更加有利於測試,滿足架構的可測性設計要求。

單元測試的意義包括如下內容:

  • 儘早發現缺陷,降低開發投入成本

    • 85%的缺陷是代碼階段產生的,單元測試階段可以發現絕大部分軟件缺陷。同時軟件產品的缺陷發現的越早往往會大大的降低其開發的投入成本,其缺陷的發現時間與修復缺陷的成本如下圖中紅色曲線。紅色曲線表明隨着軟件開發的進行,漏洞越早發現,其修復的成本越低,並且其修復成本與開發進度的上升趨勢越在後期越接近於指數上升。

  • 放心重構

    • 無論是對單體項目還是單體項目向微服務架構遷移,代碼都在不斷的在變化和重構,通過單元測試,開發可以放心的修改重構代碼,減少改代碼時心理負擔,提高重構的成功率。
  • 改進設計

    • 越是良好設計的代碼,越容易編寫單元測試,多個小的方法的單測一般比大方法(成百上千行代碼)的單測代碼要簡單、要穩定,一個依賴接口的類一般比依賴具體實現的類容易測試,所以在編寫單測的過程中,如果發現單測代碼非常難寫,一般表明被測試的代碼包含了太多的依賴或職責,需要反思代碼的合理性,進而推進代碼設計的優化,形成正向循環。
  • 選擇測試驅動開發(TDD)的模式進行項目開發,以單元測試引導項目實現。這種模式下單元測試先行,根據單元測試代碼開發功能代碼,進而非常精準的實現業務需求,減少返工和缺陷率,可提高項目質量和效率。

單元測試的常見誤解

  • 單元測試浪費了太多的時間

    • 雖然不進行單元測試可以更快的交付到後續測試階段,但是在後續集成測試階段、系統測試階段會發現更多的缺陷甚至軟件無法運行的致命缺陷,這些缺陷修復的時間遠超過單元測試的時間。另外沒有單元測試的代碼後期軟件進行重構或者改進時花費的時間也比有單元測試的所花費的時間要多很多。所以說完整計劃下的單元測試是對時間的更高效的利用。
  • 已經有接口集成測試、系統功能測試進行質量保證了,集成測試階段對接口進行全面測試就可以達到單元測試的要求,沒必要做重複工作在進行單元測試。

    • 接口測試和功能測試無法覆蓋所有的代碼,這樣如果缺陷存在則將被遺漏,並且Bug將被帶到生產上去。一旦用戶使用過程中觸發了這些沒有測試的代碼就會帶來嚴重的經濟後果。
  • 跑通一個業務主流程等價於做過單元測試

    • 目前有很多開發人員認爲,開發完代碼之後,寫個main方法,從入口調完所有的模塊,最後驗證下返回結果,就認爲做過單元測試了,這種想法是及其錯誤的,這充其量算一種不全面的冒煙測試,是對單元測試概念的錯誤認知。

##微服務架構下如何開展單元測試 下面將從單元測試所處的階段、單元測試用例設計規範、單元測試實現幾個維度分別介紹如何在微服務模式下開展單元測試。 首先看下單元測試所處的階段,下圖爲非TDD模式下單元測試所處的階段

由圖可見單元測試處在特性分支開發完成之後,具體的描述如下:

  • 1.開發人員從Master分支拉取特性分支作爲開發分支;
  • 2.開發完特性分支後、代碼構建、單元測試、靜態代碼掃描;
  • 3.通過後合併到Master分支,用於投產。

下面看下什麼樣的單元測試用例是優秀的用例,是即滿足運行速度又滿足高覆蓋率的用例。隨行付定製了單元測試規範,下面節選了強制要求的部分規範。優秀的單元測試用例要符合以下用例設計規範的要求。

  • 1.必須遵守 AIR 原則

    • 【說明】單元測試在線上運行時,感覺像空氣(AIR)一樣並不存在,但在測試質量的保障上,卻是非常關鍵的。好的單元測試宏觀上來說,具有自動化、獨立性、可重複執行的特點。 A:Automatic(自動化) I:Independent(獨立性) R:Repeatable(可重複)
  • 2.單元測試應該是全自動執行的,並且非交互式的

    • 【說明】測試框架通常是定期執行的,執行過程必須完全自動化纔有意義。輸出結果需要人工檢查的測試不是一個好的單元測試。單元測試中不準使用 System.out 來進行人肉驗證,必須使用 assert 來驗證。
  • 3.保持單元測試的獨立性

    • 【說明】爲了保證單元測試穩定可靠且便於維護,單元測試用例之間決不能互相調用,也不能依賴執行的先後次序。反例:method2 需要依賴 method1 的執行,將執行結果做爲 method2 的輸入
  • 4.單元測試是可以重複執行的,不能受到外界環境的影響

    • 【說明】單元測試通常會被放到持續集成中,每次有代碼 check in時單元測試都會被執行。如果單測對外部環境(網絡、服務、中間件等)有依賴,容易導致持續集成機制的不可用。
  • 5.對於單元測試,要保證測試粒度足夠小,有助於精確定位問題。單測粒度至多是類級別,一般是方法級別

    • 【說明】只有測試粒度小才能在出錯時儘快定位到出錯位置。單測不負責檢查跨類或者跨系統的交互邏輯,那是集成測試的領域
  • 6.核心業務、核心應用、核心模塊的增量代碼確保單元測試通過

    • 【說明】新增代碼及時補充單元測試,如果新增代碼影響了原有單元測試,請及時修正
  • 7.單元測試代碼必須寫在如下工程目錄:src/test/java,不允許寫在業務代碼目錄下

    • 【說明】源碼構建時會跳過此目錄,而單元測試框架默認是掃描此目錄

隨行付在推行單元測試落地過程中採用循序漸進的方式,逐步增加單元測試用例達到單元測試規範中規定的覆蓋率要求。需要說明的是我們不是追求覆蓋率這個數字指標,那樣就捨本求末了,我們是通過覆蓋率這個可以量化的指標實現提高代碼質量的這個根本目的。

  • 第一階段:單元測試覆蓋率要求至少25%
  • 第二階段:單元測試覆蓋率要求至少60%
  • 第三階段:單元測試覆蓋率要求至少80%

隨行付單元測試覆蓋率統計同樣採用SonarQube平臺結合Jenkins工具,Jacoco單元測試覆蓋率工具完成,這個同上篇介紹的靜態代碼掃描流程是一脈相承的。同時要求開發人員本地的IDE工具中安裝Jacoco覆蓋率插件,當本地開發完單元測試用例並構建後,即可看到覆蓋率信息,進而可以快速補充用例,達到覆蓋率要求。 以Eclipse爲例,當開發完單元測試代碼後,按照如下操作即可查看覆蓋率信息。

  1.選擇需要統計的java測試代碼或者包;
  2.右鍵,Coverage as->Junit
  3.覆蓋率結果會自動在Coverage 視圖中展示出來;
  4.在Java編輯器中用不同的顏色標識代碼的覆蓋情況。
    【說明】 綠色----全覆蓋
          紅色----未覆蓋
          黃色----部分覆蓋

下面介紹下在微服務下應該如何進行單元測試。爲了有效的進行單元測試,需要遵循一定的方法,通常採用路徑覆蓋法設計單元測試用例。所謂路徑覆蓋法就是選取足夠多的測試數據,使程序的每條可能路徑都至少執行一次(如果程序圖中有環,則要求每個環至少經過一次)。具體設計過程參見如下步驟:

 1.畫出程序控制流程圖
 2.計算圈複雜度
 3.找出所有程序基本路徑
 4.根據路徑設計測試數據

以下圖代碼爲例說明路徑覆蓋法的設計單元測試的過程

  1. 首先根據代碼畫出其對應的流程圖如下,圖中數字代表行號。當條件語句中包含多個條件時應予以拆分,如第13行,拆分爲13.1和13.2;對於沒有分支和循環的語句可忽略,如第16行。

  1. 有了流程圖後,我們可以根據它計算出圈複雜度,這個可以作爲測試用例數的上限,圈複雜度計算公式如下:

    V(G)= E - N + 2,E是流圖中邊的數量,N是流圖中結點的數量。 V(G)= P + 1 ,P是流圖G中判定結點的數量。

  2. 兩個公式用哪個都行,最後的結果應該是一樣的。這裏我們用第二個公式,V(G)= 3 + 1 = 4,也就是我們只需要設計4條用例即可覆蓋所有路徑

  3. 接下來就是找出所有基本路徑,基本路徑是從程序的開始結點到結束可以選擇任何的路徑遍歷,但是每條路徑至少應該包含一條已定義路徑不曾用到的邊,所有的基本路徑如下

    A B C B D E F B D E G E F

  4. 得到了所有的基本路徑,剩下的簡單了,只需要按照路徑設計出對應的入參數據即可

    案例1:a = 0, b = 1, 期望值 -1

    案例2:a = 1, b = 0, 期望值 -1

    案例3: a = 4, b = 2, 期望值 2

    案例4:a = 8, b = 12, 期望值 4

除此之外,單元測試用例設計還需要考慮以下場景:

 邊界值
     業務邊界
     溢出邊界
    字符串、數組、集合等的邊界
異常場景
    業務異常
    輸入異常(如參數不合法)
正常場景
    單個模塊的用例設計都可以按照路徑覆蓋法達到語句覆蓋和分支覆蓋,但是對於有依賴關係的模塊

在微服務模式下,每個模塊之間會存在依賴的情況,爲了保持單元測試的獨立性原則,在不依賴於外部條件的情況下製造各種輸入數據,需要藉助Mock技術,其本質是用一個模擬的對象代替真實的對象(例如一個類、模塊、函數或者微服務)。模擬對象的行爲特徵和真實對象非常相似,採用相同的調用邏輯,返回內容按照之前預定義的內容返回,提供返回數據。Mock技術的原理可以用如下案例進行解釋。

當要進行單元測試時,需要給A注入B和C,但是C又依賴D,D又依賴E。這就導致了,A的單元測試不滿足獨立性原則。 但使用了Mock來進行模擬對象後,就可以把這種依賴解耦,只關心A本身的測試,它所依賴的B和C,全部使用Mock出來的對象,並且給MockB和MockC指定一個明確的行爲。

在單元測試工具的選擇方面,隨行付單元測試藉助Junit工具和Mockito工具進行單元測試,微服務模式下不管是spring boot還是spring cloud,通常使用@SpringBootTest註解進行單元測試。一個單元測試的實現步驟主要包括4步:

  1. 設置測試數據
  2. Mock依賴的系統並給定預期值,如果沒有依賴這步可以省略
  3. 在測試中調用方法
  4. 斷言返回的結果是否符合預期

下面以一個非常簡單的例子介紹在微服務模式下如何對spring boot中的controller層和service層進行單元測試。

調用邏輯簡化版如圖所示,Controller調用ServiceA,ServiceA依賴ServiceB。

被依賴ServiceB的代碼如下

  package cn.vbill.quality.service;
  import org.springframework.stereotype.Service;

  [@Service](https://my.oschina.net/service)
  public class ServiceB {
       public boolean serve(int param) {
       return param % 2 == 0;
       }
  }

被測ServiceA的代碼如下

package cn.vbill.quality.service;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

[@Service](https://my.oschina.net/service)
public class ServiceA {
   @Autowired
   private ServiceB srvB;

   public String doSomething(int param) {
      if (srvB.serve(param)) {
        return "even";
      }
    return "obb";
  }
}

ServiceA和ServiceB的邏輯非常簡單,現在測試ServiceA,步驟如下:

首先:在gradle中增加測試需要的依賴包

  // 可根據實際情況添加版本號
  testCompile("org.springframework.boot:spring-boot-starter-test")

其次:在src/test/java下面創建測試類,採用@SpringBootTest註解和Mockito技術對ServiceB進行測試和Mock,更多Mockito的使用可以參考其他文章,這裏不過多介紹。代碼如下:

 package cn.vbill.quality.service;
 import static org.junit.Assert.assertEquals;
 import static org.mockito.Mockito.when;
 import org.junit.Before;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.mockito.InjectMocks;
 import org.mockito.Mock;
 import org.mockito.Mockito;
 import org.mockito.MockitoAnnotations;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.boot.test.context.SpringBootTest;
 import org.springframework.test.context.junit4.SpringRunner;

 // 以下兩個註解在Spring測試中可以說是固定寫法
 @RunWith(SpringRunner.class)
 @SpringBootTest
 public class ServiceATest {
 @InjectMocks  //創建被測試類實例
 @Autowired
 private ServiceA srvA; // 先自動裝配ServiceA,然後用Mock的ServiceB替換原來的ServiceB

 @Mock
 private ServiceB srvB; // 自動生成ServiceB的Mock實例

 @Before
 public void setup() {
    // 必須在ServiceA完成自動裝配後在調用此方法
    // 處理@Mock註解,注入Mock對象
    MockitoAnnotations.initMocks(this);
 }

 // 用Mock對象替換真實的ServiceB可以輕鬆創造出我們所需的場景
 @Test
 public void doSomething_Even_Success() {
    // 設置Mock預期值
    when(srvB.serve(Mockito.anyInt())).thenReturn(true);
    // 因爲Mock的緣故,此處doSomething的實參可隨意寫
    String result = srvA.doSomething(0);
    // 驗證預期值
    assertEquals("even", result);
  }

@Test
public void doSomething_Obb_Success() {
    // 覆蓋另一條分支
    when(srvB.serve(Mockito.anyInt())).thenReturn(false);
    String result = srvA.doSomething(0);
    assertEquals("obb", result);
  }

}

最後,使用覆蓋率工具查看單元測試覆蓋率,如下圖所示,實現了100%覆蓋。

ServiceB沒有任何依賴,因此對它測試就按照常規的Junit測試即可,這裏不過多介紹。下面介紹Controller層的單元測試,整體上看 Controller 層的測試和 Service 層大致相同,只不過是我們不去直接調用 Controller 的方法,而是通過MockMvc模擬HTTP請求。從邏輯圖上看Controller是直接調用ServiceA,因此需要使用Mockito模擬ServiceA。

被測Controller代碼邏輯如下:

package cn.vbill.quality.web;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import cn.vbill.quality.service.ServiceA;

@RestController
@RequestMapping("/")
public class DemoController {
@Autowired
private ServiceA srvA; // ServiceA 代碼見上一節

@GetMapping
public String doSomething(@RequestParam("p") Integer param) {
    return srvA.doSomething(param);
  }
}

測試類如下

package cn.vbill.quality.web;

import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;

import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.Mockito;
import org.mockito.MockitoAnnotations;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.test.web.servlet.MockMvc;

@RunWith(SpringRunner.class)
@SpringBootTest
@AutoConfigureMockMvc // 使用該註解自動配置 MockMvc
public class DemoControllerTest {
 @Autowired
 @InjectMocks
 private DemoController controller;

 @Mock
 private ServiceA srvA;

@Autowired
private MockMvc mvc; // 自動配置 MockMvc

@Before
public void setup() {
    MockitoAnnotations.initMocks(this);
}

@Test
public void doSomething_Success() throws Exception {
    when(srvA.doSomething(Mockito.anyInt())).thenReturn("mock");
    // 使用MockMvc模擬HTTP請求
    // 下面三個類經常使用,常通過靜態導入簡化代碼
    // MockMvcRequestBuilders, MockMvcResultHandlers, MockMvcResultMatchers
    mvc.perform(get("/").param("p", "1")).andExpect(content().string("mock"));
}
}

最後,通過覆蓋率工具查看單元測試覆蓋率爲100%,做到了全覆蓋。

以上是如何在微服務模式下進行單元測試進行了詳細的介紹,在微服務架構下高覆蓋率的單元測試是保障代碼質量的第一道也是最重要的關口,應該持之以恆。

##總結

本篇分別從微服務模式下開展單元測試的意義、對單元測試的常見誤解以及如何開展單元測試三個方面進行介紹,單元測試是一項成本低、收益高的實踐,要利用好這把利劍,打好代碼質量基礎,爲後續的質量保證過程添磚加瓦。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章