当前位置:首页 > 系统运维

如何写好单元测试?

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

一 首先什么是元测好代码?

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 业务压力大,觉得写单测耗时 觉得可有可无 单测是一个程序员的自我修养

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

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

分享到:

滇ICP备2023006006号-16