一、让我们开启单元测试之旅

前言

思量良久在考虑要不要写这一篇,是不是直接看门见山将在项目中怎么进行单元测试。最后想想觉得是不是太猴急了写那样,就好比你想和一个姑娘滚床单,是不是先应该请姑娘吃顿饭、送点礼物什么的。所以我才决定写这篇序言,让我们一起慢慢的揭开单元测试的面纱。

什么是单元测试

这个在网上有很详细的解释。我这就简单的给出一个概念:
单元测试是开发者编写一小段代码,用于检测被测代码的一个很小的、很明确的功能是否正确。

单元测试是对软件基本单元进行的测试,实际应用中是对public 函数进行的测试。
执行单元测试,是为了验证某段代码的行为确实和开发者所期望的一致。

为什么要进行单元测试

理由千千万,我只宠这三点:

  • 减少调试时间
  • 自动化测试
  • 令设计变得更好

我们或多或少也都听说过单元测试,只知道用来检测写的代码有没有问题,这导致之前都没有写过测试用例,测试一些重要的方法最多也一个 main方法正常的数据调通了就过了,这样导致后期出现各种各样的问题,一遍遍的改代码,一遍遍的改bug。费时费力还不一定能处理好。我以为这是软件开发的诟病,其实不然,是因为我们不能确认我们写的那部分代码没有问题,所以总花费很长时间找问题上。所以才需要进行单元测试,虽然在刚开始写单元测试会花费时间,但是我们单元测试全都通过之后,我们对自己写的代码更有自信,可以确定没有代码没有问题了,而不是自己认为没有问题的那种。这样后期修复bug,也可以通过单元测试哪些执行成功哪些执行失败可以快速的定位到问题。我觉得这一点就足以让我们为我们写的代码编写相应的单元测试啦,毕竟找问题真是太痛苦,大家应该也深有体会。

单元测试怎么做

简单而言,就是对一个 public 方法编写测试用例,那测试用例又怎么写呢?
测试用例说白了也是一个方法,用来验证目标方法是否符合我们的预期。
那这样就知道怎么写了吧,就是和我们平时写方法一样,但是它有一个标准
俗称 “3A 模式” Arrange-Act-Assert(准备上下文环境–执行被测函数–断言)。也就是说一个测试用例的方法包含三部分就可以了。

测试用例应该具备的特征

上面说的测试用例包含这三部分就可以了,那我们的测试用例应该具备怎样的特征呢,短小精悍且快准繁

小:一个测试几行代码(15)
精准:一个测试之测一个场景
隔离:每个测试都可以独立、重复运行,无耦合
快:每个测试都应该是毫秒级别的
频繁:应该频繁的执行,没增加、修改、删除一个测试都要运行一遍

那什么样的是好的单元测试呢?
自动化
可重复的
彻底的
独立的
专业的

好的测试用例

测试用例应该短小精悍且快准狠。这些是对测试用例的函数本身而言的,但在实际项目中出问题往往就是某些情况没有考虑到导致程序出错的,我们在自测的时候往往会测试正常数据的情况然而却忽略的了错误情况和边界值的测试,这些才是校验一个项目的健壮性的标准。所以好的测试用例必定是有全面的测试数据。那怎样获取全面的测试数据呢?
在这之前需要知道哪些是好的测试数据

  • 最优可能抓住错误的
  • 不是重复的,多余的
  • 一组相似测试用例中最有效的
  • 既不是太简单,也不是太复杂

那怎样获取好的测试数据呢?有等价类划分法、边界值法、路径分析法。

等价类划分法

等价类划分法是把所有可能的输入数据,划分成若干个子集,然后从每个子集中选取少数的具有代表性的数据作为测试用例。
该方法是一种重要的、常用的黑盒测试用例设计方法。

有效等价类:对程序的规范说明是合理的,有意义的输入数据构成的集合。
无效等价类:对程序的规范说明不是合理的或者无意义的输入数据构成的集合。

我们来看一个例子:计算两个点距离的函数

public double getDistance(double x1, double y1, double x2, double y2)

在这里插入图片描述

边界值法

边界值分析法是对输入或者输出的边界值进行测试的一组黑盒测试方法。

通常情况下,边界值分析法是作为等价类划分法的补充,这种情况下,其测试用例来自等价类的边界。

比如上面一个例子中取边界值做为测试用例。

路径分析法

基本路径测试是一种白盒测试方法,它在程序控制图的基础上,通过分析程序的流程,构造导出基本可执行路径集合,从而设计测试用例的方法。

设计出的测试用例要保证在测试程序中的每一个可执行语句至少执行一次。
我们来看一个例子
在这里插入图片描述
可能的路径为:

1-2-3-4-5
1-2-3-4-6
1-2-4-5
1-2-4-6

断言

