13518219792

建站动态

根据您的个性需求进行定制 先人一步 抢占小程序红利时代

如何写好单元测试?

阿里妹导读:单元测试的好处到底有哪些?每次单测启动应用,太耗时,怎么办?二方三方接口可能存在日常没法用,只能上预发/正式的情况,上预发测低效如何处理?本文分享三个单元测试神器及相关经验总结。

网站建设哪家好,找成都创新互联!专注于网页设计、网站建设、微信开发、微信小程序定制开发、集团企业网站建设等服务项目。为回馈新老客户创新互联还提供了招远免费建站欢迎大家使用!

一 首先什么是好代码?

Q1:好代码应具备可读性,可测试性,可扩展性等等,那么如何写出好代码?

A:设计思想 & 编码规范。

二 设计思想&设计原则&设计模式

1 设计原则(S.O.L.I.D)

SRP 单一职责原则

OCP 开闭原则

LSP 里式替换原则

ISP 接口隔离原则

DIP 依赖倒置原则

DRY 原则、KISS 原则、YAGNI 原则、LOD 法则

设计模式

设计模式最重要的点还是在于解耦和复用,创建型模式将创建代码与使用代码解耦,结构型模式是将功能代码解耦,行为型模式将行为代码解耦,最终达到高内聚,松耦合的目标,设计模式体现了设计原则。

附:我们经常说的“高内聚 松耦合”究竟什么是高内聚,什么是松耦合?

Q2: 那么如何验证代码是好代码呢?

A: CR & 单测(下面进入正题^_^)

三 什么是单测?

单元测试(unit testing),指由开发人员对软件中的最小可测试单元进行检查和验证。对于单元测试中单元的含义,一般来说,要根据实际情况去判定其具体含义,如C语言中单元指一个函数,Java里单元指一个类,图形化的软件中可以指一个窗口或一个菜单等。总的来说,单元就是人为规定的最小的被测功能模块。单元测试是在软件开发过程中要进行的最低级别的测试活动,软件的独立单元将在与程序的其他部分相隔离的情况下进行测试。

来源:https://baike.baidu.com/item/单元测试

四 为什么要写单测?

1 异(che)常(huo)场(xian)景(chang)

相信大家肯定遇到过以下几种情况:

要想故障出的少,还得单测好好搞。

2 优点

提高代码正确性

发现设计问题

提升代码可读性

顺便微重构

提升开发人员自信心

启动速度,提升效率

不用重复启动Pandora容器,浪费大量时间在容器启动上,方便逻辑验证。

场景保存(多场景)

CodeReview时作为重点CR的地方

好的单测可作为指导文档,方便使用者使用及阅读

3 举个小例子

改动前:OSS文件夹概念是通过文件名创建的,下面改动前的方法入参是File,该方法可以正常使用,但是在写单测的时候,我发现使用文件有两个成本:

坑:本地获取的路径与在容器获取的路径是不一致的,复杂度明显增高。

 
 
 
 
  1. /** 
  2.  * 向阿里云的OSS存储中存储文件 (改动前) 
  3.  * 
  4.  * @param client OSS客户端 
  5.  * @param file   上传文件 
  6.  * @return String 唯一MD5数字签名 
  7.  */ 
  8.  private static void uploadObject2Oss(OSS client, File file, String bucketName, String dirName) throws Exception { 
  9.      InputStream is = new FileInputStream(file); 
  10.      String fileName = file.getName(); 
  11.      Long fileSize = file.length(); 
  12.      //创建上传Object的Metadata 
  13.      ObjectMetadata metadata = new ObjectMetadata(); 
  14.      metadata.setContentLength(is.available()); 
  15.      metadata.setCacheControl("no-cache"); 
  16.      metadata.setHeader("Pragma", "no-cache"); 
  17.      metadata.setContentEncoding("utf-8"); 
  18.      metadata.setContentType(getContentType(fileName)); 
  19.      metadata.setContentDisposition("filename/filesize=" + fileName + "/" + fileSize + "Byte."); 
  20.      //上传文件 
  21.      client.putObject(bucketName, dirName + PublicConstant.DIAGONAL_CHARACTER + fileName, is, metadata); 

改动后:将入参file修改为inputStream,这样便可省去创建文件以及编写获取获取文件路径方法,同时还避免了获取路径的坑,一举两得,也通过单测找到了代码设计不合理之处。

 
 
 
 
  1. /** 
  2.  * 向阿里云的OSS存储中存储文件(改动后) 
  3.  * 
  4.  * @param client   OSS 上传client 
  5.  * @param bucketName bucketName 
  6.  * @param dirName  目录 
  7.  * @param is       输入流 
  8.  * @param fileName 文件名 
  9.  * @param fileSize 文件大小 
  10.  * @throws Exception 
  11.  */ 
  12.  private void uploadObject2Oss(OSS client, String bucketName, String dirName, InputStream is, String fileName, 
  13.     long fileSize) throws Exception { 
  14.     //创建上传Object的Metadata 
  15.     ObjectMetadata metadata = new ObjectMetadata(); 
  16.     metadata.setContentLength(is.available()); 
  17.     metadata.setCacheControl("no-cache"); 
  18.     metadata.setHeader("Pragma", "no-cache"); 
  19.     metadata.setContentEncoding("utf-8"); 
  20.     metadata.setContentType(getContentType(fileName)); 
  21.     metadata.setContentDisposition("filename/filesize=" + fileName + "/" + fileSize + "Byte."); 
  22.     //上传文件 
  23.     client.putObject(bucketName, dirName + PublicConstant.DIAGONAL_CHARACTER + fileName, is, metadata); 

4 还想再举一个

以下这个方法先不说可读性问题,单从编写单测来验证逻辑是否正确,在写单测时需要:

显然这个方法是非常复杂的,但是逻辑就是得到一个指定长度列表。

 
 
 
 
  1. /** 
  2.  * 按比例混排结果 (改动前) 
  3.  * @param sourceInfos 渠道配比信息 
  4.  * @param resultMap 结果 
  5.  * @param pageSize 总条数 
  6.  * @param aliuid 用户id 
  7.  * @return 结果集 
  8.  */ 
  9. private List getResultList(List sourceInfos, Map> resultMap, int pageSize, User user) { 
  10.     Map sourceNumMap = new HashMap<>(sourceInfos.size()); 
  11.     sourceInfos.stream().forEach(s -> sourceNumMap.put(s[0], Integer.parseInt(s[1]) * pageSize / 100)); 
  12.     List resultList = new ArrayList<>(); 
  13.     resultMap.forEach((s, strings) -> resultList.addAll(strings.stream().limit(sourceNumMap.get(s)).collect( 
  14.         Collectors.toList()))); 
  15.     // 弥补条数,防止数据量不足 
  16.     if (resultList.size() < pageSize) { 
  17.         compensate(resultList, pageSize, user.getAliuid()); 
  18.     } 
  19.     return resultList; 

改动后:将入参改为List sourceInfos, int pageSize, String aliuid,将String[]改为SourceInfo,提升代码可读性,否则无从得知s[0]表示什么,s[1]表示什么,在写单测时需要:

经过改造,可测试性、可读性均有提升,另外在这个例子中其实user对象只使用了aliuid,无需传入整个对象,遵循KISS原则。

 
 
 
 
  1. /** 
  2.  * 按比例混排结果 
  3.  * @param sourceInfos 渠道配比信息 
  4.  * @param pageSize 条数 
  5.  * @param aliuid 用户id 
  6.  * @return 结果集 
  7.  */ 
  8. private List getResultList(List sourceInfos, int pageSize, String aliuid) { 
  9.     // 获取结果集 
  10.     List resultList = sourceInfos.stream() 
  11.         .flatMap(sourceInfo -> { 
  12.             int needNum = (int)(sourceInfo.getSourceRatio() * pageSize / 100); 
  13.             return listSource(sourceInfo.getSourceChannel(), needNum, aliuid).stream(); 
  14.         }).collect(Collectors.toList()); 
  15.     // 补偿数据 
  16.     compensate(resultList, pageSize, aliuid()); 
  17.     return resultList; 

五 如何写好单测?

1 工具

工欲善其事必先利其器,抗拒写单测的其中最主要的一个原因就是没有神器在手!

Fast-tester

每次启动应用动辄就是几分钟起,想要测试一个方法,上个厕所回来可能应用还没启动,如此低效,怎么愿意去写,fast_tester只需要启动应用一次(tip: 添加注解及测试方法需要重新启动应用),支持测试代码热更新,后续可随意编写测试方法,一个字“秀”!

使用方式:

(1)需要引入jar包

 
 
 
 
  1.  
  2.     com.alibaba 
  3.     fast-tester 
  4.     1.3 
  5.     test 
  6.  

(2)在test的package下创建TestApplication

 
 
 
 
  1. /** 
  2.  * @author QZJ 
  3.  * @date 2020-08-03 
  4.  */ 
  5. @SpringBootApplication 
  6. public class TestApplication { 
  7.     public static void main(String[] args){ 
  8.         PandoraBootstrap.run(args); 
  9.         ConfigurableApplicationContext context = SpringApplication.run(TestApplication.class, args); 
  10.         // 将ApplicationContext传给FastTester 
  11.         FastTester.run(context); 
  12.     } 

(3)编写需要依赖pandora容器的case

 
 
 
 
  1. /** 
  2.  * tip:添加注解及方法需要重新启动应用 
  3.  * 
  4.  * @author QZJ 
  5.  * @date 2020-08-03 
  6.  */ 
  7. @Slf4j 
  8. public class BucketServiceTest { 
  9.  
  10.     @Autowired 
  11.     BucketService bucketService; 
  12.  
  13.     @Test 
  14.     public void testSaveBucketInfo() { 
  15.         BucketRequest bucketRequest = new BucketRequest(); 
  16.          
  17.         // 缺少参数 
  18.         bucketRequest.setAccessKeyId("123"); 
  19.         bucketRequest.setAccessKeySecret("123"); 
  20.         bucketRequest.setBucketDomain("123"); 
  21.         bucketRequest.setEndpoint("123"); 
  22.         bucketRequest.setRegionId("123"); 
  23.         bucketRequest.setRoleArn("123"); 
  24.         bucketRequest.setRoleSessionName("123"); 
  25.         Result result = bucketService.saveBucketInfo(bucketRequest); 
  26.         log.info("缺少参数 result :{}", JSON.toJSONString(result)); 
  27.          
  28.         // bucketName 重复 
  29.         bucketRequest.setBucketName("video2sky"); 
  30.         result = bucketService.saveBucketInfo(bucketRequest); 
  31.         log.info("bucketName 重复 result :{}", JSON.toJSONString(result)); 
  32.          
  33.         // 正例(执行后,则bucketName已存在,需更换bucketName) 
  34.         bucketRequest.setBucketName("12345"); 
  35.         result = bucketService.saveBucketInfo(bucketRequest); 
  36.         log.info("正例 result :{}", JSON.toJSONString(result)); 
  37.     } 
  38.      
  39.     @Test 
  40.     public void testCreateBucketFolder() { 
  41.         BucketFolderRequest bucketFolderRequest = new BucketFolderRequest(); 
  42.         bucketFolderRequest.setFolderPath("/test"); 
  43.         bucketFolderRequest.setAppName("wudao"); 
  44.         bucketFolderRequest.setDescription("data"); 
  45.         bucketFolderRequest.setWriteTokenExpireTime(3600L); 
  46.         Result result = bucketService.createBucketFolder(bucketFolderRequest); 
  47.         log.info("缺少参数 result :{}", JSON.toJSONString(result)); 
  48.          
  49.         // 错误的bucketId 
  50.         bucketFolderRequest.setBucketId(1L); 
  51.         result = bucketService.createBucketFolder(bucketFolderRequest); 
  52.         log.info("错误的bucketId result :{}", JSON.toJSONString(result)); 
  53.          
  54.         // 异常的读时间,读写时间不得超过2小时 
  55.         bucketFolderRequest.setWriteTokenExpireTime(7300L); 
  56.         result = bucketService.createBucketFolder(bucketFolderRequest); 
  57.         log.info("异常的读时间 result :{}", JSON.toJSONString(result)); 
  58.          
  59.         // 重复的bucketFolder 
  60.         bucketFolderRequest.setBucketId(11L); 
  61.         bucketFolderRequest.setWriteTokenExpireTime(3500L); 
  62.         result = bucketService.createBucketFolder(bucketFolderRequest); 
  63.         log.info("重复的bucketFolder result :{}", JSON.toJSONString(result)); 
  64.          
  65.         // 正例 (本地与服务器默认文件地址不一致,所以本地无法执行成功,除非改地址,或者添加分支代码) 
  66.         bucketFolderRequest.setFolderPath("/test2"); 
  67.         result = bucketService.createBucketFolder(bucketFolderRequest); 
  68.         log.info("正例 result :{}", JSON.toJSONString(result)); 
  69.     } 

(4)启动TestApplication,输入对应类名,选择要执行的相应方法即可(切换测试类,直接重新输入类路径(包名+文件名)即可,原理还是反射)。

Tip:如果service注解失败,检查测试包的层级,例如:

Junit

JUnit是一个Java语言的单元测试框架, Junit测试是程序员测试,即所谓白盒测试,因为程序员知道被测试的软件如何(How)完成功能和完成什么样(What)的功能。继承TestCase类,就可以用Junit进行自动测试。

来源:https://baike.baidu.com/item/白盒测试

使用方式:

(1)私有方法测试

 
 
 
 
  1. /** 
  2.  * 普通类测试,无需启动容器 
  3.  * 
  4.  * @author QZJ 
  5.  * @date 2020-08-05 
  6.  */ 
  7. @Slf4j 
  8. public class OssServiceTest { 
  9.      
  10.     private OssServiceImpl ossService = new OssServiceImpl(); 
  11.  
  12.     @Test 
  13.     public void testCreateOssFolder() { 
  14.         try { 
  15.             // 私有方法测试:方法一:用反射(推荐);方法二:修改类中方法属性(不推荐) 
  16.             Method method = OssServiceImpl.class.getDeclaredMethod("createOssFolder", 
  17.                 new Class[] {OSS.class, String.class, String.class}); 
  18.             method.setAccessible(true); 
  19.             OSS client = new OSSClientBuilder().build("oss-cn-beijing.aliyuncs.com", "**", 
  20.                 "****"); 
  21.             Object obj = method.invoke(ossService, new Object[] {client, "***", "wudao/test3"}); 
  22.             Assert.assertEquals(true, obj); 
  23.         } catch (Exception e) { 
  24.             Assert.fail("testCreateOssFolder fail"); 
  25.         } 
  26.     } 

(2)相关测试注解如@Ignore使用,相关属性如timeout测试接口性能、expected异常期望返回结果使用,测试全部测试方法等。

 
 
 
 
  1. /** 
  2.  * 普通工具类测试 
  3.  * @author QZJ 
  4.  * @date 2020-08-05 
  5.  */ 
  6. @Slf4j 
  7. public class DateUtilTest { 
  8.  
  9.     @Ignore // 忽略该方法执行结果 
  10.     @Test 
  11.     public void testGetCurrentTime(){ 
  12.         String dateStr = DateUtil.getCurrentTime("yyyy-MM-dd HH:mm"); 
  13.         log.info("date:{}", dateStr); 
  14.         Assert.assertEquals("2020-08-05 17:22", dateStr); 
  15.     } 
  16.  
  17.     // 方法超时时间设置以及期望执行抛出的异常类型设置(错误的日期格式解析异常) 
  18.     @Test(timeout = 110L, expected = ParseException.class) 
  19.     public void testString2Date() throws ParseException{ 
  20.         Date date = DateUtil.string2Date("20202-02 02:02"); 
  21.         log.info("date:{}" , date); 
  22.         //Thread.sleep(200L); 
  23.     } 
  24.  
  25.     @BeforeClass 
  26.     public static void beforeClass() { 
  27.         log.info("before class"); 
  28.     } 
  29.  
  30.     @AfterClass 
  31.     public static void  afterClass() { 
  32.         log.info("after class"); 
  33.     } 
  34.  
  35.     @Before 
  36.     public void before() { 
  37.         log.info("before"); 
  38.     } 
  39.  
  40.     @After 
  41.     public void after() { 
  42.         log.info("after"); 
  43.     } 
  44.  
  45.     public static void main(String[] args) { 
  46.         // 不需启动容器的情况下使用,跑类中所有case 
  47.         Result result = JUnitCore.runClasses(DateUtilTest.class); 
  48.         result.getFailures().stream().forEach(f -> System.out.println(f.toString())); 
  49.         log.info("result:{}", result.wasSuccessful()); 
  50.     } 

详细使用文档见:https://wiki.jikexueyuan.com/project/junit/environment-setup.html

Mockito

Mockito是一个针对Java的mocking框架,主要作用mock请求及返回值。

Mockito可以隔离类之间的相互依赖,做到真正的方法级别单测。

使用方式:

(1)需要引入jar包

 
 
 
 
  1.  
  2.    org.mockito 
  3.    mockito-all 
  4.    1.9.5 
  5.    test 
  6.  

(2)编写测试代码(例子)

需要测试的方法中调用了二方/三方接口,而接口无测试环境,为了测试方法逻辑,可以模拟接口返回结果(对原先代码无侵入),达到应用内测试闭环。

tip:mock数据并非真正的返回值,需要注意返回的结果类型,字符串长度等,防止出现转化,入库字段超长等问题。

 
 
 
 
  1. @Override 
  2. public ConsumeCodeResult consumeCode(String code) { 
  3.     // 权益核销 
  4.     if (code.startsWith(BENEFIT_CENTER_CODE_HEADER) && BENEFIT_CENTER_CODE_LENGTH == code.length()) { 
  5.         return consumeCodeFromCodeBenefitCenter(code); 
  6.     } 
  7.     // 码商核销 
  8.     return consumeCodeFromCodeCenter(code); 
  9.  
  10. /** 
  11.  * 从权益中心核销电子凭证 
  12.  * 
  13.  * @param code 电子码 
  14.  * @return 核销结果 
  15.  */ 
  16. private ConsumeCodeResult consumeCodeFromCodeBenefitCenter(String code) { 
  17.     // 参数构造 
  18.     BenefitUseDTO benefitUseDTO = new BenefitUseDTO(); 
  19.     benefitUseDTO.setCouponCode(code); 
  20.     benefitUseDTO.getExtendFields().put("configId", benefitId); 
  21.     benefitUseDTO.getExtendFields().put("type", BenefitTypeEnum.CODE_GENERAL.getType().toString()); 
  22.     AlispResult alispResult = benefitService.useBenefit(benefitUseDTO); 
  23.     log.info("benefitUseDTO:{}, result:{}", benefitUseDTO, alispResult); 
  24.     if (alispResult.isSuccess()) { 
  25.         BenefitUseResult benefitUseResult = (BenefitUseResult)alispResult.getValue(); 
  26.         return new ConsumeCodeResult(benefitUseResult.getOutOrderId(), 
  27.             String.valueOf(benefitUseResult.getConfigId()), benefitUseResult.getUseTime()); 
  28.     } 
  29.     // 已使用 
  30.     if (BizErrorCodeEnum.BENEFIT_RECORD_USED.name().equals(alispResult.getErrCodeName())) { 
  31.         throw new BizException(StudentErrorEnum.VERIFICATION_CODE_REPEAT); 
  32.     } else if (BizErrorCodeEnum.BENEFIT_RECORD_NOT_EXIST.name().equals(alispResult.getErrCodeName()) 
  33.         || BizErrorCodeEnum.BENEFIT_RECORD_EXPIRED.name().equals(alispResult.getErrCodeName())) { 
  34.         // 不存在或者过期 
  35.         throw new BizException(StudentErrorEnum.VERIFICATION_CODE_INVALID); 
  36.     } else { 
  37.         // 其他异常 
  38.         throw new BizException(StudentErrorEnum.VERIFICATION_CODE_CONSUME_FAILED); 
  39.     } 

 
 
 
 
  1. @Test 
  2. public void mockConsume(){ 
  3.     BenefitService benefitService = Mockito.mock(BenefitService.class); 
  4.     // 核销成功链路 
  5.     AlispResult alispResult = new AlispResult(true); 
  6.     BenefitUseResult benefitUseResult = new BenefitUseResult(); 
  7.     benefitUseResult.setConfigId(1L); 
  8.     benefitUseResult.setOutOrderId("lalala"); 
  9.     benefitUseResult.setUseTime(new Date()); 
  10.     alispResult.setValue(benefitUseResult); 
  11.  
  12.     Mockito.when(benefitService.useBenefit(Mockito.any(BenefitUseDTO.class))).thenReturn(alispResult); 
  13.     ConsumeCodeService consumeCodeService = new ConsumeCodeServiceImpl(benefitService, "1"); 
  14.     ConsumeCodeResult consumeCodeResult = consumeCodeService.consumeCode("082712345678"); 
  15.     System.out.println(JSON.toJSONString(consumeCodeResult)); 
  16.  
  17.     alispResult = new AlispResult(false); 
  18.     // 已核销链路 
  19.     alispResult.setErrCodeName("BENEFIT_RECORD_USED"); 
  20.     // 已过期链路 
  21.     //alispResult.setErrCodeName("BENEFIT_RECORD_EXPIRED"); 
  22.     // 码不存在链路 
  23.     //alispResult.setErrCodeName("BENEFIT_RECORD_NOT_EXIST"); 
  24.     // 其他返回错误 
  25.     //alispResult.setErrCodeName("LALALA"); 
  26.  
  27.     Mockito.when(benefitService.useBenefit(Mockito.any(BenefitUseDTO.class))).thenReturn(alispResult); 
  28.     consumeCodeService = new ConsumeCodeServiceImpl(benefitService, "1"); 
  29.     try { 
  30.         consumeCodeService.consumeCode("082712345678"); 
  31.     } catch (Exception e) { 
  32.         e.printStackTrace(); 
  33.     } 
  34.  
  35.     // 核销码头有误 
  36.     consumeCodeService = new ConsumeCodeServiceImpl(benefitService, "1"); 
  37.     try { 
  38.         consumeCodeService.consumeCode("081712345678"); 
  39.     } catch (Exception e) { 
  40.         e.printStackTrace(); 
  41.     } 
  42.     // 核销码长度有误 
  43.     consumeCodeService = new ConsumeCodeServiceImpl(benefitService, "1"); 
  44.     try { 
  45.         consumeCodeService.consumeCode("08271234567"); 
  46.     } catch (Exception e) { 
  47.         e.printStackTrace(); 
  48.     } 

Mockito的功能非常多,可以验证行为,做测试桩,匹配参数,验证调用次数和执行顺序等等,在这不一一枚举了,更多详细使用可见文档:https://github.com/hehonghui/mockito-doc-zh

2 覆盖率

覆盖率是度量测试完整性的一个手段,是测试有效性的一个度量。

覆盖率准则

场景总结

具体还需自己判断,但是要避免过度自信。

覆盖率要求

是否覆盖率越高越好?回归根本,我们写单测的意义最重要的一点是为了保证代码的正确性,如果我们把复杂的、重要的、必要的测试覆盖到,即可保证应用的正确性,例如set、get方法,完全没有必要写单测,不必为了追求覆盖率而刻意写单测,尺度这个东西,无论何时何事都是要有分寸的。躬身入局,写起来,会慢慢找到节奏的。

3 思想

测试工具是神兵利器,设计原则是内功心法,设计原则作为编写代码的指导思想,单元测试作为验证代码好坏的有效途径,共同推动代码演进。

6 最后

影响单测落地的原因:

【本文为专栏作者“阿里巴巴官方技术”原创稿件,转载请联系原作者】

戳这里,看该作者更多好文


当前文章:如何写好单元测试?
地址分享:http://cdbrznjsb.com/article/dphpgsh.html

其他资讯

让你的专属顾问为你服务