單元測試的好處到底有哪些?每次單測啟動應用,太耗時,怎么辦?二方三方接口可能存在日常沒法用,只能上預發/正式的情況,上預發測低效如何處理?本文分享三個單元測試神器及相關經驗總結。
一 首先什么是好代碼?
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
- 業務壓力大,覺得寫單測耗時
- 覺得可有可無
- 單測是一個程序員的自我修養