我们这里说的断言只是Junit断言,java 本身也有断言的,但是貌似我们使用的很少以至于我们都忘记了它的存在。
Junit 断言说是断言,其实也就是一份方法,没有什么语法。我们测试用例中使用断言,也就是使用这些方法来进行验证是否达到我们的预期。
方法有很多,大家可以看看源码,我这里给出几个常见的。

函数名 描述
assertEquals 判断实际产生的值与期望值是否相等
assertNull 判断对象是否为null
assertNotNull 判断对象是否为非null
assertSame 判断实际产生的对象与期望对象是否为同一个对象
assertNotSame 判断实际产生的对象与期望对象是否为不同的对象
assertTrue 判断bool变量是否为真
assertFalse 判断bool变量是否为假
Fail 使测试立即失败

上面这样说好像没有什么效果,我们先来看其中一个断言方法的源代码。我们就看第一个assertEquals 吧
在这里插入图片描述
可以看到有很多assertEquals方法。这样的方法的重载在底层很常见。我们来看下三个参数类似是Object的这个吧。

public static void assertEquals(String message, Object expected, Object actual) {
        if (!equalsRegardingNull(expected, actual)) {
            if (expected instanceof String && actual instanceof String) {
                String cleanMessage = message == null ? "" : message;
                throw new ComparisonFailure(cleanMessage, (String)expected, (String)actual);
            } else {
                failNotEquals(message, expected, actual);
            }
        }
    }

private static boolean equalsRegardingNull(Object expected, Object actual) {
        if (expected == null) {
            return actual == null;
        } else {
            return isEquals(expected, actual);
        }
    }

    private static boolean isEquals(Object expected, Object actual) {
        return expected.equals(actual);
    }
private static void failNotEquals(String message, Object expected, Object actual) {
        fail(format(message, expected, actual));
    }

    static String format(String message, Object expected, Object actual) {
        String formatted = "";
        if (message != null && !message.equals("")) {
            formatted = message + " ";
        }

        String expectedString = String.valueOf(expected);
        String actualString = String.valueOf(actual);
        return expectedString.equals(actualString) ? formatted + "expected: " + formatClassAndValue(expected, expectedString) + " but was: " + formatClassAndValue(actual, actualString) : formatted + "expected:<" + expectedString + "> but was:<" + actualString + ">";
    }

equalsRegardingNull() 函数就是判断两个值是否相等,底层还是相当于用的object.equals()。如果两个值相等就断言通过,如果不相等就判断expected和actual是否是string类型,如果是直接将message输出。如果不是就failNotEquals().failNotEquals方法的源码我也贴出来了,可以看也很简单,就是message、expected、actual转换成string格式输出出来,并执行fail()使得测试失败。

从上面看断言也就不过如此(Junit 断言)。我们会使用常用的方法就可以写好测试用例啦,至于其他的方法,我们用到的时候可以直接其源代码,毕竟也不会很复杂。

简单案例

目标代码及功能说明

在这里插入图片描述
这段代码在项目中的作用是对特殊字段的对应的值进行处理并返回。
如果字段是包含time,那将值改成日期格式返回。
如果字段是包含iphone,那将值截取后11位返回。
其他情况,直接返回。

public class DataHandle {
    public static final String REGEX_MOBILE = "^((13[0-9])|(15[0-9])|(17[0-9])|(18[0-9])|(19[0-9])|(14[0-9]))\\d{8}$";

    public String fieldDataHandle(String key,String value){
        //如果是时间类型,将时间戳转成时间
         if(key.toLowerCase().contains("ipone")){  //如果手机号长于11位,截取后11位
            if(value.length()>11){
                value=value.substring(value.length()-11);
            }
            if(!isMobile(value)){
                return null;
            }
        }else if(key.toLowerCase().contains("time")){
            value=timeStampToDate(value,"yyyy-MM-dd HH:mm:ss");
        }
        return value;
    }
    private static String timeStampToDate(String time,String timeFormat) {
        Long timeLong = Long.parseLong(time);
        SimpleDateFormat sdf = new SimpleDateFormat(timeFormat);//要转换的时间格式
        Date date;
        try {
            date = sdf.parse(sdf.format(timeLong));
            return sdf.format(date);
        } catch (Exception e) {
            return null;
        }
    }

    private static boolean isMobile(String mobile) {
        return Pattern.matches(REGEX_MOBILE, mobile);
    }

}

单元测试设计

等价类设计

等价类划分 有效等价类 无效等价类
key 包含time, 包含ipone,包含time和ipone 不包含time 和ipone
value 时间戳,手机号,带区号的手机号 不是时间戳,也不是手机号

我们根据这个来设计测试用例

key value 预期值
字段包含time 时间戳 返回日期格式的的字符串
字段包含time 不是时间戳 null
字段包含ipone 不是手机号 null
字段包含ipone 是11位的手机号 返回11位手机号字符串
字段包含ipone 是手机号,但位数大于11位 返回11位手机号字符串
字段包含time,ipone 时间戳 返回日期格式的的字符串
字段包含time,ipone 不是手机号,也不是时间戳 null
字段包含time,ipone 手机号 null
字段不包含time 和ipone 时间戳 时间戳字符串
字段不包含time 和ipone 11位手机号 手机号字符串
字段不包含time 和ipone 大于11位手机号 返回值字符串
字段不包含time 和ipone 不是手机号,也不是时间戳 值对应字符串

编写测试用例

public class DataHandleTest {

    DataHandle dataHandle = null;

    @Before
    public void setup()
    {
        dataHandle = new DataHandle();
    }

    @After
    public void tearDown()
    {
        dataHandle = null;
    }


    @Test
    public void testFieldDataHandle_包含time是时间戳_返回日期字符串(){
        assertEquals("2019-09-10 19:02:30", dataHandle.fieldDataHandle("atime","1568113350000"));
    }

    @Test
    public void testFieldDataHandle_包含time不是时间戳_返回NULL(){
        assertNull(dataHandle.fieldDataHandle("atime","1568113350aaa"));
    }

    @Test
    public void testFieldDataHandle_包含ipone不是手机号_返回NULL(){
        assertNull(dataHandle.fieldDataHandle("bipone","aaa"));
    }

    @Test
    public void testFieldDataHandle_包含ipone是11位手机号_返回手机号字符串(){
        assertEquals("13265459362",dataHandle.fieldDataHandle("bipone","13265459362"));
    }

    @Test
    public void testFieldDataHandle_包含ipone是大于11位手机号_返回手机号字符串(){
        assertEquals("13265459362",dataHandle.fieldDataHandle("bipone","+8613265459362"));
    }

    @Test
    public void testFieldDataHandle_包含time和ipone是时间戳_返回NULL(){
        assertNull(dataHandle.fieldDataHandle("atimebipone","1568168656000"));
    }

    @Test
    public void testFieldDataHandle_包含time和ipone是手机号_返回手机号字符串(){
        assertEquals("13265459362",dataHandle.fieldDataHandle("atimebipone","13265459362"));
    }

    @Test
    public void testFieldDataHandle_包含time和ipone不是时间戳手机号_返回NULL(){
        assertNull(dataHandle.fieldDataHandle("atimebipone","aaabbb"));
    }

    @Test
    public void testFieldDataHandle_不包含time和ipone是时间戳_返回时间戳字符串(){
        assertEquals("1568114439",dataHandle.fieldDataHandle("ccc","1568114439"));
    }

    @Test
    public void testFieldDataHandle_不包含time和ipone是11位手机号_返回时间手机号字符串(){
        assertEquals("13112341234",dataHandle.fieldDataHandle("ccc","13112341234"));
    }

    @Test
    public void testFieldDataHandle_不包含time和ipone是大于11位手机号_返回值字符串(){
        assertEquals("+8613412341234",dataHandle.fieldDataHandle("ccc","+8613412341234"));
    }

    @Test
    public void testFieldDataHandle_不包含time和ipone不是时间戳手机号_返回值字符串(){
        assertEquals("abcdefg",dataHandle.fieldDataHandle("ccc","abcdefg"));
    }


} 

然后我们执行一下测试用例;
在这里插入图片描述
可以看到有一个地方的测试用例是不通过的,那就说明有问题,我们看一下。

 @Test
    public void testFieldDataHandle_包含time不是时间戳_返回NULL(){
        assertNull(dataHandle.fieldDataHandle("atime","1568113350aaa"));
    }

这个是抛异常了,因为日期格式转换错误,但是我们在日期转换的时候已经捕获了呀,并且返回为null 。
在这里插入图片描述
那为什么测试用例没有通过呢,而是直接抛异常出来了,调试发现这个方法没有捕获到异常,而是直接抛出给Junit了。所以这里提示代码不能这么写。一般异常了不建议返回null.而是打印出异常把信息抛出。这里我们就不改了。我们将测试用例改一下,在测试用例中捕获一下异常。
改成如下:

 @Test(expected = NumberFormatException.class)
    public void testFieldDataHandle_包含time不是时间戳_throwsException(){
        dataHandle.fieldDataHandle("atime","1568113350aaa");
    }

再全部执行一下
在这里插入图片描述
这样就不抱错了。
好啦这个就是一个简单的测试用例啦。

总结

最后总结一下吧,我觉得应该知道以下几点

  • 认识到单元测试的必要性
  • 好的测试用例是关键
  • 测试用例中断言必不可少
  • 编写测试用例的规范要遵循

看到这啦的小伙伴,如果觉得喜欢就点个赞吧嘿嘿。如果有什么意见,欢迎给我提。嘿嘿。后续想写一下测试用例的规范,喜欢的可以持续关注❤

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章