Java標准單測實踐分享


一、單測的目的

1、單測可以很好保證代碼質量。

從而增加開發人員的信心,這點就不再贅述了。

2、單測可以一定程度提高代碼合理性。

當我們發現給一個方法寫單測非常困難,比如單測需要覆蓋的分支非常多,那可能說明方法可以拆分;又比如單測需要mock的調用非常多,那可能說明方法違背了單一責任原則,處理了太多的邏輯,也可以拆分等等。

3、單測能夠有效防止回溯問題(regression issue)的出現。

所謂回溯問題,指的就在之前版本沒有在新版本才出現的問題。這種問題的嚴重程度是最高的,影響也是最惡劣的。原因很簡單:用戶可以接受一個本來在老版本就不存在的功能不可用,但是一定無法接受一個本來在老版本用地好好的功能突然失效了。在新功能開發完后,運行老功能單測,如果發現未變更邏輯的老功能單測報錯,則很有可能是出現了回溯問題。

4、單測能夠幫助測試人員確定回歸范圍。

這一點其實是第三點擴展。在新功能提測的時候,開發人員需要提供測試范圍,畢竟隨着功能的不停增加,全量回歸已經變得越來越不可能了。有些開發同學,為了安全起見,隨意增加回歸范圍,這無疑增加了測試人員不必要的工作,是一種嚴重浪費測試資源的行為。在新功能開發完后,運行老功能單測,如果發現單測報錯,則說明這部分老功能的邏輯可能發生了變化,單測需要進行相應的調整,且相關功能應該屬於回歸的范圍。

二、單元測試規范

1、可衡量:單測的編寫應該是可以用具體的指標衡量的

單測通過率要求100%,行覆蓋率要求60%(目前mapyasign和query系統要求)

解釋:通過率100%沒啥好多說的,如果單測跑不通過,那不是單測有問題就是代碼邏輯有問題。覆蓋率的話可以根據具體的工程進行微調,建議不應小於40%,越底層的代碼覆蓋率應該越高,越新的代碼覆蓋率也應該越高。

老代碼有邏輯變更時,單測也應該做相應的變更。

解釋:這點的目的也是為了保證單測通過率100%。同時,這部分功能應該也屬於改次功能的測試回歸范圍內。

新業務提測前,必須保證老單測的通過率也保持100%。

解釋:這點的目的是為了防止回溯問題的出現。

2、獨立性:單測應該是獨立且相互隔離的

一個單測只測試一個方法。

解釋:保證了單測的獨立性。當單測出錯的時候也能夠明確知道是哪個方法出了問題。但這並不是說一個方法只對應一個單測,因為為了覆蓋方法內的不同分支,我們可以為一個方法創建多個單測。

單測不應該依賴於別的單測。

解釋:保證了單測的獨立性。每個單測應該都能獨立運行。不應該有A單測跑完才能跑B單測的情況。

單測如果涉及到數據變更,必須進行回滾或隔離。

解釋:保證了單測的隔離性。如果單測運行后在數據庫中產生了數據,那這些臟數據可能干擾測試同學的測試工作,且也可能影響別的單測的運行結果。

3、 規范性:單測的編寫需要符合一定規范

對實現類進行測試而非接口。

解釋:面向接口編程,面向實現測試。

單測應該是無狀態的。

解釋:即單測應該可以重復執行,且無論跑幾次都應該保證通過率。比如有些方法會對當前時間進行判斷,對於這類方法的單測也需根據當前時間的不同而進行不同的測試。

覆蓋范圍應包括所有提供了邏輯的類:service層、manager層、自定義mapper等,甚至還有部分提供業務邏輯的controller層代碼。

解釋:只要是提供了邏輯的就應該測試,不過個人並不建議在controller層提供業務邏輯,具體原因參考《設計之道-controller層的設計》。

覆蓋范圍不應包括自動生成的類:如MyBatis Generator生成的Mapper類、Example類,不應包括各種POJO(DO,BO,DTO,VO...),也不應包括無業務邏輯的controller類。

解釋:自動生成的類有啥好測的?POJO的getter/setter有啥好測的?沒有提供業務邏輯的controller類有啥好測的?這些被排除的類應該在覆蓋率統計中被剔除。

私有方法通過調用類的單測進行測試。

