Spock单元测试框架实战指南二-mock第三方依赖

1,232 阅读8分钟

Spock自带的Mock用法

在上一篇讲单元测试代码可读性和维护性的问题时举了一种业务场景,即接口调用,我们的用户服务需要调用用户中心接口获取用户信息,代码如下:

/**
 * 用户服务
 * @author 公众号:Java老K
 * 个人博客:www.javakk.com
 */
@Service
public class UserService {

    @Autowired
    UserDao userDao;

    @Autowired
    MoneyDAO moneyDAO;

    public UserVO getUserById(int uid){
        List<UserDTO> users = userDao.getUserInfo();
        UserDTO userDTO = users.stream().filter(u -> u.getId() == uid).findFirst().orElse(null);
        UserVO userVO = new UserVO();
        if(null == userDTO){
            return userVO;
        }
        userVO.setId(userDTO.getId());
        userVO.setName(userDTO.getName());
        userVO.setSex(userDTO.getSex());
        userVO.setAge(userDTO.getAge());
        // 显示邮编
        if("上海".equals(userDTO.getProvince())){
            userVO.setAbbreviation("沪");
            userVO.setPostCode(200000);
        }
        if("北京".equals(userDTO.getProvince())){
            userVO.setAbbreviation("京");
            userVO.setPostCode(100000);
        }
        // 手机号处理
        if(null != userDTO.getTelephone() && !"".equals(userDTO.getTelephone())){
            userVO.setTelephone(userDTO.getTelephone().substring(0,3)+"****"+userDTO.getTelephone().substring(7));
        }

        return userVO;
    }
}

其中userDao是使用spring注入的用户中心服务的实例对象,我们只有拿到了用户中心的返回的users,才能继续下面的逻辑(根据uid筛选用户,DTO和VO转换,邮编、手机号处理等)

所以正常的做法是把userDao的getUserInfo()方法mock掉,模拟一个我们指定的值,因为我们真正关心的是拿到users后自己代码的逻辑,这是我们需要重点验证的地方

按照上面的思路使用Spock编写的测试代码如下:

package com.javakk.spock.service

import com.javakk.spock.dao.UserDao

import spock.lang.Specification
import spock.lang.Unroll

/**
 * 用户服务测试类
 * @author 公众号:Java老K
 * 个人博客:www.javakk.com
 */
class UserServiceTest extends Specification {
    def userService = new UserService()
    def userDao = Mock(UserDao)

    void setup() {
        userService.userDao = userDao
    }

    def "GetUserById"() {
        given: "设置请求参数"
        def user1 = new UserDTO(id:1, name:"张三", province: "上海")
        def user2 = new UserDTO(id:2, name:"李四", province: "江苏")

        and: "mock掉接口返回的用户信息"
        userDao.getUserInfo() >> [user1, user2]

        when: "调用获取用户信息方法"
        def response = userService.getUserById(1)

        then: "验证返回结果是否符合预期值"
        with(response) {
            name == "张三"
            abbreviation == "沪"
            postCode == 200000
        }
    }
}

如果要看junit如何实现可以参考上一篇的对比图,这里主要讲解spock的代码:(从上往下)

def userDao = Mock(UserDao) 这一行代码使用spock自带的Mock方法构造一个userDao的mock对象,如果要模拟userDao方法的返回,只需userDao.方法名() >> 模拟值 的方式,两个右箭头的方式即可

setup方法是每个测试用例运行前的初始方法,类似于junit的@before

GetUserById方法是单测的主要方法,可以看到分为4个模块:givenandwhenthen,用来区分不同单测代码的作用:

  • given: 输入条件(前置参数)
  • when: 执行行为(mock接口、真实调用)
  • then: 输出条件(验证结果)
  • and: 衔接上个标签,补充的作用

每个标签后面的双引号里可以添加描述,说明这块代码的作用(非强制),如"when: "调用获取用户信息方法""

因为spock使用groovy作为单测开发语言,所以代码量上比使用java写的会少很多,比如given模块里通过构造函数的方式创建请求对象

given: "设置请求参数"
def user1 = new UserDTO(id:1, name:"张三", province: "上海")
def user2 = new UserDTO(id:2, name:"李四", province: "江苏")

实际上UserDTO.java 这个类并没有3个参数的构造函数,是groovy帮我们实现的,groovy默认会提供一个包含所有对象属性的构造函数

而且调用方式上可以指定属性名,类似于key:value的语法,非常人性化,方便我们在属性多的情况下构造对象,如果使用java写,可能就要调用很多setXXX()方法才能完成对象初始化的工作

and: "mock掉接口返回的用户信息"
userDao.getUserInfo() >> [user1, user2]

这个就是spock的mock用法,即当调用userDao.getUserInfo()方法时返回一个List,list的创建也很简单,中括号"[]"即表示list,groovy会根据方法的返回类型自动匹配是数组还是list,而list里的对象就是之前given块里构造的user对象

其中 ">>" 就是指定返回结果,类似mockito的when().thenReturn()语法,但更简洁一些

如果要指定返回多个值的话可以使用3个右箭头">>>",比如:

userDao.getUserInfo() >>> [[user1,user2],[user3,user4],[user5,user6]]

也可以写成这样:

userDao.getUserInfo() >> [user1,user2] >> [user3,user4] >> [user5,user6]

即每次调用userDao.getUserInfo()方法返回不同的值

如果mock的方法带有入参的话,比如下面的业务代码:

public List<UserDTO> getUserInfo(String uid){
    // 模拟用户中心服务接口调用
    List<UserDTO> users = new ArrayList<>();
    return users;
}

这个getUserInfo(String uid)方法,有个参数uid,这种情况下如果使用spock的mock模拟调用的话,可以使用下划线"_"匹配参数,表示任何类型的参数,多个逗号隔开,类似与mockito的any()方法

如果类中存在多个同名函数,可以通过 "_ as 参数类型" 的方式区别调用,类似下面的语法:

// _ 表示匹配任意类型参数
List<UserDTO> users = userDao.getUserInfo(_);

// 如果有同名的方法,使用as指定参数类型区分
List<UserDTO> users = userDao.getUserInfo(_ as String);

when模块里是真正调用要测试方法的入口:userService.getUserById()

then模块作用是验证被测方法的结果是否正确,符合预期值,所以这个模块里的语句必须是boolean表达式,类似于junit的assert断言机制,但你不必显示的写assert,这也是一种约定优于配置的思想

then块中使用了spock的with功能,可以验证返回结果response对象内部的多个属性是否符合预期值,这个相对于junit的assertNotNullassertEquals的方式更简单一些

Where用法

上面的业务代码有3个if判断,分别是对邮编和手机号的处理逻辑:

// 显示邮编
if("上海".equals(userDTO.getProvince())){
    userVO.setAbbreviation("沪");
    userVO.setPostCode(200000);
}
if("北京".equals(userDTO.getProvince())){
    userVO.setAbbreviation("京");
    userVO.setPostCode(100000);
}
// 手机号处理
if(null != userDTO.getTelephone() && !"".equals(userDTO.getTelephone())){
    userVO.setTelephone(userDTO.getTelephone().substring(0,3)+"****"+userDTO.getTelephone().substring(7));
}

现在的单元测试如果要完全覆盖这3个分支就需要构造不同的请求参数多次调用被测试方法才能走到不同的分支,在上一篇中介绍了spock的where标签可以很方便的实现这种功能,代码如下:

@Unroll
def "当输入的用户id为:#uid 时返回的邮编是:#postCodeResult,处理后的电话号码是:#telephoneResult"() {
    given: "mock掉接口返回的用户信息"
    userDao.getUserInfo() >> users

    when: "调用获取用户信息方法"
    def response = userService.getUserById(uid)

    then: "验证返回结果是否符合预期值"
    with(response) {
        postCode == postCodeResult
        telephone == telephoneResult
    }

    where: "表格方式验证用户信息的分支场景"
    uid | users                         || postCodeResult | telephoneResult
    1   | getUser("上海", "13866667777") || 200000         | "138****7777"
    1   | getUser("北京", "13811112222") || 100000         | "138****2222"
    2   | getUser("南京", "13833334444") || 0              | null
}

def getUser(String province, String telephone){
    return [new UserDTO(id: 1, name: "张三", province: province, telephone: telephone)]
}

where模块第一行代码是表格的列名,多个列使用"|"单竖线隔开,"||"双竖线区分输入和输出变量,即左边是输入值,右边是输出值

格式如下:

输入参数1 | 输入参数2 || 输出结果1 | 输出结果2

而且intellij idea支持format格式化快捷键,因为表格列的长度不一样,手动对齐比较麻烦

表格的每一行代表一个测试用例,即被测方法被测试了3次,每次的输入和输出都不一样,刚好可以覆盖全部分支情况

比如uid、users都是输入条件,其中users对象的构造调用了getUser方法,每次测试业务代码传入不同的user值,postCodeResulttelephoneResult表示对返回的response对象的属性判断是否正确

第一行数据的作用是验证返回的邮编是否是"200000",第二行是验证邮编是否是"100000",第三行的邮编是否是"0"(因为代码里没有对南京的邮编进行处理,所以默认值是0)

这个就是where+with的用法,更符合我们实际测试的场景,既能覆盖多种分支,又可以对复杂对象的属性进行验证

其中在第2行定义的测试方法名是使用了groovy的字面值特性:

@Unroll
def "当输入的用户id为:#uid 时返回的邮编是:#postCodeResult,处理后的电话号码是:#telephoneResult"() {

即把请求参数值和返回结果值的字符串里动态替换掉,"#uid、#postCodeResult、#telephoneResult" 井号后面的变量是在方法内部定义的,前面加上#号,实现占位符的功能

@Unroll注解,可以把每一次调用作为一个单独的测试用例运行,这样运行后的单测结果更直观:

而且其中一行测试结果不对,spock的错误提示信息也很详细,方便排查(比如我们把第2条测试用例返回的邮编改成"100001"):

可以看出第2条测试用例失败,错误信息是postCodeResult的预期结果和实际结果不符,业务代码逻辑返回的邮编是"100000",而我们预期的邮编是"100001",这样你就可以排查是业务代码逻辑有问题还是我们的断言不对。

通过这个例子大家可以看到Spock结合groovy语言在测试多个分支场景时的优势。

(完整的源码在公众号【java老k】里回复spock获取)

文章来源:javakk.com/273.html