单元测试方法论(上)

前言

清代杰出思想家章学诚有一句名言:“学必求其心得,业必贵其专精。”

意思是:学习上一定要追求心得体会,事业上一定要贵以专注精深。做技术就是这样,一件事如果做到了极致,就必然会有所心得体会。

上一篇文章《Java单元测试》除了介绍单元测试基础知识外,主要介绍了“为什么要编写单元测试”。很多同学读完后,还是不能快速地编写单元测试用例。而这篇文章,立足于“如何来编写单元测试用例”,能够让同学们“有章可循”,能快速地编写出单元测试用例。

1.编写单元测试用例

1.1.测试框架简介

Mockito是一个单元测试模拟框架,可以让你写出优雅、简洁的单元测试代码。Mockito采用了模拟技术,模拟了一些在应用中依赖的复杂对象,从而把测试对象和依赖对象隔离开来。

PowerMock是一个单元测试模拟框架,是在其它单元测试模拟框架的基础上做出扩展。 通过提供定制的类加载器以及一些字节码篡改技术的应用,PowerMock实现了对静态方法、构造方法、私有方法以及final方法的模拟支持等强大的功能。但是,正因为PowerMock进行了字节码篡改,导致部分单元测试用例并不被JaCoco统计覆盖率。

通过作者多年单元测试的编写经验,优先推荐使用Mockito提供的功能;只有在Mockito提供的功能不能满足需求时,才会采用PowerMock提供的功能;但是,不推荐使用影响JaCoco统计覆盖率的PowerMock功能。在本文中,我们也不会对影响JaCoco统计覆盖率的PowerMock功能进行介绍。

下面,将以Mockito为主、以PowerMock为辅,介绍一下如何编写单元测试用例。

1.2.测试框架引入

为了引入Mockito和PowerMock包,需要在maven项目的pom.xml文件中加入以下包依赖:

<dependency>
    <groupId>org.powermock</groupId>
    <artifactId>powermock-core</artifactId>
    <version>${powermock.version}</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.powermock</groupId>
    <artifactId>powermock-api-mockito2</artifactId>
    <version>${powermock.version}</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.powermock</groupId>
    <artifactId>powermock-module-junit4</artifactId>
    <version>${powermock.version}</version>
    <scope>test</scope>
</dependency>

其中,powermock.version为2.0.9,为当前的最新版本,可根据实际情况修改。在PowerMock包中,已经包含了对应的Mockito和JUnit包,所以无需单独引入Mockito和JUnit包。

1.3.典型代码案例

一个典型的服务代码案例如下:

/**
 * 用户服务类
 */
@Service
public class UserService {
    /** 服务相关 */
    /** 用户DAO */
    @Autowired
    private UserDAO userDAO;
    /** 标识生成器 */
    @Autowired
    private IdGenerator idGenerator;
​
    /** 参数相关 */
    /** 可以修改 */
    @Value("${userService.canModify}")
    private Boolean canModify;
​
    /**
     * 创建用户
     * 
     * @param userCreate 用户创建
     * @return 用户标识
     */
    public Long createUser(UserVO userCreate) {
        // 获取用户标识
        Long userId = userDAO.getIdByName(userCreate.getName());
​
        // 根据存在处理
        // 根据存在处理: 不存在则创建
        if (Objects.isNull(userId)) {
            userId = idGenerator.next();
            UserDO create = new UserDO();
            create.setId(userId);
            create.setName(userCreate.getName());
            userDAO.create(create);
        }
        // 根据存在处理: 已存在可修改
        else if (Boolean.TRUE.equals(canModify)) {
            UserDO modify = new UserDO();
            modify.setId(userId);
            modify.setName(userCreate.getName());
            userDAO.modify(modify);
        }
        // 根据存在处理: 已存在禁修改
        else {
            throw new UnsupportedOperationException("不支持修改");
        }
​
        // 返回用户标识
        return userId;
    }
}

1.4.测试用例编写

采用Mockito和PowerMock单元测试模拟框架,编写的单元测试用例如下:

UserServiceTest.java:

/**
 * 用户服务测试类
 */
