• 单元测试

    测试类型

    处于象限底部的是面向技术的测试,即那些首先能够帮助开发人员构建系统的测试。这个象限里的测试大都是可以自动化的,例如性能测试和小范围的单元测试。相对而言,处于象限顶部的测试则是帮助非技术背景的相关人群,了解系统是如何工作的。这种测试包括象限左上角的大范围、端到端的验收测试,还有象限右上角的由用户代表在UAT系统上进行手工验证的探索性测试。

    测试象限

    测试范围

    1.单元测试

    单元测试通常只测试一个函数和方法调用,通过TDD(Test-Driver Design,测试驱动开发)写的测试就属于这一类。在单元测试中,我们不会启动服务,并且对外部文件和网络连接的使用也很有限。通常情况下需要大量的单元测试,如果做的合理,他们运行起来会非常快。单元测试是面向开发人员的,是面向技术而非业务的,我们希望通过他们来捕获大部分的缺陷。单元测试对于代码重构非常重要,因为不小心犯了错误,这些小范围的测试能很快做出提醒,这样就可以放心的随时调整代码。

    2.服务测试

    服务测试是绕开用户界面、直接针对服务的测试。在独立应用程序中,服务测试可能只测试为用户界面提供服务的一些类。对于包含多个服务的系统,一个服务测试只测试其中一个单独服务的功能。服务测试比简单的单元测试覆盖的范围更大,因此当运行失败时,也比单元测试更难定位问题。

    3.端到端测试

    端到端测试会覆盖整个系统,这类测试通常需要打开一个浏览器来操作图形用户界面,这种类型的测试会覆盖大范围的产品代码。

    测试范围

    单元测试优缺点

    单元测试(又称为模块测试, Unit Testing)是针对程序模块(软件设计的最小单位)来进行正确性检验的测试工作。程序单元是应用的最小可测试部件。在过程化编程中,一个单元就是单个程序、函数、过程等;对于面向对象编程,最小单元就是方法,包括基类(超类)、抽象类、或者派生类(子类)中的方法。

    1.“合格的”单元测试需要满足几个条件

    2.常见的单元测试典型场景

    3.单元测试难以去除依赖

    Spock

    Spock框架是基于Groovy语言的测试框架,Groovy与Java具备良好的互操作性,因此可以在Spring Boot项目中使用该框架写优雅、高效以及DSL化的测试用例。因为基于Groovy, 使得Spock 可以更容易地写出表达能力更强的测试用例。又因为它内置了Junit Runner, 所以Spock兼容大部分的IDE,测试工具,和持续集成服务器。

    1.Spock 特性

    2.Spock 参考

    3.Spock 标签

    单元测试包括:准备测试数据、执行待测试方法、判断执行结果三个步骤。Spock通过givenexpectwhenthen等标签将这些步骤放在一个测试用例中。

    4.Spock 注解

    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-spock-enhancements

    在IDEA中,先选择某个待测试类,然后选择 Navigate>Test,然后勾选需要单测的方法点击OK,会自动生成到test目录下。

    1531399466.jpg
    1531399572.jpg
    1531400148.jpg

    Eclipse

    点击 Help -> Eclipse Marketplace
    搜索并安装Groovy Development ToolsSpock 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);
        }
    
    }