解釋:因為私有方法在測試類內沒法直接調用,除非使用反射或其他Mock框架(PowerMock, TestableMock等)。

三、單測原則

強制:

  1. 以Class為最小測試單元

  2. 盡量不依賴Spring環境

  3. mock依賴的下游服務或基礎組件

四、最佳實踐

創建單測類

1、使用idea快捷鍵command+shift+T創建單測類

2、引入junit斷言,mockito依賴--自動import時一定要看清是否和下面一樣

import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.BDDMockito.*;

3、創建test類與被測試類在同package下

有些實現會使用抽象類,在實現類中沒有public方法,只有protect和private方法,若test類與被測試類不在同一package下,則無法對被測類中的方法進行測試;在同一目錄則可以通過protect方法進行測試。

規范:test類命名應該為 被測類名+Test 。

4、測試類必不可少的

//InjectMocks注解定義被測試類,注意是實現類,而非接口類
@InjectMocks
private HRTokenThriftServiceImpl hrTokenThriftService;
//使用Mock注解定義被測試類中依賴的bean
@Mock
private TokenService tokenService;
​@BeforeEach
void setup() {
  //啟用mockito注解
  MockitoAnnotations.openMocks(this);
}
 

實踐分享

1、寫單測時使用的givenwillThrow應是org.mockito.BDDMockito.*

2、目前各服務中存在2種以上(包括2種)的測試包依賴,在寫標准單測時要注意@Test是否是屬於org.junit.jupiter.api

3、在測試controller類中的http接口時,需要從Request中獲取userId、userType等字段,實現方法如下

使用getAttribute("xxx")獲取request中參數的mock方法
try (MockedStatic rch = mockStatic(RequestContextHolder.class)) {
   //不同點
   HttpServletRequest http = new MockHttpServletRequest();
   http.setAttribute("mainUserId", 123456L);
   ServletRequestAttributes request = new ServletRequestAttributes(http);
   rch.when(RequestContextHolder::getRequestAttributes).thenReturn(request);
   ......
 }
使用getParameter("xxx")獲取request中參數的mock方法
try (MockedStatic rch = mockStatic(RequestContextHolder.class)) {
  //不同點
  MockHttpServletRequest http = new MockHttpServletRequest();
  http.setParameter("payMemberId", "123456");
  http.setParameter("idType", "merchant");
  http.setParameter("bankcardId", "34215633");
  ServletRequestAttributes request = new ServletRequestAttributes(http);
  rch.when(RequestContextHolder::getRequestAttributes).thenReturn(request);
  ......
  }
需要mock多個靜態方法時
try (MockedStatic rch = mockStatic(RequestContextHolder.class);
      MockedStatic api = mockStatic(APIParamsSecurityHolder.class);
      MockedStatic responseVoMockedStatic = mockStatic(ResponseVo.class)
     ) {
   MockHttpServletRequest http = new MockHttpServletRequest();
   http.setParameter("payMemberId", "123456");
   http.setParameter("idType", "merchant");
   http.setParameter("bankcardId", "34215633");
   ServletRequestAttributes request = new ServletRequestAttributes(http);
   rch.when(RequestContextHolder::getRequestAttributes).thenReturn(request);
​
   responseVoMockedStatic.when(()->ResponseVo.checkStatus(anyString())).thenReturn(true);
   api.when(()->APIParamsSecurityHolder.getString("bankcardId", true)).thenReturn("34215633");
  ......
 }

4、mock異常場景與assert校驗

GetOneClickPayOpenInfoReq getOneClickPayOpenInfoReq = new GetOneClickPayOpenInfoReq();
getOneClickPayOpenInfoReq.setUserId(12345L);
getOneClickPayOpenInfoReq.setMtPlanId(1585L);
//構造拋出異常
willThrow(new TException()).given(thriftOneClickPayService).getOneClickPayOpenInfo(getOneClickPayOpenInfoReq);
try {
  UserQuotaInfo userQuotaInfo = oneClickPayService.getUserQuotaInfo(1585L, 12345L);
  Assert.assertNull(userQuotaInfo);
} catch (CommonException e) {
  //校驗是否是符合預期的異常
  Assert.assertEquals(CommonExceptionEnum.PAYMEMBER_MPAYSIGN_BUSINESS_ONE_CLICK_PAY_QUOTA_INFO_FAIL, e.getCommonExceptionEnum());
}