@RunWith(PowerMockRunner.class)
public class UserServiceTest {
    /** 模拟依赖对象 */
    /** 用户DAO */
    @Mock
    private UserDAO userDAO;
    /** 标识生成器 */
    @Mock
    private IdGenerator idGenerator;
​
    /** 定义被测对象 */
    /** 用户服务 */
    @InjectMocks
    private UserService userService;
​
    /**
     * 在测试之前
     */
    @Before
    public void beforeTest() {
        // 注入依赖对象
        Whitebox.setInternalState(userService, "canModify", Boolean.TRUE);
    }
​
    /**
     * 测试: 创建用户-新
     */
    @Test
    public void testCreateUserWithNew() {
        // 模拟依赖方法
        // 模拟依赖方法: userDAO.getByName
        Mockito.doReturn(null).when(userDAO).getIdByName(Mockito.anyString());
        // 模拟依赖方法: idGenerator.next
        Long userId = 1L;
        Mockito.doReturn(userId).when(idGenerator).next();
​
        // 调用被测方法
        String text = ResourceHelper.getResourceAsString(getClass(), "userCreateVO.json");
        UserVO userCreate = JSON.parseObject(text, UserVO.class);
        Assert.assertEquals("用户标识不一致", userId, userService.createUser(userCreate));
​
        // 验证依赖方法
        // 验证依赖方法: userDAO.getByName
        Mockito.verify(userDAO).getIdByName(userCreate.getName());
        // 验证依赖方法: idGenerator.next
        Mockito.verify(idGenerator).next();
        // 验证依赖方法: userDAO.create
        ArgumentCaptor<UserDO> userCreateCaptor = ArgumentCaptor.forClass(UserDO.class);
        Mockito.verify(userDAO).create(userCreateCaptor.capture());
        text = ResourceHelper.getResourceAsString(getClass(), "userCreateDO.json");
        Assert.assertEquals("用户创建不一致", text, JSON.toJSONString(userCreateCaptor.getValue()));
​
        // 验证依赖对象
        Mockito.verifyNoMoreInteractions(idGenerator, userDAO);
    }
​
    /**
     * 测试: 创建用户-旧
     */
    @Test
    public void testCreateUserWithOld() {
        // 模拟依赖方法
        // 模拟依赖方法: userDAO.getByName
        Long userId = 1L;
        Mockito.doReturn(userId).when(userDAO).getIdByName(Mockito.anyString());
​
        // 调用被测方法
        String text = ResourceHelper.getResourceAsString(getClass(), "userCreateVO.json");
        UserVO userCreate = JSON.parseObject(text, UserVO.class);
        Assert.assertEquals("用户标识不一致", userId, userService.createUser(userCreate));
​
        // 验证依赖方法
        // 验证依赖方法: userDAO.getByName
        Mockito.verify(userDAO).getIdByName(userCreate.getName());
        // 验证依赖方法: userDAO.modify
        ArgumentCaptor<UserDO> userModifyCaptor = ArgumentCaptor.forClass(UserDO.class);
        Mockito.verify(userDAO).modify(userModifyCaptor.capture());
        text = ResourceHelper.getResourceAsString(getClass(), "userModifyDO.json");
        Assert.assertEquals("用户修改不一致", text, JSON.toJSONString(userModifyCaptor.getValue()));
​
        // 验证依赖对象
        Mockito.verifyNoInteractions(idGenerator);
        Mockito.verifyNoMoreInteractions(userDAO);
    }
​
    /**
     * 测试: 创建用户-异常
     */
    @Test
    public void testCreateUserWithException() {
        // 注入依赖对象
        Whitebox.setInternalState(userService, "canModify", Boolean.FALSE);
​
        // 模拟依赖方法
        // 模拟依赖方法: userDAO.getByName
        Long userId = 1L;
        Mockito.doReturn(userId).when(userDAO).getIdByName(Mockito.anyString());
​
        // 调用被测方法
        String text = ResourceHelper.getResourceAsString(getClass(), "userCreateVO.json");
        UserVO userCreate = JSON.parseObject(text, UserVO.class);
        UnsupportedOperationException exception = Assert.assertThrows("返回异常不一致",
            UnsupportedOperationException.class, () -> userService.createUser(userCreate));
        Assert.assertEquals("异常消息不一致", "不支持修改", exception.getMessage());
    }
}

