【软件测试】从零开始写单元测试

一、关于单元测试的三个问题

作为一个程序员,或多或少听说过单元测试,但很多小伙伴还没有在实际项目中用到。究其原因,可能是对单元测试有一些「误解」,比如:

  • 写单元测试需要花费更多的时间,我每天写产品代码都要加班,哪来时间写测试;
  • 写单元测试收益不大,还不是一样有bug;
  • 写单元测试有负担,改产品代码的结构,还得去改测试代码。

先尝试解答这几个问题。

写单元测试会花费更多的时间,这点描述其实不准确。准确地说,写单元测试需要花费更多「写代码的时间」,这点没什么可说的,毕竟要多写一些测试代码。但一个程序员,做一个需求的时候,花在纯写代码的时间其实不多。你得理以前代码的逻辑,设计类和方法,然后才是写代码,写完了再手动测试,可能有bug还要去debug,再修复,再测试。「真正写代码的时间,其实是很少的」。使用单元测试虽然可能占用了更多写代码的时间,但它可以帮你缩短其它时间,「会让你做这个需求花费的总时间更少」。

写单元测试会有bug吗?当然可能有了,我们是无法做到真正的bug free的,但是单元测试写好了,可以显著减小bug的数量。因为写单元测试发现bug的成本是非常低的,它可以在开发阶段就发现bug,而且可以测试很多边界的条件。但如果你「对需求和业务的认知本来都是有误」的,这是单元测试解决不了的,自然会产生bug。话说回来,写单元测试的收益远不止发现bug这么简单,它还具有「代码文档」的功能,以及「重构的安全网」存在。甚至它还可以帮你「理需求」,「设计代码」。

改产品代码需要维护对应的测试代码,这确实是带来了额外的成本。不过借助编辑器的「重构功能」,可以比较方便地批量修改需要修改的地方,其实代价没有想象中的那么大。而且重构后,再跑一遍单元测试,看哪些挂掉了,可以double-check你改的产品代码有没有问题。如果我们把单元测试当成是「产品代码的文档」来看,大概就更能够接受这个维护的成本了。

二、为什么需要单元测试

前面提到,单元测试有很多功能。个人觉得单元测试最大的作用是“代码文档”和“重构安全网”。毕竟软件开发的漫长过程中,总少不了修修改改。如果没有足够的测试,改一段代码就像是在排地雷,改完后心里也总是打鼓,上线前需要先默默拜个神,生怕触发了什么bug。

但如果有足够的测试(不只是单元测试),改完代码后可以跑一遍测试,看哪些挂掉了,是不是自己的改动导致的,该怎么修好测试。这样心里就有底气多了。

要知道,代码写出来是给人看的。而测试比产品代码更友好,因为它简单,直白,站在使用者的视角来描述,所以如果想要了解一段产品代码具有什么功能,看它的单元测试会更直观,更舒服。

很多团队会做测试,但绝大多数测试的工作是在开发后,由专门的测试同学去负责端到端的测试或者API测试。其实端到端的测试成本是非常大的,尤其是对于某些边界条件,构造数据和场景是非常麻烦的。而且一旦发现了bug,再去沟通,修改,提交,部署,需要花费很多时间。

而单元测试最大的优势就是“成本低”,想要测试产品代码的每个分支都比较容易,而且单元测试一般是开发同学自己写,可以用最小的时间发现bug,用最低的成本修改bug。

三、什么是单元测试

1.测试金字塔

并不是所有测试都是单元测试,测试其实分成很多种。业界比较广泛传播的“测试金字塔”描述了它们的区别和关系:
在这里插入图片描述
从测试金字塔模型来看,越在底层的测试,覆盖面应该更广,成本更低。单元测试处于测试金字塔的最低端,是整个测试金字塔的基础。

当然了,测试金字塔并不一定只有三层,中间可能会有其它的测试,比如“契约测试”等。

2. 单元测试的特点

单元测试就像它的名字一样,“单元”(Unit),足够小,足够快,无依赖。单元测试只测你想测的那部分产品代码的逻辑,一个单元测试应该只测一个简单的业务逻辑。一般来说,运行一个单元测试是很快的,基本上在几毫秒到几十毫秒之间。如果有依赖的类,可以mock其他类,消除外部依赖。

3. 什么不是单元测试?

很多同学容易将其他测试与单元测试搞混,最常见的是会启动Spring上下文的集成测试。比如使用@SpringBootTest注解可以启动Spring上下文,这可以测试依赖是否正常注入等Spring的功能,但运行一次需要耗费很多时间(因为要启动Spring上下文),也并不是真正的“单元测试”,因为它依赖了Spring框架。

四、 如何写单元测试

那具体如何写单元测试呢?我们业界有一个叫做「TDD」(测试驱动开发)的方法论。TDD的核心在于“驱动”二字,它的理念是从测试视角出发,通过测试驱动出来产品代码。而在测试金字塔中,单元测试与开发人员最息息相关,所以这里的“测试”一般是指的单元测试。

TDD大概分这几个步骤:

理清需求设计类和方法的出参和入参写测试代码驱动出产品代码重构,循环3-5步。

首先要理清楚需求,因为只有理清楚了需求,才能保证我们使用TDD驱动出来的代码是跟业务期望的一致的。然后第二步是设计类和方法的过程,也称为Task List。这一步可以设计好类与类之间的关系,方法的出参和入参。其实不使用TDD也会有前面这两个步骤,只不过使用TDD的话,可以帮助你更好地从业务视角出发,先把该设计的东西都设计好,避免直接上手写代码,写到一半的时候觉得不对,再去改。

3-5步其实是一个循环的过程。因为刚开始写代码可能并没有太注意代码的格式、风格、性能,一气呵成写得比较快,让测试通过。等测试通过后,可以回过头来重构一下之前写的代码,重构后再跑一遍所有的单元测试,看是否有挂掉的单元测试,以此来检测重构是否对期望的输入输出有影响。