5、對於需要構造大量數據的可以自己寫簡單測試方法來獲取線下庫中數據,將其輸出成json格式,然后進行JsonToBean轉化 

String str2 = "text";
MtScorePayPlanWrapperVo mtScorePayPlanWrapperVo = JSONObject.parseObject(str2, MtScorePayPlanWrapperVo.class);
given(mtScorePayPlanWrapperService.findMtScorePayPlanWrapperVoByPlanId(scorePlanId)).willReturn(mtScorePayPlanWrapperVo);
​

6、對於使用了@Builder的對象,無法使用工具進行JsonToBean的轉化--大家有好的解決方法嗎??

目前沒有好的方法,構造返回值時比較費時
DeductMtPlanVo.DeductMtPlanVoBuilder deductMtPlanVo = DeductMtPlanVo.builder();
List<MtPlan_PayType> list = Lists.newArrayList();
list.add(MtPlan_PayType.card);
list.add(MtPlan_PayType.balance);
list.add(MtPlan_PayType.maidan);
deductMtPlanVo.optimal(MtPlan_PayType.maidan)
  .paytypes(list)
  .order_quota(500)
  .deductText("優先從扣款")
  .unifyDoubleOpenButton(false).sequentialModel(4)
  .memo("優先從扣款").rule(MtPlan_Rule.mt).scene(2)
  .uuidCare(MtPlan_UuidCare.no)
  .mt_plan_content("先享后付").iph(41807181701589231L)
  .wxDoubleOpenButton(false).begintime(1582536536)
  .showList(false).mt_plan_name("先享后付")
  .cancel_duration(0).partnerId(0)
  .mtPlanId(103943).day_quota(3000);
DeductMtPlanVo deductMtPlanVo = deductMtPlanVo.build();
given(mpmMtPlanIdAdapter.getMtPlanInfo(mtScorePayPlan.getCreditConfigVo().getConfigForMaidan().getMtPlanId(), false)).willReturn(deductMtPlanVo);

7、mock時有時使用any()、anyInt()、anyLong()等做入參時,會出現mock結果返回不符合預期的情況,這時可以將設置過的參數換成實際值,而不是全部使用這類匹配參數

8、mock多線程Future下游

String dis = "text";
AgreementDisplay agreementDisplay = JSONObject.parseObject(dis, AgreementDisplay.class);
Future<AgreementDisplay> agreementDisplayFuture = mock(Future.class);
given(agreementDisplayFuture.get()).willReturn(agreementDisplay);
given(asyncCreditPayAdapter.asynGetAgreementDisplay(userId, sellerId)).willReturn(agreementDisplayFuture);

9、當一個class中有A()、B()兩個對外的方法時,在測試A()方法時,內部又調用了B(),這時如果想把B()方法mock掉,需要對該class進行spy處理

例:這里要測試WalletPaypassManageController.paypass(),但是paypass()內部又調用了getTouchDetail(),對getTouchDetail()的返回值進行mock
 
import static org.mockito.Mockito.spy;
​
class WalletPaypassManageControllerTest {
  @InjectMocks
  WalletPaypassManageController walletPaypassManageController;
  @BeforeEach
  void setup() {
    MockitoAnnotations.openMocks(this);
  }
  @Test
  void paypassTest() {
    ResponseVo touchVo  = new ResponseVo();
    touchVo.setStatus("success");
    touchVo.setData(Maps.newHashMap());
    WalletPaypassManageController spy = spy(walletPaypassManageController);
    willReturn(touchVo).given(spy).getTouchDetail(http, 1, "finger");
​
    ResponseVo<?> paypass = spy.paypass(http, "imsi", 12, 1, "1", "modelKey", "finger");
    //Asesert
  }

10、對於多次會用到的變量,如userId、mtPlanId、mtIph等可以設置成靜態變量

11、進行最后的Assert時,要保證能夠檢驗到代碼邏輯變動

12、對@value注解進行mock

ReflectionTestUtils.setField(bean, "fieldName", "value")

 13、異步方法的測試

例如下面這個方法的測試:

測試樣例:

 參考文獻

單元測試-FAQ

如何寫好標准單測

使用Powermock對私有方法進行mock

《阿里巴巴Java開發手冊.pdf》第三章


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM