如何编写好的单元测试

好单元测试的特点及编写具有可测试性的代码

Posted by songjunhao on November 10, 2022

什么是单元测试

单元测试(unit testing),是指对软件中的最小可测试单元进行检查和验证。类比到盖楼就是对每块砖头,每个钢筋,每份水泥做检查,只有每一块砖头都满足要求,最后搭建起来的房子才可能没问题。

单元测试和集成测试的区别在于,单元测试是检查每个组件方法是否无误,集成测试在于将组件彼此依赖组装后进行验证。

单元测试的优点/作用

1.修改后的代码可以做快速测试,避免修改后,只能祈祷其正确。

2.可以倒逼业务代码的设计松耦合,满足设计原则,更清晰,易懂。

3.单元测试的测试用例本身也是对测试目标的一种解释说明。

好的单元测试的特点

1.运行快

2.可以帮助定位问题,具有表达性,表达出这个测试是测什么

3.应该是自动化的

4.每个单元测试不应该依赖其它测试的结果和执行顺序,单元测试框架可以按任意的顺序执行每个测试

5.每个单元测试不应该依赖数据库,外部文件,外部接口,或者任何长时间运行或不稳定的任务。

6.测试代码应该跟生产代码拥有同等标准要求

不好的单元测试的特点

1.与数据库有交互

2.与网络交互

3.与文件系统交互

4.需特定环境才可运行,例如必须先修改配置文件

Mock技术

为了满足单元测试的隔离性,隔离外部环境,我们需要将外部依赖,通过替身的方式,模拟出一个可以满足我们测试的假对象,这个对象我们可以定义外部依赖类方法的本次测试的返回值,来控制其行为,进而实现隔离。 常用的mock框架有Mockito,PowerMock。或者直接自行实现接口。

编写好的单元测试

为编写出好的单元测试,首先被测代码应具备可测试性。当写出具有可测试性的代码时,其实其变得遵从了经典的设计原则和思想,可读性,可维护性,也会提高。

下面列举出什么样的代码不可测试,通过对比,即可学习到如何编写具有可测试性的代码。

1.直接在方法体内 new 对象。

反例:

public boolean queryAndCheckUserInfo(String id) {
    UserJdbcMapper userJdbcMapper = new UserJdbcMapperImpl();
    UserInfo user = userJdbcMapper.queryUserInfo(id);
    return user.getAge() > 15;
}

直接通过new构造出UserJdbcMapper 接口对应的实现类跟数据库交互的类,该类并非构造时传入或注入依赖,该类无法被mock替换,所以无法编写隔离数据库/外部依赖的单元测试。

正例1,从外部传入:

public boolean queryAndCheckUserInfo(String id, UserJdbcMapper userJdbcMapper ) {
    UserInfo user = userJdbcMapper.queryUserInfo(id);
    return user.getAge() > 15;
}

上述代码,可以用mock框架,也可以自行实现一个UserJdbcMapper 接口的实现类传入该方法。

单元测试:

@Test

public void test_queryAndCheckUserInfo() {
    UserJdbcMapper userJdbcMapper = new UserJdbcMapperImpl();
    // 使用mock 控制依赖的其他类的方法行为,进而屏蔽与数据库交互
    Mockito.when(orderMapper.queryUserInfo(1)).thenReturn(new UserInfo(张三, 
17));
    boolean ans = userService.queryAndCheckUserInfo(1, userJdbcMapper );
    Assert.assertEquals(ans, true);
}

正例2,依赖注入

@Service
public class UserService {

    @Autowired
    private UserJdbcMapper userJdbcMapper;

    public boolean queryAndCheckUserInfo(String id) {
        UserInfo user = userJdbcMapper.queryUserInfo(id);
        return user.getAge() > 15;
    }

}

可以使用Mockito框架编写单元测试如下:

@RunWith(MockitoJUnitRunner.class)
public class UserServiceMockTest {

    @InjectMocks
    private UserService userService;

    @Mock
    private UserJdbcMapper orderMapper

    @Test
    public void test_queryAndCheckUserInfo() {
        // 使用mock 控制依赖的其他类的方法行为,进而屏蔽与数据库交互
        Mockito.when(orderMapper.queryUserInfo(1)).thenReturn(new UserInfo(张三, 17));
        boolean ans = userService.queryAndCheckUserInfo(1);
        Assert.assertEquals(ans, true);
    }

}

通过上述修改,除了具备可测试性,而且还满足了依赖倒置原则。

2.未决行为

代码可能是随机或不确定的,例如跟时间,随机数有关的代码。方法体内尽量不要出现随机,如果一定要出现,最好调用时传入或额外封装一个方法传入,保证实现的业务代码可被测即可,额外封装的单独方法非常简单可不进行单元测试。

反例:

public boolean checkDate(Date needCheckDate) {
    long currentTime = System.currentTimeMillis();
    return currentTime > needCheckDate.getTime();
}

跟系统时间耦合,倒置单元测试编写后可能随着时间而变化结果。

正例:

public boolean checkDate(Date needCheckDate, Date indexDate) {
    return indexDate.getTime() > needCheckDate.getTime();
}

public boolean checkDateNow(Date needCheckDate) {
    return checkDate(needCheckDate, new Date());
}

一定要校验当前时间的话,可以单独抽出一个方法checkDateNow,该方法因简单可认为其本身无问题,可不进行单元测试,只需测试 checkDate 方法即可。 随机数也是同样道理。

单元测试:

@Test
public void testCheckDate() {
    Date needCheckDate = new SimpleDateFormat(yyyy-MM-dd 
    HH:mm:ss).parse(2022-11-01 16:01:02);
    Date indexDate= new SimpleDateFormat(yyyy-MM-dd 
    HH:mm:ss).parse(2022-11-02 16:02:03);
    Assert.assertTrue(checkDate(needCheckDate , indexDate));
}

3.全局变量

因为好的单元测试,每个单元测试都互不影响,可并发执行,如果被测代码出现全局变量,则可能导致因运行顺序前后不同,导致结果不定。

如果被测方法涉及全局变量,也是可以例如上述未决条件一样,抽出不依赖全局变量的方法,单独测试,依赖全局变量的方法仅用于传值,可不做测试。

4.复杂继承

若父类需要 mock 某个依赖对象才能进行单元测试,那所有的子孙类,在编写单元测试的时候,都要 mock 这个依赖对象。越底层的子类,要mock的越多,还需要去看父类实现,导致测试困难。继承体系不能过深,本身也是高质量代码编程原则,所以通过代码的可测试性,也可以提升业务代码编写的质量。

5.高耦合代码

一个类,依赖了几十个其他类对象,才可完成工作,代码耦合度极高,在编写单元测试时,需要mock几十个依赖对象。对于编写单元测试代码,也是十分困难。从程序设计角度来看,也不应该如此设计一个类。

总结

好的单元测试,彼此独立,运行快速,不受时间空间配置文件等外部依赖条件影响。通过Mock技术,可以实现对外部依赖的解耦。当发现很难编写单元测试时,记得检查一下是否是代码本身就不具备可测试性。通过上面的例子,也可发现,具备可测试性的本质,关键就是解耦。