如何寫好單元測試?
阿里妹導讀:單元測試的好處到底有哪些?每次單測啟動應用,太耗時,怎么辦?二方三方接口可能存在日常沒法用,只能上預發/正式的情況,上預發測低效如何處理?本文分享三個單元測試神器及相關經驗總結。
一 首先什么是好代碼?
Q1:好代碼應具備可讀性,可測試性,可擴展性等等,那么如何寫出好代碼?
A:設計思想 & 編碼規范。
二 設計思想&設計原則&設計模式
1 設計原則(S.O.L.I.D)
SRP 單一職責原則
- 軟件模塊應該只有一個被修改的理由。在大多數情況下,編寫Java代碼時都會將單一職責原則應用于類。單一職責原則可被視為使封裝工作達到最佳狀態的良好實踐。更改的理由是:需要修改代碼。
- 單一原則,類、方法只干一件事。
OCP 開閉原則
- 模塊、類和函數應該對擴展開放,對修改關閉。
- 通過繼承和多態擴展來添加新功能。開閉原則是最重要的設計原則之一,是大多數設計模式的基礎。
- 軟件建設一個復雜的結構,當我們完成其中的一部分,就應該不要修改它,而是在其基礎上繼續建設。
LSP 里式替換原則
- 在設計模塊和類時,必須確保派生類型從行為的角度來看是可替代的。
- 使用父類的地方都可以用子類替代。
- 父類最好為抽象類。
- 子類可實現父類的非抽象方法,盡量不要覆蓋重寫已實現的方法。
- 子類可寫自身的方法,有自身的特性,在父類的基礎上擴建。
- 子類覆蓋重寫父類方法時,方法的前置條件(即方法的形參)要比父類方法的輸入參數更寬松,后置條件(返回值)要更嚴格。
ISP 接口隔離原則
- 減少了代碼耦合,使軟件更健壯,更易于維護和擴展。
- 客戶端不應該依賴它所不需要的接口。
DIP 依賴倒置原則
- 高級模塊不應該依賴低級模塊,兩者都應該依賴抽象。
- 抽象不應該依賴于細節,細節應該依賴于抽象。
DRY 原則、KISS 原則、YAGNI 原則、LOD 法則
- DRY:不要干重復的事兒。
- KISS:不要干復雜的事兒,思從深而行從簡。
- YAGNI:不要干不需要的事兒,尺度把握尤為重要,超越尺度則會有過度設計之嫌。
- LOD:最小依賴。
設計模式
設計模式最重要的點還是在于解耦和復用,創建型模式將創建代碼與使用代碼解耦,結構型模式是將功能代碼解耦,行為型模式將行為代碼解耦,最終達到高內聚,松耦合的目標,設計模式體現了設計原則。
附:我們經常說的“高內聚 松耦合”究竟什么是高內聚,什么是松耦合?
- 高內聚:相近功能放在同一類中,相近功能往往會被同時修改,放到同一個類中在修改時,代碼更易維護(指導類本身的設計)
- 松耦合:類與類之間的依賴關系簡單清晰,一個類的代碼改動不會或者很少導致依賴類的代碼修改(指導類間依賴關系設計)
Q2: 那么如何驗證代碼是好代碼呢?
A: CR & 單測(下面進入正題^_^)
三 什么是單測?
單元測試(unit testing),指由開發人員對軟件中的最小可測試單元進行檢查和驗證。對于單元測試中單元的含義,一般來說,要根據實際情況去判定其具體含義,如C語言中單元指一個函數,Java里單元指一個類,圖形化的軟件中可以指一個窗口或一個菜單等。總的來說,單元就是人為規定的最小的被測功能模塊。單元測試是在軟件開發過程中要進行的最低級別的測試活動,軟件的獨立單元將在與程序的其他部分相隔離的情況下進行測試。
來源:https://baike.baidu.com/item/單元測試
四 為什么要寫單測?
1 異(che)常(huo)場(xian)景(chang)
相信大家肯定遇到過以下幾種情況:
- 測試環境沒問題,線上怎么就不行。
- 所有異常捕獲,一切盡在掌控(你以為你以為的是你以為的)。
- 祖傳代碼,改個小功能(只有上帝知道)。
- .....
要想故障出的少,還得單測好好搞。
2 優點
提高代碼正確性
- 流程判讀符合預期,按照步驟運行,邏輯正確。
- 執行結果符合預期,代碼執行后,結果正確。
- 異常輸出符合預期,執行異常或者錯誤,超越程序邊界,保護自身。
- 代碼質量符合預期,效率,響應時間,資源消耗等。
發現設計問題
- 代碼可測性差
- 方法封裝不合理
- 流程不合理
- 設計漏洞等
提升代碼可讀性
- 易寫單測的方法一定是簡單好理解的,可讀性是高的,反之難寫的單測代碼是復雜的,可讀性差的。
順便微重構
- 如設計不合理可微重構,保證代碼的可讀性以及健壯性。
提升開發人員自信心
- 經過單元測試,能讓程序員對自己的代碼質量更有信心,對實現方式記憶更深。
啟動速度,提升效率
不用重復啟動Pandora容器,浪費大量時間在容器啟動上,方便邏輯驗證。
場景保存(多場景)
- 在HSF控制臺中只能保存一套參數,而單測可保存多套參數,覆蓋各個場景,多條分支,就是一個個測試用例。
CodeReview時作為重點CR的地方
好的單測可作為指導文檔,方便使用者使用及閱讀
- 寫起來,相信你會發現更多單測帶來的價值。
3 舉個小例子
改動前:OSS文件夾概念是通過文件名創建的,下面改動前的方法入參是File,該方法可以正常使用,但是在寫單測的時候,我發現使用文件有兩個成本:
- 必須要有默認文件。
- 要編寫獲取文件的路徑的方法。
坑:本地獲取的路徑與在容器獲取的路徑是不一致的,復雜度明顯增高。
- /**
- * 向阿里云的OSS存儲中存儲文件 (改動前)
- *
- * @param client OSS客戶端
- * @param file 上傳文件
- * @return String 唯一MD5數字簽名
- */
- private static void uploadObject2Oss(OSS client, File file, String bucketName, String dirName) throws Exception {
- InputStream is = new FileInputStream(file);
- String fileName = file.getName();
- Long fileSize = file.length();
- //創建上傳Object的Metadata
- ObjectMetadata metadata = new ObjectMetadata();
- metadata.setContentLength(is.available());
- metadata.setCacheControl("no-cache");
- metadata.setHeader("Pragma", "no-cache");
- metadata.setContentEncoding("utf-8");
- metadata.setContentType(getContentType(fileName));
- metadata.setContentDisposition("filename/filesize=" + fileName + "/" + fileSize + "Byte.");
- //上傳文件
- client.putObject(bucketName, dirName + PublicConstant.DIAGONAL_CHARACTER + fileName, is, metadata);
- }
改動后:將入參file修改為inputStream,這樣便可省去創建文件以及編寫獲取獲取文件路徑方法,同時還避免了獲取路徑的坑,一舉兩得,也通過單測找到了代碼設計不合理之處。
- /**
- * 向阿里云的OSS存儲中存儲文件(改動后)
- *
- * @param client OSS 上傳client
- * @param bucketName bucketName
- * @param dirName 目錄
- * @param is 輸入流
- * @param fileName 文件名
- * @param fileSize 文件大小
- * @throws Exception
- */
- private void uploadObject2Oss(OSS client, String bucketName, String dirName, InputStream is, String fileName,
- long fileSize) throws Exception {
- //創建上傳Object的Metadata
- ObjectMetadata metadata = new ObjectMetadata();
- metadata.setContentLength(is.available());
- metadata.setCacheControl("no-cache");
- metadata.setHeader("Pragma", "no-cache");
- metadata.setContentEncoding("utf-8");
- metadata.setContentType(getContentType(fileName));
- metadata.setContentDisposition("filename/filesize=" + fileName + "/" + fileSize + "Byte.");
- //上傳文件
- client.putObject(bucketName, dirName + PublicConstant.DIAGONAL_CHARACTER + fileName, is, metadata);
- }
4 還想再舉一個
以下這個方法先不說可讀性問題,單從編寫單測來驗證邏輯是否正確,在寫單測時需要:
- 構造sourceInfos列表
- 構造String數組
- 構造map對象
- 構造List
- 構造User 對象
顯然這個方法是非常復雜的,但是邏輯就是得到一個指定長度列表。
- /**
- * 按比例混排結果 (改動前)
- * @param sourceInfos 渠道配比信息
- * @param resultMap 結果
- * @param pageSize 總條數
- * @param aliuid 用戶id
- * @return 結果集
- */
- private List<String> getResultList(List<String[]> sourceInfos, Map<String, List<String>> resultMap, int pageSize, User user) {
- Map<String, Integer> sourceNumMap = new HashMap<>(sourceInfos.size());
- sourceInfos.stream().forEach(s -> sourceNumMap.put(s[0], Integer.parseInt(s[1]) * pageSize / 100));
- List<String> resultList = new ArrayList<>();
- resultMap.forEach((s, strings) -> resultList.addAll(strings.stream().limit(sourceNumMap.get(s)).collect(
- Collectors.toList())));
- // 彌補條數,防止數據量不足
- if (resultList.size() < pageSize) {
- compensate(resultList, pageSize, user.getAliuid());
- }
- return resultList;
- }
改動后:將入參改為List sourceInfos, int pageSize, String aliuid,將String[]改為SourceInfo,提升代碼可讀性,否則無從得知s[0]表示什么,s[1]表示什么,在寫單測時需要:
- 構造List列表
- 構造SourceInfo對象
經過改造,可測試性、可讀性均有提升,另外在這個例子中其實user對象只使用了aliuid,無需傳入整個對象,遵循KISS原則。
- /**
- * 按比例混排結果
- * @param sourceInfos 渠道配比信息
- * @param pageSize 條數
- * @param aliuid 用戶id
- * @return 結果集
- */
- private List<String> getResultList(List<SourceInfo> sourceInfos, int pageSize, String aliuid) {
- // 獲取結果集
- List<String> resultList = sourceInfos.stream()
- .flatMap(sourceInfo -> {
- int needNum = (int)(sourceInfo.getSourceRatio() * pageSize / 100);
- return listSource(sourceInfo.getSourceChannel(), needNum, aliuid).stream();
- }).collect(Collectors.toList());
- // 補償數據
- compensate(resultList, pageSize, aliuid());
- return resultList;
- }
五 如何寫好單測?
1 工具
工欲善其事必先利其器,抗拒寫單測的其中最主要的一個原因就是沒有神器在手!
Fast-tester
每次啟動應用動輒就是幾分鐘起,想要測試一個方法,上個廁所回來可能應用還沒啟動,如此低效,怎么愿意去寫,fast_tester只需要啟動應用一次(tip: 添加注解及測試方法需要重新啟動應用),支持測試代碼熱更新,后續可隨意編寫測試方法,一個字“秀”!
使用方式:
(1)需要引入jar包
- <dependency>
- <groupId>com.alibaba</groupId>
- <artifactId>fast-tester</artifactId>
- <version>1.3</version>
- <scope>test</scope>
- </dependency>
(2)在test的package下創建TestApplication
- /**
- * @author QZJ
- * @date 2020-08-03
- */
- @SpringBootApplication
- public class TestApplication {
- public static void main(String[] args){
- PandoraBootstrap.run(args);
- ConfigurableApplicationContext context = SpringApplication.run(TestApplication.class, args);
- // 將ApplicationContext傳給FastTester
- FastTester.run(context);
- }
- }
(3)編寫需要依賴pandora容器的case
- /**
- * tip:添加注解及方法需要重新啟動應用
- *
- * @author QZJ
- * @date 2020-08-03
- */
- @Slf4j
- public class BucketServiceTest {
- @Autowired
- BucketService bucketService;
- @Test
- public void testSaveBucketInfo() {
- BucketRequest bucketRequest = new BucketRequest();
- // 缺少參數
- bucketRequest.setAccessKeyId("123");
- bucketRequest.setAccessKeySecret("123");
- bucketRequest.setBucketDomain("123");
- bucketRequest.setEndpoint("123");
- bucketRequest.setRegionId("123");
- bucketRequest.setRoleArn("123");
- bucketRequest.setRoleSessionName("123");
- Result<Long> result = bucketService.saveBucketInfo(bucketRequest);
- log.info("缺少參數 result :{}", JSON.toJSONString(result));
- // bucketName 重復
- bucketRequest.setBucketName("video2sky");
- result = bucketService.saveBucketInfo(bucketRequest);
- log.info("bucketName 重復 result :{}", JSON.toJSONString(result));
- // 正例(執行后,則bucketName已存在,需更換bucketName)
- bucketRequest.setBucketName("12345");
- result = bucketService.saveBucketInfo(bucketRequest);
- log.info("正例 result :{}", JSON.toJSONString(result));
- }
- @Test
- public void testCreateBucketFolder() {
- BucketFolderRequest bucketFolderRequest = new BucketFolderRequest();
- bucketFolderRequest.setFolderPath("/test");
- bucketFolderRequest.setAppName("wudao");
- bucketFolderRequest.setDescription("data");
- bucketFolderRequest.setWriteTokenExpireTime(3600L);
- Result<Long> result = bucketService.createBucketFolder(bucketFolderRequest);
- log.info("缺少參數 result :{}", JSON.toJSONString(result));
- // 錯誤的bucketId
- bucketFolderRequest.setBucketId(1L);
- result = bucketService.createBucketFolder(bucketFolderRequest);
- log.info("錯誤的bucketId result :{}", JSON.toJSONString(result));
- // 異常的讀時間,讀寫時間不得超過2小時
- bucketFolderRequest.setWriteTokenExpireTime(7300L);
- result = bucketService.createBucketFolder(bucketFolderRequest);
- log.info("異常的讀時間 result :{}", JSON.toJSONString(result));
- // 重復的bucketFolder
- bucketFolderRequest.setBucketId(11L);
- bucketFolderRequest.setWriteTokenExpireTime(3500L);
- result = bucketService.createBucketFolder(bucketFolderRequest);
- log.info("重復的bucketFolder result :{}", JSON.toJSONString(result));
- // 正例 (本地與服務器默認文件地址不一致,所以本地無法執行成功,除非改地址,或者添加分支代碼)
- bucketFolderRequest.setFolderPath("/test2");
- result = bucketService.createBucketFolder(bucketFolderRequest);
- log.info("正例 result :{}", JSON.toJSONString(result));
- }
- }
(4)啟動TestApplication,輸入對應類名,選擇要執行的相應方法即可(切換測試類,直接重新輸入類路徑(包名+文件名)即可,原理還是反射)。
Tip:如果service注解失敗,檢查測試包的層級,例如:
Junit
JUnit是一個Java語言的單元測試框架, Junit測試是程序員測試,即所謂白盒測試,因為程序員知道被測試的軟件如何(How)完成功能和完成什么樣(What)的功能。繼承TestCase類,就可以用Junit進行自動測試。
來源:https://baike.baidu.com/item/白盒測試
使用方式:
(1)私有方法測試
- /**
- * 普通類測試,無需啟動容器
- *
- * @author QZJ
- * @date 2020-08-05
- */
- @Slf4j
- public class OssServiceTest {
- private OssServiceImpl ossService = new OssServiceImpl();
- @Test
- public void testCreateOssFolder() {
- try {
- // 私有方法測試:方法一:用反射(推薦);方法二:修改類中方法屬性(不推薦)
- Method method = OssServiceImpl.class.getDeclaredMethod("createOssFolder",
- new Class[] {OSS.class, String.class, String.class});
- method.setAccessible(true);
- OSS client = new OSSClientBuilder().build("oss-cn-beijing.aliyuncs.com", "**",
- "****");
- Object obj = method.invoke(ossService, new Object[] {client, "***", "wudao/test3"});
- Assert.assertEquals(true, obj);
- } catch (Exception e) {
- Assert.fail("testCreateOssFolder fail");
- }
- }
- }
(2)相關測試注解如@Ignore使用,相關屬性如timeout測試接口性能、expected異常期望返回結果使用,測試全部測試方法等。
- /**
- * 普通工具類測試
- * @author QZJ
- * @date 2020-08-05
- */
- @Slf4j
- public class DateUtilTest {
- @Ignore // 忽略該方法執行結果
- @Test
- public void testGetCurrentTime(){
- String dateStr = DateUtil.getCurrentTime("yyyy-MM-dd HH:mm");
- log.info("date:{}", dateStr);
- Assert.assertEquals("2020-08-05 17:22", dateStr);
- }
- // 方法超時時間設置以及期望執行拋出的異常類型設置(錯誤的日期格式解析異常)
- @Test(timeout = 110L, expected = ParseException.class)
- public void testString2Date() throws ParseException{
- Date date = DateUtil.string2Date("20202-02 02:02");
- log.info("date:{}" , date);
- //Thread.sleep(200L);
- }
- @BeforeClass
- public static void beforeClass() {
- log.info("before class");
- }
- @AfterClass
- public static void afterClass() {
- log.info("after class");
- }
- @Before
- public void before() {
- log.info("before");
- }
- @After
- public void after() {
- log.info("after");
- }
- public static void main(String[] args) {
- // 不需啟動容器的情況下使用,跑類中所有case
- Result result = JUnitCore.runClasses(DateUtilTest.class);
- result.getFailures().stream().forEach(f -> System.out.println(f.toString()));
- log.info("result:{}", result.wasSuccessful());
- }
- }
詳細使用文檔見:https://wiki.jikexueyuan.com/project/junit/environment-setup.html
Mockito
Mockito是一個針對Java的mocking框架,主要作用mock請求及返回值。
Mockito可以隔離類之間的相互依賴,做到真正的方法級別單測。
使用方式:
(1)需要引入jar包
- <dependency>
- <groupId>org.mockito</groupId>
- <artifactId>mockito-all</artifactId>
- <version>1.9.5</version>
- <scope>test</scope>
- </dependency>
(2)編寫測試代碼(例子)
需要測試的方法中調用了二方/三方接口,而接口無測試環境,為了測試方法邏輯,可以模擬接口返回結果(對原先代碼無侵入),達到應用內測試閉環。
tip:mock數據并非真正的返回值,需要注意返回的結果類型,字符串長度等,防止出現轉化,入庫字段超長等問題。
- @Override
- public ConsumeCodeResult consumeCode(String code) {
- // 權益核銷
- if (code.startsWith(BENEFIT_CENTER_CODE_HEADER) && BENEFIT_CENTER_CODE_LENGTH == code.length()) {
- return consumeCodeFromCodeBenefitCenter(code);
- }
- // 碼商核銷
- return consumeCodeFromCodeCenter(code);
- }
- /**
- * 從權益中心核銷電子憑證
- *
- * @param code 電子碼
- * @return 核銷結果
- */
- private ConsumeCodeResult consumeCodeFromCodeBenefitCenter(String code) {
- // 參數構造
- BenefitUseDTO benefitUseDTO = new BenefitUseDTO();
- benefitUseDTO.setCouponCode(code);
- benefitUseDTO.getExtendFields().put("configId", benefitId);
- benefitUseDTO.getExtendFields().put("type", BenefitTypeEnum.CODE_GENERAL.getType().toString());
- AlispResult alispResult = benefitService.useBenefit(benefitUseDTO);
- log.info("benefitUseDTO:{}, result:{}", benefitUseDTO, alispResult);
- if (alispResult.isSuccess()) {
- BenefitUseResult benefitUseResult = (BenefitUseResult)alispResult.getValue();
- return new ConsumeCodeResult(benefitUseResult.getOutOrderId(),
- String.valueOf(benefitUseResult.getConfigId()), benefitUseResult.getUseTime());
- }
- // 已使用
- if (BizErrorCodeEnum.BENEFIT_RECORD_USED.name().equals(alispResult.getErrCodeName())) {
- throw new BizException(StudentErrorEnum.VERIFICATION_CODE_REPEAT);
- } else if (BizErrorCodeEnum.BENEFIT_RECORD_NOT_EXIST.name().equals(alispResult.getErrCodeName())
- || BizErrorCodeEnum.BENEFIT_RECORD_EXPIRED.name().equals(alispResult.getErrCodeName())) {
- // 不存在或者過期
- throw new BizException(StudentErrorEnum.VERIFICATION_CODE_INVALID);
- } else {
- // 其他異常
- throw new BizException(StudentErrorEnum.VERIFICATION_CODE_CONSUME_FAILED);
- }
- }
- @Test
- public void mockConsume(){
- BenefitService benefitService = Mockito.mock(BenefitService.class);
- // 核銷成功鏈路
- AlispResult alispResult = new AlispResult(true);
- BenefitUseResult benefitUseResult = new BenefitUseResult();
- benefitUseResult.setConfigId(1L);
- benefitUseResult.setOutOrderId("lalala");
- benefitUseResult.setUseTime(new Date());
- alispResult.setValue(benefitUseResult);
- Mockito.when(benefitService.useBenefit(Mockito.any(BenefitUseDTO.class))).thenReturn(alispResult);
- ConsumeCodeService consumeCodeService = new ConsumeCodeServiceImpl(benefitService, "1");
- ConsumeCodeResult consumeCodeResult = consumeCodeService.consumeCode("082712345678");
- System.out.println(JSON.toJSONString(consumeCodeResult));
- alispResult = new AlispResult(false);
- // 已核銷鏈路
- alispResult.setErrCodeName("BENEFIT_RECORD_USED");
- // 已過期鏈路
- //alispResult.setErrCodeName("BENEFIT_RECORD_EXPIRED");
- // 碼不存在鏈路
- //alispResult.setErrCodeName("BENEFIT_RECORD_NOT_EXIST");
- // 其他返回錯誤
- //alispResult.setErrCodeName("LALALA");
- Mockito.when(benefitService.useBenefit(Mockito.any(BenefitUseDTO.class))).thenReturn(alispResult);
- consumeCodeService = new ConsumeCodeServiceImpl(benefitService, "1");
- try {
- consumeCodeService.consumeCode("082712345678");
- } catch (Exception e) {
- e.printStackTrace();
- }
- // 核銷碼頭有誤
- consumeCodeService = new ConsumeCodeServiceImpl(benefitService, "1");
- try {
- consumeCodeService.consumeCode("081712345678");
- } catch (Exception e) {
- e.printStackTrace();
- }
- // 核銷碼長度有誤
- consumeCodeService = new ConsumeCodeServiceImpl(benefitService, "1");
- try {
- consumeCodeService.consumeCode("08271234567");
- } catch (Exception e) {
- e.printStackTrace();
- }
- }
Mockito的功能非常多,可以驗證行為,做測試樁,匹配參數,驗證調用次數和執行順序等等,在這不一一枚舉了,更多詳細使用可見文檔:https://github.com/hehonghui/mockito-doc-zh
2 覆蓋率
覆蓋率是度量測試完整性的一個手段,是測試有效性的一個度量。
覆蓋率準則
- 函式覆蓋率(Function coverage):有呼叫到程式中的每一個函式(或副程式)嗎?
- 指令覆蓋率(Statement coverage):若用控制流圖(英語:control flow graph)表示程式,有執行到控制流圖中的每一個節點嗎?
- 判斷覆蓋率(Decision coverage):(和分支覆蓋率不同)若用控制流圖表示程式,有執行到控制流圖中的每一個邊嗎?例如控制結構中所有IF指令都有執行到邏輯運算式成立及不成立的情形嗎?
- 條件覆蓋率(Condition coverage):也稱為謂詞覆蓋(predicate coverage),每一個邏輯運算式中的每一個條件(無法再分解的邏輯運算式)是否都有執行到成立及不成立的情形嗎?條件覆蓋率成立不表示判斷覆蓋率一定成立。
- 條件/判斷覆蓋率(Condition/decision coverage):需同時滿足判斷覆蓋率和條件覆蓋率。
場景總結
- 必要的
- 復雜的
- 重要的
- 不寫無用的
具體還需自己判斷,但是要避免過度自信。
覆蓋率要求
是否覆蓋率越高越好?回歸根本,我們寫單測的意義最重要的一點是為了保證代碼的正確性,如果我們把復雜的、重要的、必要的測試覆蓋到,即可保證應用的正確性,例如set、get方法,完全沒有必要寫單測,不必為了追求覆蓋率而刻意寫單測,尺度這個東西,無論何時何事都是要有分寸的。躬身入局,寫起來,會慢慢找到節奏的。
3 思想
測試工具是神兵利器,設計原則是內功心法,設計原則作為編寫代碼的指導思想,單元測試作為驗證代碼好壞的有效途徑,共同推動代碼演進。
6 最后
影響單測落地的原因:
- 團隊無單測習慣,個人是否follow
- 業務壓力大,覺得寫單測耗時
- 覺得可有可無
- 單測是一個程序員的自我修養
【本文為51CTO專欄作者“阿里巴巴官方技術”原創稿件,轉載請聯系原作者】