阿里妹導讀:單元測試的好處到底有哪些?每次單測啟動應用,太耗時,怎麼辦?二方三方接口可能存在日常沒法用,只能上預發/正式的情況,上預發測低效如何處理?本文分享三個單元測試神器及相關經驗總結。
文末福利:《Linux運維學習路線》技術公開課。
Q1:好代碼應具備可讀性,可測試性,可擴展性等等,那麼如何寫出好代碼?DRY 原則、KISS 原則、YAGNI 原則、LOD 法則設計模式最重要的點還是在於解耦和復用,創建型模式將創建代碼與使用代碼解耦,結構型模式是將功能代碼解耦,行為型模式將行為代碼解耦,最終達到高內聚,鬆耦合的目標,設計模式體現了設計原則。附:我們經常說的「高內聚 鬆耦合」究竟什麼是高內聚,什麼是鬆耦合?單元測試(unit testing),指由開發人員對軟體中的最小可測試單元進行檢查和驗證。對於單元測試中單元的含義,一般來說,要根據實際情況去判定其具體含義,如C語言中單元指一個函數,Java裡單元指一個類,圖形化的軟體中可以指一個窗口或一個菜單等。總的來說,單元就是人為規定的最小的被測功能模塊。單元測試是在軟體開發過程中要進行的最低級別的測試活動,軟體的獨立單元將在與程序的其他部分相隔離的情況下進行測試。CodeReview時作為重點CR的地方
好的單測可作為指導文檔,方便使用者使用及閱讀
改動前:OSS文件夾概念是通過文件名創建的,下面改動前的方法入參是File,該方法可以正常使用,但是在寫單測的時候,我發現使用文件有兩個成本:坑:本地獲取的路徑與在容器獲取的路徑是不一致的,複雜度明顯增高。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(); 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,這樣便可省去創建文件以及編寫獲取獲取文件路徑方法,同時還避免了獲取路徑的坑,一舉兩得,也通過單測找到了代碼設計不合理之處。
private void uploadObject2Oss(OSS client, String bucketName, String dirName, InputStream is, String fileName, long fileSize) throws Exception { 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);}以下這個方法先不說可讀性問題,單從編寫單測來驗證邏輯是否正確,在寫單測時需要:構造sourceInfos列表
構造String數組
構造map對象
構造List
構造User 對象
顯然這個方法是非常複雜的,但是邏輯就是得到一個指定長度列表。
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]表示什麼,在寫單測時需要:經過改造,可測試性、可讀性均有提升,另外在這個例子中其實user對象只使用了aliuid,無需傳入整個對象,遵循KISS原則。
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;}工欲善其事必先利其器,抗拒寫單測的其中最主要的一個原因就是沒有神器在手!每次啟動應用動輒就是幾分鐘起,想要測試一個方法,上個廁所回來可能應用還沒啟動,如此低效,怎麼願意去寫,fast_tester只需要啟動應用一次(tip: 添加註解及測試方法需要重新啟動應用),支持測試代碼熱更新,後續可隨意編寫測試方法,一個字「秀」!<dependency> <groupId>com.alibaba</groupId> <artifactId>fast-tester</artifactId> <version>1.3</version> <scope>test</scope></dependency>(2)在test的package下創建TestApplication@SpringBootApplicationpublic class TestApplication { public static void main(String[] args){ PandoraBootstrap.run(args); ConfigurableApplicationContext context = SpringApplication.run(TestApplication.class, args); FastTester.run(context); }}@Slf4jpublic 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)); bucketRequest.setBucketName("video2sky"); result = bucketService.saveBucketInfo(bucketRequest); log.info("bucketName 重複 result :{}", JSON.toJSONString(result)); 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)); bucketFolderRequest.setBucketId(1L); result = bucketService.createBucketFolder(bucketFolderRequest); log.info("錯誤的bucketId result :{}", JSON.toJSONString(result)); bucketFolderRequest.setWriteTokenExpireTime(7300L); result = bucketService.createBucketFolder(bucketFolderRequest); log.info("異常的讀時間 result :{}", JSON.toJSONString(result)); 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是一個Java語言的單元測試框架, Junit測試是程式設計師測試,即所謂白盒測試,因為程式設計師知道被測試的軟體如何(How)完成功能和完成什麼樣(What)的功能。繼承TestCase類,就可以用Junit進行自動測試。來源:https://baike.baidu.com/item/白盒測試@Slf4jpublic 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異常期望返回結果使用,測試全部測試方法等。@Slf4jpublic 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); }
@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) { 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.htmlMockito是一個針對Java的mocking框架,主要作用mock請求及返回值。Mockito可以隔離類之間的相互依賴,做到真正的方法級別單測。<dependency> <groupId>org.mockito</groupId> <artifactId>mockito-all</artifactId> <version>1.9.5</version> <scope>test</scope></dependency>需要測試的方法中調用了二方/三方接口,而接口無測試環境,為了測試方法邏輯,可以模擬接口返回結果(對原先代碼無侵入),達到應用內測試閉環。tip:mock數據並非真正的返回值,需要注意返回的結果類型,字符串長度等,防止出現轉化,入庫欄位超長等問題。@Overridepublic ConsumeCodeResult consumeCode(String code) { if (code.startsWith(BENEFIT_CENTER_CODE_HEADER) && BENEFIT_CENTER_CODE_LENGTH == code.length()) { return consumeCodeFromCodeBenefitCenter(code); } return consumeCodeFromCodeCenter(code);}
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); }}@Testpublic 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");
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覆蓋率是度量測試完整性的一個手段,是測試有效性的一個度量。是否覆蓋率越高越好?回歸根本,我們寫單測的意義最重要的一點是為了保證代碼的正確性,如果我們把複雜的、重要的、必要的測試覆蓋到,即可保證應用的正確性,例如set、get方法,完全沒有必要寫單測,不必為了追求覆蓋率而刻意寫單測,尺度這個東西,無論何時何事都是要有分寸的。躬身入局,寫起來,會慢慢找到節奏的。測試工具是神兵利器,設計原則是內功心法,設計原則作為編寫代碼的指導思想,單元測試作為驗證代碼好壞的有效途徑,共同推動代碼演進。團隊無單測習慣,個人是否follow
業務壓力大,覺得寫單測耗時
覺得可有可無
單測是一個程式設計師的自我修養
5 大學習階段、20 門免費課程、175 課時教學視頻、12 套自測考試,帶大家掌握使用虛擬機安裝Linux,以及Linux常用命令、文件及用戶管理、文本處理、Vim工具使用等。
點擊「閱讀原文」,去學習吧~