单元测试经典三问:是什么,为什么,怎么做?

一、背景

编写合格的单元测试可以说是 Java 程序员的基本功。
很多公司对但单测覆盖率都会有要求,通常要求在 60% 到 90% 不等。

但是很多同学对单元测试或多或少有一些抵触,对如何写出“标准”的单元测试代码存在疑问。

有些同学编写单元测试,纯粹是应付工作,完全起不到单测应该起到的作用。
单元测试经典三问:是什么,为什么,怎么做?

本文解答单元测试的三个基本问题,即单元测试是什么,为什么编写单元测试,怎么编写单元测试?

二、经典三问

2.1 单元测试是什么?

单元测试英文单词叫: Unit Test 。
什么是 Unit (单元)?

单元可以是一个方法、可以是一个类,也可以是一个包甚至是一个子系统。
我们开发时编写的单元测试,通常是对一个类中的部分或者所有方法进行测试,用来验证它们功能的正确性。

通常用来验证给定特定的输入,是否能够给出符合预期的输出。

2.2 为什么要编写单元测试?

我们知道错误越早发现,越早解决,越好。
编写单元测试可以在编码阶段就可以验证代码的正确性,及早改正。

单元测试通常可以帮助我们尽早发现一些低级错误、一些逻辑错误,非常有价值。

如果编写单元测试比较全面,那么重构代码就比较容易,重构后单元测试不通过就说明代码有问题。


但是国内的大环境下,工期比较紧张,而且编写单元测试比较耗时,往往会出现应付测试的心态。

如果是因为编写单测比较浪费时间,可以参考我的另外一篇文章自动生成部分单测代码,自己只需要做出少部分调整即可:
《告别加班/解放双手提高单测覆盖率之Java 自动生成单测代码神器推荐》

还有一些同学并不认可单元测试的价值,会认为功能测试阶段一样可以验证代码正确性,写单元测试纯粹是教条主义,浪费时间。

单元测试是保证代码质量的一个重要手段。
(1)虽然不写单测也会进行功能测试,但是测试阶段发现的很多问题都可能是单元测试阶段就应该发现和解决的。
(2)有时开发新的功能数据量少时,功能测试场景没覆盖到,可能就把本可以在单元测试阶段发现的错误带到了线上。

2.3 如何编写单元测试?

2.3.1 介绍

这里只谈单元测试的大逻辑,让新手明确知道单测应该写什么,并不是 JUnit 的入门教程。

单元测试的三部曲: given -> when -> then
单元测试经典三问:是什么,为什么,怎么做?

所谓 given 即构造参数和条件(如mock 依赖的bean ),所谓 when 执行目标方法; 所谓 then 即在给定的参数和条件下,执行目标方法后,我们的预期是什么。

下面是示例代码:

@Test
public void shouldDeliverCargoToDestination() {
    // given
    Driver driver = new Driver("Teddy");
    Cargo cargo = new Cargo();
    Position destinationPosition = new Position("52.229676", "21.012228");
    Truck truck = new Truck();
    truck.setDriver(driver);
    truck.load(cargo);
    truck.setDestination(destinationPosition);

    // when
    truck.driveToDestination();

    // then
    assertEquals(destinationPosition, truck.getCurrentPosition());
}
示例代码来源:https://blog.j-labs.pl/2017/02/Given-When-Then-pattern-in-unit-tests
一句话:单元测试类似与实验中的【控制变量法】,构造已知参数,mock 依赖的接口,断言运行的结果是否符合预期。

原则:
(1)测试时要尽可能覆盖正常用例,也要覆盖异常用例。
(2)尽量保证每个分支条件都要覆盖到。

2.3.2 案例

案例一
字符串拼接工具类

import java.util.Iterator;
import java.util.List;

public class SomeUtils {

    /**
     * 使用英文逗号拼接字符串
     */
    public static  String joinWithDot(List<String> params){

        StringBuilder stringBuilder = new StringBuilder();

        Iterator<String> iterator = params.iterator();
        while (iterator.hasNext()) {
            stringBuilder.append(iterator.next()).append(".");
        }
        return stringBuilder.toString();
    }
}

实际编码对字符串进行拼接时建议使用 StringJoiner 类

我们使用 《告别加班/解放双手提高单测覆盖率之Java 自动生成单测代码神器推荐》 介绍的 Squaretest 插件自动生成单测:

import org.junit.Test;

import java.util.Arrays;

import static org.junit.Assert.assertEquals;

public class SomeUtilsTest {


    @Test
    public void testJoinWithDot() {
        assertEquals("result", SomeUtils.joinWithDot(Arrays.asList("value")));
    }
}

我们在此基础上进行修改:

public class SomeUtilsTest {


    @Test
    public void testJoinWithDot() {
        assertEquals(null, SomeUtils.joinWithDot(null));
        assertEquals("a.b", SomeUtils.joinWithDot(Arrays.asList("a","b")));
        assertEquals("a.null.c", SomeUtils.joinWithDot(Arrays.asList("a",null,"c")));
    }
}

虽然,这里看似只有断言,比如:

 assertEquals(null, SomeUtils.joinWithDot(null));

这里其实是一种简写,实际上依然符合 Given when then 的模式:

    @Test
    public void testJoinWithDot() {

        // given
        List<String> params = null;
        
        // when
        String result = SomeUtils.joinWithDot(null);

        // then
        assertEquals(null, result);
    }

运行单元测试,发现出现空指针:
单元测试经典三问:是什么,为什么,怎么做?