五、 单元测试的结构

一个完整的单元测试,应该分为4个部分:

  1. 声明和参数
  2. 准备入参和mock
  3. 调用产品代码
  4. 验证,也叫断言

拿Java来说,单元测试框架有几个,最流行的应该是JUnit和TestNG。笔者使用JUnit多一点,JUnit使用@Test注解在方法上来声明一个测试。JUnit最新版本是JUnit 5,JUnit 5相较于上一个版本,在参数化测试方面做了很多改进,这样我们就不用写很多个高度相似的测试方法了。

一般来说,方法名需要尽可能可读,它可能比较长,但能够清晰地表述这个测试的意图

@Test
void shouldReturn5WhenCalculateSumGiven2And3() {}

@Test
void should_return_5_when_calculate_sum_given_2_and_3() {}

具体使用驼峰命名法还是下划线,根据自己团队的规范来就好,尽量所有测试风格保持一致。(个人更喜欢下划线~)

入参一般是基本类型或者POJO对象,有些参数可以抽成变量,后面在验证阶段可能用得上。

如果产品代码有外部依赖,就需要用mock来消除外部依赖。常见的Mock框架有EasyMock、「Mockito」等,大家可以对比一下各个mock框架的区别,选择一个合适的。

很多同学刚开始写单元测试的时候不能理解为什么需要mock,觉得mock比较麻烦,甚至有点多此一举的感觉。其实不然,mock的意义在于,你「可以保证你的测试只测试了你要测的那部分代码」。这样如果测试不通过,你就可以知道一定是要测的那个方法有问题,不可能是外部依赖的问题,这样才能做到真正的“单元”化,才能保证每个测试足够小,足够纯粹。
准备好入参和mock后,会显式地调用一下要测的那个方法,这个一般只有简单的一行。

最后是验证,验证分为好几种,最常用的是验证出参是符合自己期望的。也有时候会验证异常等边界情况。JUnit等测试框架基本上自己带了验证的功能,但API都比较简单,个人感觉不是特别好用,推荐使用「AssertJ」,功能强大,API用起来也比较舒服。

举个例子吧:

@Test
void shouldReturnUserWithOrgInfoWhenLoginWithUserId() {    
	String userId = "userId";    
	String orgId = "orgId";    
	User user = UserFactory.getUser(userId);    
	Org org = OrgFactory.getOrg(orgId);   
	given(orgService.getOrgById(orgId)).willReturn(org);     
    UserInfo userInfo = userService.login(userId);        
    assertEquals(org, userInfo.getOrg());
  }

六、单元测试常见问题

下面聊一聊单元测试常见的一些问题。

1. 先写测试还是先写产品代码?

都可以。虽然有一种说法是TDD推荐的是先写测试,再写实现。但很多刚开始写单元测试的同学并不习惯这种方式。先写测试有一个好处,可以让你在设计代码的时候从业务视角去思考,而不是代码实现视角。大家可以尝试先写测试再写实现,体会一下这种感觉。

2. 写单元测试需要花费大量额外的时间?

这个其实在文章开篇已经讨论过了。写单元测试确实会花费更多的“写代码”的时间,但是总的来说,它可以缩短整个需求开发周期的时间。所以写单元测试完全是一笔“划算的生意”。

3. 什么代码最需要单元测试?

不自信的代码,逻辑复杂的代码,重要的代码。比如工具类、三层架构的Service层、DDD的聚合根和领域服务等,这些都应该写足够的单元测试。

4. 入参对象构造太麻烦?

构造一个合适的入参对象比较麻烦,尤其是有些对象有非常多的参数,如果每个测试都要去从头构造的话,会让测试代码变得非常臃肿,可读性变差。这个时候可以使用工厂类来批量生产对象。这个工厂类放在测试目录下,并不会对生产代码造成影响。前面的例子里面,UserFactory就是一个User对象的工厂类。

5. 返回值为void测什么?

返回值为void,说明方法没有出参,那方法内部必然有一些行为,它可能是「改变了内部属性的值」,也可能是「调用了某个外部类的方法」。
如果是改变内部的某个值,那可以通过对象的get参数来断言。这在使用DDD后的领域模型是一个问题,因为有可能本来产品代码不需要暴露出get方法的,但由于测试需要,暴露出了内部属性的get方法。虽然使用反射也可以拿到内部属性的值,但没有太大必要。权衡利弊,还是暴露领域模型的get方法好一点。
如果是调用某个外部的方法,可以用verify来验证是否调用了某个方法,可以用capture验证调用其它方法的入参。这样也可以验证产品代码是否如自己预期的设计在工作。

6. static方法如何mock?

static方法不好mock,需要用特殊的mock框架。比如PowerMock、JMockit。一般来说,Utils类的方法很多是static的,我们用得很多的时间类LocalDateTime,获取当前时间,也是static的。这个时候需要用专门的mock框架来mock一下。

7. 多线程如何测试

多线程也不好测试。如果程序简单,可以用「睡眠」或者CountDownLatch等多线程工具类来辅助测试,等所有线程跑完,再统一验证。

如果程序相对复杂,需要使用专门的多线程测试框架,比如tempus-fugit、Thread Weaver、MultithreadedTC、以及OpenJDK的jcstress项目等。

关于具体的框架如何使用,以后有时间可以写一篇常用的注解的介绍。其实官方文档里面都有写,大家照着官网写几个例子就会了。比较推荐的基础套餐是junit 5 + mockito + assertj。关于static方法和多线程测试框架,大家有需要的时候再去了解也行。

作者:编了个程
链接:https://juejin.im/post/6856754311867826190
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值