谈起测试
其实测试这个东西在我们项目流程中是必需的一个步骤。
那到底测试是什么?他到底承担了一个怎样的职责?
完整的软件测试工作包括单元测试、集成测试、确认测试和系统测试工作。单元测试工作主要在编码阶段完成,由开发人员和软件测试工程师共同完成,其主要依据是详细测试。
确认测试和系统测试是在软件开发完成后,验证软件的功能与需求的一致性、验证软件在相应的硬件条件下的系统功能是否满足用户需求,其主要依据是用户需求。
测试从下到上大致可以分为单元测试
、端到端测试
和系统集成测试
。
单元测试是最基础的,一般其代码量也是最多的,一般是针对函数、方法和类的测试,但其写好后改动一般是最小的,单元测试是其他测试的基石。
端到端测试是基于单元测试之上的,主要针对API和接口的测试,由于只针对接口进行测试,相对单元测试,端到端测试代码量更少,但面对需求的变更其测试代码也更容易变更。
系统集成测试主要是对整个系统进行测试,针对地是客户端的使用界面。
以现在主流的前后端分离来说明:
单元测试 | 端到端测试 | 系统集成测试 |
---|---|---|
针对视图层中的某个视图方法的测试,或者针对模型层中某个orm的测试 | 模拟接口请求进行测试 | 模拟用户操作进行测试 |
往往开发人员不喜欢测试,主要可能有以下几个原因:
-
反正有测试人员会测试
-
时间紧迫
-
太麻烦了
综合各种外部以及内部因素,造成了以下场景:
在各种跟测试人员沟通中,项目以各种形式的加班后上线,各方在分析项目问题的时候,就抓着项目的质量各种讨伐,到底是谁的锅?
作为一个开发人员,我只能说:其实开发人员真的很累,bug也是改不完的,但是这不是造成项目质量问题的借口。
诚然让开发人员完全是承担测试的保障是不科学的,BUT 你写的代码你需要去负责它的功能,保证它符合需求,不管有没有测试或者其他人在你背后。 这个我觉得是开发人员的自觉性以及本身职责所在。
虽然我上面讲得义正言辞似的,但我也曾是一个不喜欢测试的开发者,我现在也不是一个代码测试覆盖率很高的开发者,我只是一个努力的工具人,努力提高自己代码质量的开发者。
在我学习的过程中,一些前辈跟我说,“不要觉得编写测试代码麻烦浪费时间,在合适的时间点提高项目代码的覆盖率,利用我们最擅长的能力去保障我们的功能正确性以及稳定性。” 接下来我将从如何利用代码去完成我们能做的测试——单元测试以及部分性能测试,提供我们常用的测试代码示例,主要以Java语言为主,希望大家在投入项目开发过程的时候可以以此作为参考,同时达到有效减少测试向我们扔BUG的行为。
开发如何测试
开发自测一般两种方式就是基于UI进行功能测试
以及代码脚本辅助测试
。结合我们常见的MVC模型来说,通过页面进行UI测试,是属于View层的测试,我们常说的单元测试则是针对View层以下的层Controller以及Model的子模块方法,开发人员根据开发的功能模块以及开发阶段,选择不同的方式验证功能的正确性以及稳定性
。
我们大部分开发者无法写得跟测试人员一样优秀的测试用例,但是我们可以使用我们最擅长的工具语言去表达我们验证代码的逻辑。
以下内容将主要基于Java语言,分别从单元测试以及性能测试上面诠释开发如何测试这个题目。
单元测试
认识单元测试
单元测试是用来对一个模块、一个函数或者一个类来进行正确性检验的测试工作。
单元在质量保证中是非常重要的环节,根据测试金字塔原理,越往上层的测试,所需的测试投入比例越大,效果也越差,而单元测试的成本要小的多,也更容易发现问题。
单元测试的意义
来自从头到脚说单测——谈有效的单元测试说到的一个经典的单元测试意义的解释
因此总结起来,单元测试的意义就是:
- 单元测试对我们的产品质量是非常重要的。
- 单元测试是所有测试中最底层的一类测试,是第一个环节,也是最重要的一个环节,是唯一一次有保证能够代码覆盖率达到100%的测试,是整个软件测试过程的基础和前提,单元测试防止了开发的后期因bug过多而失控,单元测试的性价比是最好的。
- 据统计,大约有80%的错误是在软件设计阶段引入的,并且修正一个软件错误所需的费用将随着软件生命期的进展而上升。错误发现的越晚,修复它的费用就越高,而且呈指数增长的趋势。作为编码人员,也是单元测试的主要执行者,是唯一能够做到生产出无缺陷程序这一点的人,其他任何人都无法做到这一点。
- 代码规范、优化,可测试性的代码
- 放心重构的资本
单元测试的实施
在《单元测试的艺术》这本书提到一个案例:找了开发能力相近的两个团队,同时开发相近的需求。进行单测的团队在编码阶段时长增长了一倍,从7天到14天,但是,这个团队在集成测试阶段的表现非常顺畅,bug量小,定位bug迅速等。最终的效果,整体交付时间和缺陷数,均是单测团队最少。
单测,存在即合理。一方面,需要把单测放在整个迭代周期来观测其效果;一方面,写单测也是技术活,写得好的同学,时间少代码质量高(也即,不是说写了单测,就能写好单测)
单元测试的阶段
广义的单元测试,我们指这三部分的有机组合:
因此遵循这个过程,一开始从单元测试用例编写开始,然后一个阶段后进行静态代码扫描,主要是使用sonarqube
,接着针对扫描结果进行code review,分析问题,不断优化代码,提高代码质量。
细化单元测试的编写过程就是:
-
数据准备
在编写测试用例前,需要依赖到一些数据,根据入参准备测试数据。 -
构造参数及打桩(stub)
调用方法需要传递入参。
打桩科普:
mock是一种用于单元测试数据模拟的技术,俗称打桩技术。
mock的好处:
1、并行工作:借助mock,接口之间可以实现解耦合,实现测试驱动开发
2、隔离系统:可以模拟请求,避免数据库的污染,同时可以做界面演示 -
执行测试
这一步比较简单,直接调用被测方法即可。 -
结果验证
这里除了验证被测方法的返回值外,还需要验证插入到数据库中的数据是否正确,某外部方法被调用过n次或未调用过。 -
必要的清理
对打桩进行清理,对数据库脏数据进行清理。
编写单元测试
一个基本的单元测试编写
示例代码
我们先通过一个示例来看如何编写测试。假定我们编写了一个计算叠加的类,它只有一个静态方法来计算叠加:
S=1+2+…+n
示例代码如下:
public class TestDemo {
public static long superimposed(long n) {
long r = 0;
for (long i = 1; i <= n; i++) {
r += i;
}
return r;
}
}
生成测试用例
基于Junit4框架编写我们的测试用例
JUnit 是 Java 编程语言的单元测试框架,用于编写和可重复运行的自动化测试。
使用JUnit编写单元测试的好处在于,我们可以非常简单地组织测试代码,并随时运行它们,JUnit就会给出成功的测试和失败的测试,还可以生成测试报告,不仅包含测试的成功率,还可以统计测试的代码覆盖率,即被测试的代码本身有多少经过了测试。
使用Idea自带的生成测试用例功能,勾选我们需要测试的方法以及测试框架
测试代码如下:
public class TestDemoTest {
@Test
public void superimposed() {
assertEquals(1, TestDemo.superimposed(1));
assertEquals(3, TestDemo.superimposed(2));
assertNotEquals(50, TestDemo.superimposed(10));
}
}
上面有列举了3个测试条件分别是:
- 当入参等于1的时候,输出结果应该等于1
- 当入参等于2的时候,输出结果应该等于3
- 当入参等于10的时候,输出结果应该不等于50
执行测试
Run/Debug执行测试,测试结果Pass即通过
常用的断言方法介绍:
- assertEquals() 如果比较的两个对象是相等的,此方法将正常返回;否则失败显示在 JUnit 的窗口测试将中止。
- assertSame() 和 assertNotSame() 方法测试两个对象引用指向完全相同的对象。
- assertNull() 和 assertNotNull() 方法测试一个变量是否为空或不为空(null)。
- assertTrue() 和 assertFalse() 方法测试 if 条件或变量是 true 还是 false。
- assertArrayEquals() 将比较两个数组,如果它们相等,则该方法将继续进行不会发出错误。否则失败将显示在 JUnit 窗口和中止测试。
SpringBoot项目测试用例编写
示例工程
工程代码结构如下,这是一个基于SpringBoot实现对user对象进行CRUD的基础工程
│ DemoApplication.java
|
└─user
├─controller
│ UserController.java
│
├─dao
│ UserDao.java
│
├─entity
│ User.java
│
└─service
│ UserService.java
│
└─impl
UserServiceImpl.java
User对象主要包含以下两个属性
@Getter
@Setter
public class User implements Serializable {
private static final long serialVersionUID = -99146895849578786L;
private int id;
private String name;
假设我们要对UserService的新增User逻辑进行测试.
新增User逻辑:新增User的时候,假如Name属性值为空的时候,不插入数据到数据库直接返回Null,否则新增成功,并返回user对象。
@Override
public User insert(User user) {
if(StringUtils.isBlank(user.getName())){
return null;
}
this.userDao.insert(user);
return user;
}
基于spring-boot-starter-test编写测试用例
由于是基于SpringBoot的工程,我们可以直接使用spring-boot-starter-test
框架编写测试用例
springboot测试步骤
直接在测试类上面加上如下2个注解
@RunWith(SpringRunner.class)
@SpringBootTest
就能取到spring中的容器的实例,如果配置了@Autowired那么就自动将对象注入。
@SpringBootTest
@RunWith(SpringRunner.class)
public class UserServiceImplTest {
@Autowired
UserService userService;
@Test
public void insert() {
String name = "test2020062101";
User posUser = new User();
posUser.setName(name);
User res = userService.insert(posUser);
Assert.assertNotNull("验证返回结果不为空", res);
posUser.setName(null);
res = userService.insert(posUser);
Assert.assertNull("验证返回结果为空", res);
}
执行测试
Run/Debug测试类,查看测试结果
查看数据新增记录
测试打桩
有个很常见的情形,在开发中有可能你调用的其他服务没有开发完,比如实现类逻辑还没有确定,我们可以通过规定其入参以及对应的返回值来模拟这个bean的逻辑,或者根据某个情形下进行某个路由操作的选择(如果入参是A则结果为B,如果为C则D)。这种模拟也被成为测试打桩。
以上面的SpringBoot工程为例,我们假定UserDao里面的queryByName逻辑没有实现,我们需要去验证UserService的使用name查询User对象逻辑.
UserService.queryByName
@Override
public User queryByName(String name) {
if(StringUtils.isBlank(name)){
return null;
}
return this.userDao.queryByName(name);
}
测试用例
@SpringBootTest
@RunWith(SpringRunner.class)
public class UserServiceImplTest {
@Autowired
UserService userService;
@MockBean
private UserDao userDao;
@Test
public void queryByName() throws Exception {
String name = "test2020062101";
User posUser = new User();
posUser.setId(100);
posUser.setName(name);
// 定义当调用mock userDao的queryByName()方法,并且参数为test2020062101时,
// 就返回id为100、name为test2020062101的user对象
Mockito.when(userDao.queryByName(name)).thenReturn(posUser);
// 返回的会是名字为test2020062101的user对象
User user = userService.queryByName(name);
Assert.assertNotNull(user);
Assert.assertEquals(user.getId(), 100);
Assert.assertEquals(user.getName(), name);
}
}
这里关注userDao的引入,是使用@MockBean
,当 userDao 被加上这个注解之后,表示 Mockito
会帮我们创建一个假的 mock 对象,替换掉 Spring 中已存在的那个真实的 userDao bean,也就是说,注入进 userService 的 userDao bean,已经被我们替换成假的 mock 对象了,所以当我们再次调用 userService 的方法时,会去调用的实际上是 mock userDao bean 的方法, 而不是真实的 userDao bean。
当我们创建了一个假的 userDao 后,我们需要为这个 mock userDao 自定义方法的返回值,这里有一个公式用法,下面这段代码的意思为,当调用了某个 mock 对象的方法时,就回传我们想要的自定义结果
Mockito.when( 对象.方法名() ).thenReturn( 自定义结果 )
Mockio基本使用
1.thenReturn 系列方法
thenReurn
:主要根据假定条件返回预设的结果
当调用mock userDao的queryByName()方法,并且参数为test2020062101时就返回id为100、name为test2020062101的user对象
Mockito.when(userDao.queryByName("test2020062101"))
.thenReturn(new User(100,"test2020062101"));
当调用mock userDao的queryByName()方法,并且任意String类型参数都返回id为100、name为test2020062101的user对象
Mockito.when(userDao.queryByName(Mockito.anyString()))
.thenReturn(new User(100,"test2020062101"));
2.thenThrow 系列方法
thenThrow
:主要根据假定条件抛出预设的异常
当调用mock userDao的queryByName()方法,并且参数为886时就抛RuntimeException异常
Mockito.when(userService.queryByName("886"))
.thenThrow(new RuntimeException("mock throw exception"));
user = userService.queryByName("886"); //会抛出一个RuntimeException
当没有参数的时候,即是方法定义为public void myMethod() {...})
,要改用````doThrow() 抛出 Exception
假如有个public void print(){}
Mockito.doThrow(new RuntimeException("mock throw exception")).when(userService).print();
3.verify 系列方法
verify
: 用于验证调用次数,我们可以在测试方法代码的末尾使用Mockito验证方法,以确保调用了指定的方法。
检查调用 userService 的queryByName、且参数为"886"的次数是否为1次
Mockito.verify(userService, Mockito.times(1)).queryByName(Mockito.eq(""886"")) ;
上述就是 Mockito 的 mock 对象使用方法,不过当使用 Mockito 在 mock 对象时,有一些限制需要遵守
- 不能 mock 静态方法
- 不能 mock private 方法
- 不能 mock final class
因此在写代码时,需要做良好的功能拆分,才能够使用 Mockito 的 mock 技术,帮助我们降低测试时 bean 的耦合度。
结语
还有一个章节是性能测试——如何使用框架测试代码性能
,由于篇幅问题将放在下一章。
就单元测试这块,其实基于断言以及apring-boot-test框架以及为我们去编写单元测试用例提供了极大的便利,因为不要再说“太麻烦了”。上面着重介绍了一下Mockio,其实大家可以尝试在写代码时,从 mock 测试的角度来写,更能够写出功能切分良好的代码架构。
还有一点要提的是,所有的测试工具框架都是辅助效率作用,核心还是在开发者的开发逻辑以及验证功能逻辑上面,这方面需要我们不断积累开发经验,汲取相关知识,希望大家一起共同努力,成为一个更优秀的开发者。
参考资料
JUnit4教程&实践
从头到脚说单测——谈有效的单元测试
有赞单元测试实践
SpringBoot 单元测试利器——Mockito