userCreateVO.json:

{"name":"test"}

userCreateDO.json:

{"id":1,"name":"test"}

userModifyDO.json:

{"id":1,"name":"test"}

通过执行以上测试用例,可以看到对源代码进行了100%的行覆盖。

2.测试用例编写流程

通过上一章编写Java类单元测试用例的实践,可以总结出以下Java类单元测试用例的编写流程:

上面一共有3个测试用例,这里只以测试用例testCreateUserWithNew(创建用户-新)为例说明。

2.1.定义对象阶段

第1步是定义对象阶段,主要包括定义被测对象、模拟依赖对象(类成员)、注入依赖对象(类成员)3大部分。

2.1.1.定义被测对象

在编写单元测试时,首先需要定义被测对象,或直接初始化、或通过Spy包装……其实,就是把被测试服务类进行实例化。

/** 定义被测对象 */
/** 用户服务 */
@InjectMocks
private UserService userService;
2.1.2.模拟依赖对象(类成员)

在一个服务类中,我们定义了一些类成员对象——服务(Service)、数据访问对象(DAO)、参数(Value)等。在Spring框架中,这些类成员对象通过@Autowired、@Value等方式注入,它们可能涉及复杂的环境配置、依赖第三方接口服务……但是,在单元测试中,为了解除对这些类成员对象的依赖,我们需要对这些类成员对象进行模拟。

/** 模拟依赖对象 */
/** 用户DAO */
@Mock
private UserDAO userDAO;
/** 标识生成器 */
@Mock
private IdGenerator idGenerator;
2.1.3.注入依赖对象(类成员)

当模拟完这些类成员对象后,我们需要把这些类成员对象注入到被测试类的实例中。以便在调用被测试方法时,可能使用这些类成员对象,而不至于抛出空指针异常。

/** 定义被测对象 */
/** 用户服务 */
@InjectMocks
private UserService userService;
​
/**
 * 在测试之前
 */
@Before
public void beforeTest() {
    // 注入依赖对象
    Whitebox.setInternalState(userService, "canModify", Boolean.TRUE);
}

2.2.模拟方法阶段

第2步是模拟方法阶段,主要包括模拟依赖对象(参数或返回值)、模拟依赖方法2大部分。

2.2.1.模拟依赖对象(参数或返回值)

通常,在调用一个方法时,需要先指定方法的参数,然后获取到方法的返回值。所以,在模拟方法之前,需要先模拟该方法的参数和返回值。

Long userId = 1L;
2.2.2.模拟依赖方法

在模拟完依赖的参数和返回值后,就可以利用Mockito和PowerMock的功能,进行依赖方法的模拟。如果依赖对象还有方法调用,还需要模拟这些依赖对象的方法。

// 模拟依赖方法
// 模拟依赖方法: userDAO.getByName
Mockito.doReturn(null).when(userDAO).getIdByName(Mockito.anyString());
// 模拟依赖方法: idGenerator.next
Mockito.doReturn(userId).when(idGenerator).next();

2.3.调用方法阶段

第3步是调用方法阶段,主要包括模拟依赖对象(参数)、调用被测方法、验证参数对象(返回值)3步。

2.3.1.模拟依赖对象(参数)

在调用被测方法之前,需要模拟被测方法的参数。如果这些参数还有方法调用,还需要模拟这些参数的方法。

String text = ResourceHelper.getResourceAsString(getClass(), "userCreateVO.json");
UserVO userCreate = JSON.parseObject(text, UserVO.class);
2.3.2.调用被测方法

在准备好参数对象后,就可以调用被测试方法了。如果被测试方法有返回值,需要定义变量接收返回值;如果被测试方法要抛出异常,需要指定期望的异常。

userService.createUser(userCreate)
2.3.3.验证数据对象(返回值)

在调用被测试方法后,如果被测试方法有返回值,需要验证这个返回值是否符合预期;如果被测试方法要抛出异常,需要验证这个异常是否满足要求。

Assert.assertEquals("用户标识不一致", userId, userService.createUser(userCreate));

2.4.验证方法阶段

第4步是验证方法阶段,主要包括验证依赖方法、验证数据对象(参数)、验证依赖对象3步。

