单元测试
测试类型
处于象限底部的是面向技术的测试,即那些首先能够帮助开发人员构建系统的测试。这个象限里的测试大都是可以自动化的,例如性能测试和小范围的单元测试。相对而言,处于象限顶部的测试则是帮助非技术背景的相关人群,了解系统是如何工作的。这种测试包括象限左上角的大范围、端到端的验收测试,还有象限右上角的由用户代表在UAT系统上进行手工验证的探索性测试。
测试范围
1.单元测试
单元测试通常只测试一个函数和方法调用,通过TDD(Test-Driver Design,测试驱动开发)写的测试就属于这一类。在单元测试中,我们不会启动服务,并且对外部文件和网络连接的使用也很有限。通常情况下需要大量的单元测试,如果做的合理,他们运行起来会非常快。单元测试是面向开发人员的,是面向技术而非业务的,我们希望通过他们来捕获大部分的缺陷。单元测试对于代码重构非常重要,因为不小心犯了错误,这些小范围的测试能很快做出提醒,这样就可以放心的随时调整代码。
2.服务测试
服务测试是绕开用户界面、直接针对服务的测试。在独立应用程序中,服务测试可能只测试为用户界面提供服务的一些类。对于包含多个服务的系统,一个服务测试只测试其中一个单独服务的功能。服务测试比简单的单元测试覆盖的范围更大,因此当运行失败时,也比单元测试更难定位问题。
3.端到端测试
端到端测试会覆盖整个系统,这类测试通常需要打开一个浏览器来操作图形用户界面,这种类型的测试会覆盖大范围的产品代码。
单元测试优缺点
单元测试(又称为模块测试, Unit Testing)是针对程序模块(软件设计的最小单位)来进行正确性检验的测试工作。程序单元是应用的最小可测试部件。在过程化编程中,一个单元就是单个程序、函数、过程等;对于面向对象编程,最小单元就是方法,包括基类(超类)、抽象类、或者派生类(子类)中的方法。
1.“合格的”单元测试需要满足几个条件
- 测试的是一个代码单元内部的逻辑,而不是各模块之间的交互。
- 无依赖,不需要实际运行环境就可以测试代码。
- 运行效率高,可以随时执行。
2.常见的单元测试典型场景
- 开发前写单元测试,通过测试描述需求,由测试驱动开发。
- 在开发过程中及时得到反馈,提前发现问题。
- 应用于自动化构建或持续集成流程,对每次代码修改做回归测试。
- 作为重构的基础,验证重构是否可靠。
3.单元测试难以去除依赖
- 要写一个纯粹的、无依赖的单元测试往往很困难,比如依赖了数据库、依赖了文件系统、依赖了其它模块、代码之间的依赖。
- 利用java的JMockit、EasyMock,或者Mockito,这类框架可以相对比较轻松的通过mock方式去做假设和验证,但遇到复杂的业务代码往往也无能为力。
- 写单元测试的难易程度跟代码的质量关系最大,并且是决定性的。项目里无论用了哪个测试框架都不能解决代码本身难以测试的问题,所以如果你遇到的是“我的代码里依赖的东西太多了所以写不出来单测”这样的问题的话,需要去看的是如何设计和重构代码。
Spock
Spock框架是基于Groovy语言的测试框架,Groovy与Java具备良好的互操作性,因此可以在Spring Boot项目中使用该框架写优雅、高效以及DSL化的测试用例。因为基于Groovy, 使得Spock 可以更容易地写出表达能力更强的测试用例。又因为它内置了Junit Runner, 所以Spock兼容大部分的IDE,测试工具,和持续集成服务器。
1.Spock 特性
- 内置支持 mocking stubbing,可以很容易地模拟复杂的类的行为
- Spock 实现了 BDD 范式(behavior-driven development)
- 与现有的 Build 工具集成,可以用来测试后端代码,Web 页面等等
- 兼容性强,内置 Junit Runner, 可以像运行 Junit 那样运行 Spock,甚至可以在同一个项目里面同时使用两种测试框架
- 取长补短,吸收了现有框架的优点,并加以改进
- Spock 代码风格简短,易读,表达性强,扩展性强,还有更清晰显示bug
2.Spock 参考
3.Spock 标签
单元测试包括:准备测试数据、执行待测试方法、判断执行结果三个步骤。Spock通过given
、expect
、when
和then
等标签将这些步骤放在一个测试用例中。
given
: 应该包含所有的初始化条件或者初始化类,例如你可以把要测试的类的实例化放在 given. 总而言之, given 就是放置所有单元测试开始前的准备工作的地方setup
: 跟 given 很相似,所以初始化的时候可以二选一,推荐使用givenwhen
: 是 Spock 测试中最重要的一部分,这里放置的就是你要测试的代码,和你如何测试的用例,这里的测试代码应该尽可能地短。有经验的 Spock 用户可以直接看 when 就了解测试流程了。then
: 包含隐式的断言, Spock 是没有 assert 这个断言函数的, Spock 使用的是 assertion, 这是一种隐式的断言。概括来说, then 就是放置你预期测试结果的地方。and
: 它的用法有点像语法糖,它自己本身是没有什么功能,它只是拿来扩展其他的功能的,使用 and 可以使代码结构更简洁优雅。expect
:是一个很强大的特性,它有很多种用法,最常用的用法就是把 given-when-then 都结合起来where
:Spock 的输入输出参数都保存在类似表格的数据结构,其实这是 Spock 的 Parameterized tests,而在 || 符号左边的是输入,右边的是输出,每一列开始都是该参数的属性名。cleanup
:就相当于在所有的测试结束以后执行的操作,例如,如果在测试中新建了 IO 流, 就可以在 clean 里面关闭 IO 流,那样就可以保证代码的正确性了。
4.Spock 注解
@Title
:描述@Unroll
:使得where的多值测试可以拆分成多个单独的单元测试@Transactional
:开启事务@Rollback
:回滚操作,可以避免更新数据库@Timeout
:设置超时时间- 其它…
Spock 单元测试规范
1.pom中引入Spock相关依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.spockframework</groupId>
<artifactId>spock-core</artifactId>
<scope>test</scope></dependency>
<dependency>
<groupId>org.spockframework</groupId>
<artifactId>spock-spring</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>cglib</groupId>
<artifactId>cglib-nodep</artifactId>
<version>3.1</version>
<scope>test</scope>
</dependency>
2.自动生成Spock测试类
IDEA
在IDEA中,先选择某个待测试类,然后选择 Navigate>Test,然后勾选需要单测的方法点击OK,会自动生成到test目录下。
Eclipse
点击 Help -> Eclipse Marketplace
搜索并安装Groovy Development Tools
和Spock Plugin
需要注意的是Spock版本对Groovy编译器版本是有要求的,如下表
Spock version | Groovy version | JUnit version | Grails version | Spring version |
---|---|---|---|---|
0.5-groovy-1.6 | 1.6.1-1.6.x | 4.7-4.x | 1.2.0-1.2.x | 2.5.0-3.x |
0.5-groovy-1.7 | 1.7.0-1.7.x | 4.7-4.x | 1.3.0-1.3.x | 2.5.0-3.x |
0.6-groovy-1.7 | 1.7.0-1.7.x | 4.7-4.x | 1.3.0-1.3.x | 2.5.0-3.x |
0.6-groovy-1.8 | 1.8.1-1.8.x | 4.7-4.x | 2.0-2.x | 2.5.0-3.x |
0.7-groovy-1.8 | 1.8.1-1.8.x | 4.7-4.x | 2.0-2.x | 2.5.0-3.x |
0.7-groovy-2.0 | 2.0.0 -2.x.x | 4.7-4.x | 2.2-2.x | 2.5.0-3.x |
1.0-groovy-2.0 | 2.0.0 -2.2.x | 4.7-4.x | 2.2-2.x | 2.5.0-4.x |
1.0-groovy-2.3 | 2.3.0 -2.3.x | 4.7-4.x | 2.2-2.x | 2.5.0-4.x |
1.0-groovy-2.4 | 2.4.0 -2.x.x | 4.7-4.x | 2.2-2.x | 2.5.0-4.x |
HZERO项目中目前的版本信息为:
<groovy.version>2.4.10</groovy.version>
<spock-core.version>1.0-groovy-2.4</spock-core.version>
<spring-core.version>4.3.8.RELEASE</spring-core.version>
所以在Project Properties的Groovy编译器版本要选择为相应的版本(HZERO项目中目前为2.4)
如果项目没有添加过测试用例,需要先在任意包下 右键 -> New -> Other -> Groovy Class,然后添加任意一个Groovy类来初始化项目的Groovy环境,当项目Build Path中出现了Groovy DSL Support之后说明Groovy运行环境已配置完成。然后再在项目上右键 -> Jspresso-> Add Spock Nature 来添加Spock运行环境。
后续添加spock测试用例的时候就可以直接在待测试类上右键 -> New -> Other -> Groovy Test Case 来创建测试用例。需要注意测试用例默认创建在main目录下,需要手工调整到test目录。
按照下文的要求创建好测试用例之后,可以通过右键 Run As / Debug As -> JUnit Test 来正常执行测试。
3.支持依赖测试
如果不需要autowire其它依赖,自动生成的代码结构已经足够完成单元测试了。如果需要注入依赖,则需要相应注解支持。在测试类上加上下面两个注解(Application为启动类):
@SpringBootTest(classes = Application.class)
@ContextConfiguration(loader = SpringBootContextLoader.class)
class HiamTenantServiceSpec extends Specification {
}
4.DEMO
given
初始化数据,在when
中写你要测试的代码,在then
中写你期待的结果,where
中写测试的数据集。注意每个区块写上对应的流程说明,要达到整个单测流程能说明你的业务流程的效果。
@SpringBootTest(classes = Application.class)
@ContextConfiguration(loader = SpringBootContextLoader.class)
@Title("Unit test for hzero iam user service")
@Unroll
@Transactional
@Rollback
@Timeout(10)
class HiamUserServiceSpec extends Specification {
@Autowired
UserRepository userRepository;
@Autowired
HiamUserRepository hiamUserRepository;
@Autowired
UserCaptchaService userCaptchaService;
@Autowired
RedisHelper redisHelper;
@Autowired
HiamUserService hiamUserService;
/**
* 修改手机号流程测试
*/
def "test modify phone #loginName"() {
given: "获取用户信息"
UserLogin.login(userRepository, loginName);
UserVO self = hiamUserRepository.selectSelfDetail();
String captchaKey, preCheckResultKey;
CommonException e1;
IllegalArgumentException e2;
when: "手机格式不正确"
captchaKey = userCaptchaService.sendPhoneCaptcha(phone1, true);
then:
e1 = thrown();
e1.getMessage() == "validation.phone.incorrect"
when: "给原手机发送验证码"
captchaKey = userCaptchaService.sendPhoneCaptcha(self.getPhone(), true);
then: "发送成功,返回验证码key"
captchaKey != null
when: "验证原手机验证码"
preCheckResultKey = userCaptchaService.validatePreCheckCaptcha(captchaKey, getCaptcha(captchaKey));
then:
preCheckResultKey != null
when: "给新手机发送验证码"
captchaKey = userCaptchaService.sendPhoneCaptcha(new_phone, false)
then:
captchaKey != null;
when: "验证新手机并修改手机号"
boolean result = hiamUserService.validateNewPhoneCaptchaAndUpdate(preCheckResultKey, captchaKey, getCaptcha(captchaKey), new_phone);
then:
result
where:
loginName | phone1 | phone2 | captchaKey1 | new_phone
"admin" | "1852322" | "18523225327" | "wrong-key" | "18888888888"
"hand" | "1852322" | "18523225327" | "wrong-key" | "16666666666"
}
private String getCaptcha(String captchaKey) {
String group = redisHelper.strGet(Constants.CacheKey.VALIDATE_CAPTCHA + ":" + captchaKey);
String[] groupArr = StringUtils.split(group, "_", 2);
return groupArr[0];
}
}
5.单测登录
单元测试中模拟用户登录,可以通过DetailsHelper拿到用户ID、组织ID等
package org.hzero.iam.infra.util;
import java.util.Collections;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import io.choerodon.core.oauth.CustomUserDetails;
import org.hzero.iam.domain.entity.User;
import org.hzero.iam.domain.repository.UserRepository;
public class UserLogin {
/**
* 模拟登录,使得可以通过DetailsHelper获取用户ID、租户ID等信息
*
* @param userRepository 用户资源库
* @param loginName 登录名
*/
public static void login(UserRepository userRepository, String loginName) {
CustomUserDetails details = new CustomUserDetails(loginName, "", Collections.emptyList());
User user = userRepository.selectByLoginName(loginName);
details.setUserId(user.getId());
details.setLanguage(user.getLanguage());
details.setTimeZone(user.getTimeZone());
details.setEmail(user.getEmail());
details.setOrganizationId(user.getOrganizationId());
AbstractAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(details, "", Collections.emptyList());
SecurityContextHolder.getContext().setAuthentication(authentication);
}
/**
* 模拟登录
*
* @param loginName 登录名
* @param userId 用户ID
* @param organizationId 租户ID
*/
public static void login(String loginName, Long userId, Long organizationId) {
CustomUserDetails details = new CustomUserDetails(loginName, "", Collections.emptyList());
details.setUserId(userId);
details.setLanguage("zh_CN");
details.setTimeZone("CTT");
details.setEmail("hand@hand-china.com");
details.setOrganizationId(organizationId);
AbstractAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(details, "", Collections.emptyList());
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}