阿里妹导读:单元测试的好处到底有哪些?每次单测启动应用,太耗时,怎么办?二方三方接口可能存在日常没法用,只能上预发/正式的情况,上预发测低效如何处理?本文分享三个单元测试神器及相关经验总结。
网站建设哪家好,找成都创新互联!专注于网页设计、网站建设、微信开发、微信小程序定制开发、集团企业网站建设等服务项目。为回馈新老客户创新互联还提供了招远免费建站欢迎大家使用!
一 首先什么是好代码?
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,该方法可以正常使用,但是在写单测的时候,我发现使用文件有两个成本:
坑:本地获取的路径与在容器获取的路径是不一致的,复杂度明显增高。
- /**
- * 向阿里云的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 还想再举一个
以下这个方法先不说可读性问题,单从编写单测来验证逻辑是否正确,在写单测时需要:
显然这个方法是非常复杂的,但是逻辑就是得到一个指定长度列表。
- /**
- * 按比例混排结果 (改动前)
- * @param sourceInfos 渠道配比信息
- * @param resultMap 结果
- * @param pageSize 总条数
- * @param aliuid 用户id
- * @return 结果集
- */
- private List
getResultList(List sourceInfos, Map > resultMap, int pageSize, User user) { - Map
sourceNumMap = new HashMap<>(sourceInfos.size()); - sourceInfos.stream().forEach(s -> sourceNumMap.put(s[0], Integer.parseInt(s[1]) * pageSize / 100));
- List
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原则。
- /**
- * 按比例混排结果
- * @param sourceInfos 渠道配比信息
- * @param pageSize 条数
- * @param aliuid 用户id
- * @return 结果集
- */
- private List
getResultList(List sourceInfos, int pageSize, String aliuid) { - // 获取结果集
- List
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包
com.alibaba fast-tester 1.3 test
(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
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
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包
org.mockito mockito-all 1.9.5 test
(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 覆盖率
覆盖率是度量测试完整性的一个手段,是测试有效性的一个度量。
覆盖率准则
场景总结
具体还需自己判断,但是要避免过度自信。
覆盖率要求
是否覆盖率越高越好?回归根本,我们写单测的意义最重要的一点是为了保证代码的正确性,如果我们把复杂的、重要的、必要的测试覆盖到,即可保证应用的正确性,例如set、get方法,完全没有必要写单测,不必为了追求覆盖率而刻意写单测,尺度这个东西,无论何时何事都是要有分寸的。躬身入局,写起来,会慢慢找到节奏的。
3 思想
测试工具是神兵利器,设计原则是内功心法,设计原则作为编写代码的指导思想,单元测试作为验证代码好坏的有效途径,共同推动代码演进。
6 最后
影响单测落地的原因:
【本文为专栏作者“阿里巴巴官方技术”原创稿件,转载请联系原作者】
戳这里,看该作者更多好文
本文题目:如何写好单元测试?
网页网址:http://www.shufengxianlan.com/qtweb/news34/131184.html
网站建设、网络推广公司-创新互联,是专注品牌与效果的网站制作,网络营销seo公司;服务项目有等
声明:本网站发布的内容(图片、视频和文字)以用户投稿、用户转载内容为主,如果涉及侵权请尽快告知,我们将会在第一时间删除。文章观点不代表本网站立场,如需处理请联系客服。电话:028-86922220;邮箱:631063699@qq.com。内容未经允许不得转载,或转载时需注明来源: 创新互联