2.4.1.验证依赖方法

作为一个完整的测试用例,需要对每一个模拟的依赖方法调用进行验证。

// 验证依赖方法
// 验证依赖方法: userDAO.getByName
Mockito.verify(userDAO).getIdByName(userCreate.getName());
// 验证依赖方法: idGenerator.next
Mockito.verify(idGenerator).next();
// 验证依赖方法: userDAO.create
ArgumentCaptor<UserDO> userCreateCaptor = ArgumentCaptor.forClass(UserDO.class);
Mockito.verify(userDAO).create(userCreateCaptor.capture());
2.4.2.验证数据对象(参数)

对应一些模拟的依赖方法,有些参数对象是被测试方法内部生成的。为了验证代码逻辑的正确性,就需要对这些参数对象进行验证,看这些参数对象值是否符合预期。

text = ResourceHelper.getResourceAsString(getClass(), "userCreateDO.json");
Assert.assertEquals("用户创建不一致", text, JSON.toJSONString(userCreateCaptor.getValue()));
2.4.3.验证依赖对象

作为一个完整的测试用例,应该保证每一个模拟的依赖方法调用都进行了验证。正好,Mockito提供了一套方法,用于验证模拟对象所有方法调用都得到了验证。

// 验证依赖对象
Mockito.verifyNoMoreInteractions(idGenerator, userDAO);

3.定义被测对象

在编写单元测试时,首先需要定义被测对象,或直接初始化、或通过Spy包装……其实,就是把被测试服务类进行实例化。

3.1.直接构建对象

直接构建一个对象,总是简单又直接。

UserService userService = new UserService();

3.2.利用Mockito.spy方法

Mockito提供一个spy功能,用于拦截那些尚未实现或不期望被真实调用的方法,默认所有方法都是真实方法,除非主动去模拟对应方法。所以,利用spy功能来定义被测对象,适合于需要模拟被测类自身方法的情况,适用于普通类、接口和虚基类。

UserService userService = Mockito.spy(new UserService());
UserService userService = Mockito.spy(UserService.class);
AbstractOssService ossService = Mockito.spy(AbstractOssService.class);

3.3.利用@Spy注解

@Spy注解跟Mockito.spy方法一样,可以用来定义被测对象,适合于需要模拟被测类自身方法的情况,适用于普通类、接口和虚基类。@Spy注解需要配合@RunWith注解使用。

@RunWith(PowerMockRunner.class)
public class CompanyServiceTest {
    @Spy
    private UserService userService = new UserService();
    
    ...
}

注意:@Spy注解对象需要初始化。如果是虚基类或接口,可以用Mockito.mock方法实例化。

3.4.利用@InjectMocks注解

@InjectMocks注解用来创建一个实例,并将其它对象(@Mock、@Spy或直接定义的对象)注入到该实例中。所以,@InjectMocks注解本身就可以用来定义被测对象。@InjectMocks注解需要配合@RunWith注解使用。

@RunWith(PowerMockRunner.class)
public class UserServiceTest {
    @InjectMocks
    private UserService userService;
    ...
}

4.模拟依赖对象

在编写单元测试用例时,需要模拟各种依赖对象——类成员、方法参数和方法返回值。

4.1.直接构建对象

如果需要构建一个对象,最简单直接的方法就是——定义对象并赋值。

Long userId = 1L;
String userName = "admin";
UserDO user = new User();
user.setId(userId);
user.setName(userName);
List<Long> userIdList = Arrays.asList(1L, 2L, 3L);

4.2.反序列化对象

如果对象字段或层级非常庞大,采用直接构建对象方法,可能会编写大量构建程序代码。这种情况,可以考虑反序列化对象,将会大大减少程序代码。由于JSON字符串可读性高,这里就以JSON为例,介绍反序列化对象。

反序列化模型对象:

String text = ResourceHelper.getResourceAsString(getClass(), "user.json");
UserDO user = JSON.parseObject(text, UserDO.class);

反序列化集合对象:

String text = ResourceHelper.getResourceAsString(getClass(), "userList.json");
List<UserDO> userList = JSON.parseArray(text, UserDO.class);

反序列化映射对象:

String text = ResourceHelper.getResourceAsString(getClass(), "userMap.json");
Map<Long, UserDO> userMap = JSON.parseObject(text, new TypeReference<Map<Long, UserDO>>() {});

4.3.利用Mockito.mock方法

Mockito提供一个mock功能,用于拦截那些尚未实现或不期望被真实调用的方法,默认所有方法都已被模拟——方法为空并返回默认值(null或0),除非主动执行doCallRealMethod或thenCallRealMethod操作,才能够调用真实的方法。

利用Mockito.mock方法模拟依赖对象,主要用于以下几种情形:

  1. 只使用类实例,不使用类属性;

  2. 类属性太多,但使用其中少量属性(可以mock属性返回值);

  3. 类是接口或虚基类,并不关心其具体实现类。

MockClass mockClass = Mockito.mock(MockClass.class);
List<Long> userIdList = (List<Long>)Mockito.mock(List.class);

4.4.利用@Mock注解

@Mock注解跟Mockito.mock方法一样,可以用来模拟依赖对象,适用于普通类、接口和虚基类。@Mock注解需要配合@RunWith注解使用。

@RunWith(PowerMockRunner.class)
public class UserServiceTest {
    @Mock
    private UserDAO userDAO;
    ...
}

4.5.利用Mockito.spy方法

Mockito.spy方法跟Mockito.mock方法功能相似,只是Mockito.spy方法默认所有方法都是真实方法,除非主动去模拟对应方法。

UserService userService = Mockito.spy(new UserService());
UserService userService = Mockito.spy(UserService.class);
AbstractOssService ossService = Mockito.spy(AbstractOssService.class);

4.6.利用@Spy注解

@Spy注解跟Mockito.spy方法一样,可以用来模拟依赖对象,适用于普通类、接口和虚基类。@Spy注解需要配合@RunWith注解使用。

@RunWith(PowerMockRunner.class)
public class CompanyServiceTest {
    @Spy
    private UserService userService = new UserService();
    ...
}

注意:@Spy注解对象需要初始化。如果是虚基类或接口,可以用Mockito.mock方法实例化。

5.注入依赖对象

当模拟完这些类成员对象后,我们需要把这些类成员对象注入到被测试类的实例中。以便在调用被测试方法时,可能使用这些类成员对象,而不至于抛出空指针异常。

5.1.利用Setter方法注入

如果类定义了Setter方法,可以直接调用方法设置字段值。

userService.setMaxCount(100);
userService.setUserDAO(userDAO);

5.2.利用ReflectionTestUtils.setField方法注入

JUnit提供ReflectionTestUtils.setField方法设置属性字段值。

ReflectionTestUtils.setField(userService, "maxCount", 100);
ReflectionTestUtils.setField(userService, "userDAO", userDAO);

5.3.利用Whitebox.setInternalState方法注入

PowerMock提供Whitebox.setInternalState方法设置属性字段值。

Whitebox.setInternalState(userService, "maxCount", 100);
Whitebox.setInternalState(userService, "userDAO", userDAO);

5.4.利用@InjectMocks注解注入

@InjectMocks注解用来创建一个实例,并将其它对象(@Mock、@Spy或直接定义的对象)注入到该实例中。@InjectMocks注解需要配合@RunWith注解使用。

@RunWith(PowerMockRunner.class)
public class UserServiceTest {
    @Mock
    private UserDAO userDAO;
    private Boolean canModify;

    @InjectMocks
    private UserService userService;
    ...
}

5.5.设置静态常量字段值

有时候,我们需要对静态常量对象进行模拟,然后去验证是否执行了对应分支下的方法。比如:需要模拟Lombok的@Slf4j生成的log静态常量。但是,Whitebox.setInternalState方法和@InjectMocks注解并不支持设置静态常量,需要自己实现一个设置静态常量的方法:

public final class FieldHelper {
    public static void setStaticFinalField(Class<?> clazz, String fieldName, Object fieldValue) throws NoSuchFieldException, IllegalAccessException {
        Field field = clazz.getDeclaredField(fieldName);
        FieldUtils.removeFinalModifier(field);
        FieldUtils.writeStaticField(field, fieldValue, true);
    }
}

具体使用方法如下:

FieldHelper.setStaticFinalField(UserService.class, "log", log);