说明我们代码不够健壮,需要修改:

 /**
     * 使用英文逗号拼接字符串
     */
    public static  String joinWithDot(List<String> params){
        if(CollectionUtils.isEmpty(params)){
            return null;
        }

        StringBuilder stringBuilder = new StringBuilder();

        Iterator<String> iterator = params.iterator();
        while (iterator.hasNext()) {
           stringBuilder.append(iterator.next()).append(".");
        }
        return stringBuilder.toString();
    }

再次运行,又报错了:
单元测试经典三问:是什么,为什么,怎么做?
我们断言 assertEquals("a.b", SomeUtils.joinWithDot(Arrays.asList("a","b"))); 执行后结果应该为 "a.b" 但是实际执行结果为 "a.b."

我们继续修改:

 /**
     * 使用英文逗号拼接字符串
     */
    public static  String joinWithDot(List<String> params){
        if(CollectionUtils.isEmpty(params)){
            return null;
        }

        StringBuilder stringBuilder = new StringBuilder();

        Iterator<String> iterator = params.iterator();
        while (iterator.hasNext()) {
            String each = iterator.next();
            stringBuilder.append(each);
            if (iterator.hasNext()) {
                stringBuilder.append(".");
            }
        }
        return stringBuilder.toString();
    }

通过单元测试,说明符合预期。


对于这种类似工具类的测试,可以参考 commons-lang、commons-collections4 等开源项目的单元测试写法。
[https://github.com/apache/commons-lang](
https://github.com/apache/commons-lang)

https://github.com/apache/commons-collections

案例二

平时开发中更常见的是对 Spring 的 Bean 进行测试,类似如下:

@Service
public class UserManager {
    @Setter
    private UserDAO userDAO;

    public UserDAO getUserDAO() {
        return userDAO;
    }

    public void setUserDAO(UserDAO userDAO) {
        this.userDAO = userDAO;
    }

    public List<UserDO> someThing(Param param) {

        List<UserDO> result = new ArrayList<>();

        if(param == null) {
            return result;
        }

        List<String> userIds = param.getUserIds();
        if(CollectionUtils.isEmpty(userIds)) {
            return result;
        }

        List<UserDO> users = userDAO.findByIds(userIds);
        if(CollectionUtils.isEmpty(users)) {
            return result;
        }

      return  users.stream().filter(UserDO::getCanShow).collect(Collectors.toList());
    }

}
注:通常依赖的 Bean 的方法时,

继续使用插件一键生成单元测试代码:

import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.MockitoJUnitRunner;

import java.util.Arrays;
import java.util.Collections;
import java.util.List;

import static org.junit.Assert.assertEquals;
import static org.mockito.Mockito.when;

@RunWith(MockitoJUnitRunner.class)
public class UserManagerTest {

    @Mock
    private UserDAO mockUserDAO;

    @InjectMocks
    private UserManager userManagerUnderTest;

    @Test
    public void testSomeThing() {
        // Setup
        final Param param = new Param();
        param.setUserIds(Arrays.asList("value"));
        param.setOthers("others");

        // Configure UserDAO.findByIds(...).
        final UserDO userDO = new UserDO();
        userDO.setCanShow(false);
        userDO.setName("name");
        final List<UserDO> userDOS = Arrays.asList(userDO);
        when(mockUserDAO.findByIds(Arrays.asList("value"))).thenReturn(userDOS);

        // Run the test
        final List<UserDO> result = userManagerUnderTest.someThing(param);

        // Verify the results
    }

    @Test
    public void testSomeThing_UserDAOReturnsNoItems() {
        // Setup
        final Param param = new Param();
        param.setUserIds(Arrays.asList("value"));
        param.setOthers("others");

        when(mockUserDAO.findByIds(Arrays.asList("value"))).thenReturn(Collections.emptyList());

        // Run the test
        final List<UserDO> result = userManagerUnderTest.someThing(param);

        // Verify the results
        assertEquals(Collections.emptyList(), result);
    }
}

我们根据业务逻辑,对生成的单测代码进行简单修改即可:

  @Test
    public void testSomeThing() {
        // given
        final Param param = new Param();
        List<String> userIds = Arrays.asList("value");
        param.setUserIds(userIds);
        param.setOthers("others");


        final UserDO userCanNotShow = new UserDO();
        userCanNotShow.setCanShow(false);
        userCanNotShow.setName("name");

        final UserDO userCanShow = new UserDO();
        userCanShow.setCanShow(true);
        userCanShow.setName("name");
        final List<UserDO> userDOS = Arrays.asList(userCanNotShow, userCanShow);
        when(mockUserDAO.findByIds(userIds)).thenReturn(userDOS);

        // when
        final List<UserDO> result = userManagerUnderTest.someThing(param);

        // then
        assertEquals(1, result.size());
        assertSame(userCanShow, result.get(0));
    }

自己只需要简单写部分代码即可完成单元测试,可节省我们大量的时间。

如果需要 mock 私有方法、静态方法等请自行学习,可以使用 powmock 等工具。


我们还可以借助其他工具,自动生成测试的参数或者返回值。
https://github.com/j-easy/easy-random

可以参考我之前的一篇文章:
《Java高效构造对象的神器:easy-random 简介》

一两行就可以构造一个非常复杂的对象或者对象列表。

《Java 单元测试生成测试字符串的神器:java-faker》

如果我们想要随机构造人名、地名、天气、学校、颜色、职业,甚至符合某正则表达式的字符串

三、总结

本文简单介绍单元测试是什么、为什么要编写单元测试和如何编写单元测试。
希望对大家有帮助,有任何疑问欢迎留言和我交流。


创作不易,如果本文对你有帮助,欢迎点赞、收藏加关注,你的支持和鼓励,是我创作的最大动力。
单元测试经典三问:是什么,为什么,怎么做?
上一篇:我眼中的Java大牛之孤尽老师


下一篇:非典型程序员的办公桌