浅论单元测试的内部输入问题
 

2010-01-21 来源:网络

 

内部输入是函数内部获得的输入,包括调用子函数获得的输入和局部静态变量。请看下面的两组功能完全一样代码(差异部分用粗体标出):

代码一(代码清单4.1.cpp):

//计算圆的外接正方形的面积
int GetArea(int r)
{
    return (r+r) * (r+r);
}

//判断指定的正方形是否可以容纳圆
//参数int r:圆的半径
//参数int x:指定正方形边长
//返回值:不能容纳,返回-1;正好可以容纳,返回0;有多余空间,返回1
int Compare(int r, int x)
{
       int a1 = GetArea(r);
       int a2 = x*x;
       
        if(a1 == a2)
                return 0;

        if(a1 < a2)
                return 1;

        return -1;
}

//调用compare
int Func(int r, int x)
{
        int result = Compare(r, x);
        printf("result %d\n", result);
        return result;
}

代码二(代码清单4.2.cpp):

//计算圆的外接正方形的面积
int GetArea(int r)
{
        return (r+r) * (r+r);
}

//判断指定的正方形是否可以容纳圆
//参数int a1:圆的外接正方形的面积
//参数int x:指定正方形边长
//返回值:不能容纳,返回-1;正好可以容纳,返回0;有多余空间,返回1
int Compare(int a1, int x)
{
        int a2 = x*x;
       
        if(a1 == a2)
                return 0;

        if(a1 < a2)
                return 1;

        return -1;
}

//调用compare
int Func(int r, int x)
{
       int a1 = GetArea(r);
        int result = Compare(a1, x);
        printf("result %d\n", result);
        return result;
}

两组代码的差别在对GetArea()的调用是在Compare()之内还是之外。假如我们的测试目标是Comapre(),圆的外接正方形的面积a1在代码一中通过调用Compare()取得,在代码二中通过参数传递,显然,两者之间并无本质区别。Compare()的功能是,计算指定正方形的面积后并与外接正方形面积比较,GetArea()只是一个取得数据的调用,GetArea()本身是否正确,不是我们的测试目标,测试目标在于:对参数输入和 GetArea()的各种输出是否做了合适的计算,只要这些计算正确,Comapre()就没有错误。因此,从测试角度来看,被测函数调用其他函数(称为底层函数)所取得的数据,完全可以和通过参数传递的数据同等对待,都是一种输入,称为内部输入。

只有深刻理解内部输入,才能真正理解单元测试。单元测试是针对代码单元的独立测试,一个函数,在调用了其他函数的情况下,如何能够独立测试?只有把底层函数的输出,视作被测函数的一种输入,才能真正进行独立测试。

把底层函数的输出视为被测函数的输入,会不会影响测试效果?当然不会,因为单元测试主要目的就是检查被测函数的功能逻辑,检查是否针对各种输入包括内部输入做了合适的处理。无论底层函数是否正确或者是否存在,只要被测函数对输入包括内部输入的各种可能做了正确的处理,被测函数本身就不会有功能错误。

内部输入是单元测试的关键难题。代码耦合有两种,虚耦合和实耦合。虚耦合是指没有调用关系的耦合,例如我们要测试Compare(),这个函数位于一个文件A中,文件A中可能还有很多其他代码,也可能包含了很多头文件,虽然这些代码并没有被Compare()调用,但是,这些代码仍然可能造成文件A难于单独编译和链接,这就是虚耦合,虚耦合一般可以靠简单打桩解决。实耦合就是调用关系形成的耦合,例如在代码一中,Comapre()和GetArea()就是实耦合,实耦合又有两种情况:一是底层函数没产生被测函数需要使用的输出,这种情形可以不处置;二是底层函数产生了被测函数需要使用的输出,成了内部输入。

内部输入有以下几种情形:

自然内部输入

这是指对底层函数的正常调用即可获得的内部输入,前面的示例就属于自然输入。代码一中Compare()函数,int a1 = GetArea(r);可以自然取得外接正方形的面积。如果外接矩形面积a1要得到某个预期的值,要传递合适的半径r。自然输入有两个条件:一是底层函数存在,二是底层函数正确。

不可控的内部输入

是指调用实际代码,但实际代码的输出难于控制,难于把各种可能输出都测试到。例如,底层函数返回一个随机数,就是不可控。在实际项目中,不可控是很常见的,下面的代码是空调控制程序中的一个函数(代码清单4.3.cpp):

/*
函数说明:
功能: 空调控制程序片断,取得环境温度并计算制冷器需运行的时间
参数: pWorkTime, 输出参数,保存制冷器需运行的时间
返回: int类型,如果函数执行失败,返回0,否则返回非0值
*/
extern int GetTemperature(int* pTemperature);
int gExpectTemperature = 25;

int WorkTime(int* pWorkTime)
{
        int success = 0;  //取环境温度是否成功
    int temperature;  //环境温度

        ////取环境温度
    success = GetTemperature(&temperature);

        if(!success)
                return 0;

        //后面的代码与_03_WorkTime2完全一致
  
        //计算温度差,gExpectTemperature是全局变量
        int TempDiff = temperature - gExpectTemperature;
        if(TempDiff <= 0)
                return 0;
              
    if(pWorkTime == 0)
                return 0;                       

        //为了简化问题,这里假设温差一度,需运行一分钟
    *pWorkTime = TempDiff * 60;
    return 1;
}

代码的重点在于success = GetTemperature(&temperature);,这行代码调用GetTemperature()取环境温度,如果操作成功,success等于1,操作不成功,success等于0;取得的环境温度保存在局部变量int temperature中。假设在实际环境中测试,调用的都是实际代码。我们首先要设定预期的温度gExpectTemperature,例如设为25,这是全局变量,容易做到。我们还要测试各种环境温度下程序的行为,例如,至少要测试25,大于25和小于25三种情况,显然,这是很困难的,真实的环境温度在短时间内很难大幅变化,即使大幅变化,也未必符合测试需求,这就是不可控。

失真的内部输入

失真是打桩造成的,是打桩的必然后果。上面的示例,假如GetTemperature()未实现,或者由于解耦合的目的必须隔离,或者试图解决不可控的问题打桩来代替,桩代码大致是这个样子(代码清单4.4.cpp):

int GetTemperature(int* pTemperature)
{
        return 0;
}

直接返回0,此外什么也不做。调用GetTemperature()后,success总是为0,环境温度temperature未初始化,测试无法进行。

一种思路是修改桩代码,使它实现一些功能,例如,给每个用例起一个名字,桩代码判断当前用例名并做合适的操作。这种方法比较麻烦,并且只能适应简单情形。一个桩可能被多个被测函数调用,一个被测函数又可能调用多个桩,要维护用例名与桩行为之间的匹配关系,无疑是一场噩梦。

难于设定的内部输入

前面介绍自然内部输入时提到:如果圆的外接正方形a1要得到某个预期的值,要传递合适的半径r,这是通过外部输入来获得预期的内部输入,即需要倒推外部输入。很多时候,这个工作是很困难的,例如,要设定圆的面积为10.00,半径应该是多少?另外,很多时候,为了获得一个简单的内部输入,需要做复杂的初始化工作,请看下面的示例(代码清单4.5.cpp):

/*
函数说明:
功能: 将PERSON对象指针保存到表格中,如果名字已存在,则不保存并返回0
参数: pData, 需保存的对象指针
     map, 保存对象指针的映射表
返回: 如果加入失败,返回0,否则返回非0值
*/
int AddPerson(PERSON* pData, CPersonMap2* map)
{
    if(map->Search(&pData->name))
            return 0;

        map->Add(pData);
        return 1;
}

参数PERSON* pData是结构指针,记录一个“人”的资料,结构PERSON含有一个字符串成员name,记录“人”的名字。参数CPersonMap2* map是一个映射表,以名字为key保存PERSON的对象指针。代码很简单,当名字已在表中存在时,直接返回0,否则保存到映射表。测试时要使 map->Search(&pData->name)返回true,一般的方法是在表中预先加入相应的数据,这可能很麻烦,如果能直接让map->Search(&pData->name)返回true,既简单又直接。

难于设定的内部输入非常常见,尤其是测试比较高层的函数,很多输入都是比较复杂但其目的只是传递给底层函数以获得一个简单的内部输入,如果我们转换思路,想办法直接设定内部输入,工作将会大量减少。

静态变量形成的内部输入

除调用底层函数形成内部输入外,局部静态变量也会形成内部输入。请看下面的代码(代码清单4.6.cpp):

/*
函数说明:
功能: 游戏程序中用于计算打击效果的代码片断,连续打击时效果随次数递减
参数: reset, 输入参数,为true时重置打击次数
返回: int类型,打击效果
*/
int PowerEffect(bool reset)
{
    //打击次数,由于是局部变量,用例中无法访问,难于测试
    static int times = 0;
    if(reset) times = 0;
        times++;

    int effect[] = {9, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0};
    if(times >= sizeof(effect) / sizeof(effect[0]))
        return 0;
    return effect[times];
}

打击次数times是一个局部静态变量,局部变量无法外部访问,这给测试造成了困难。这个示例中,打击次数只是简单递加,还有可能通过适当排列用例,或插入若干前置调用来控制它的值,但在实际项目中,未必那么简单,因此,局部静态变量也是一种必须解决的内部输入。

前面列出了内部输入的五种情形,后四种是影响单元测试能否顺利实施的关键难点。有问题并不可怕,可怕的是不知道问题的存在。只有发现和正视问题,才有可能解决问题。有趣的是,有些朋友将内部输入的问题被归结为“代码可测性差”,解决办法是改良代码提高可测性,这是典型的“站着说话不腰疼”,有经验的程序员用脚趾头想一下,就知道这些问题是大量存在并且多数是不可能消除的。单元测试方法或工具,如果无法解决内部输入的问题,就无法适应实际项目的测试,这不是代码的可测性问题,而是方法或工具的可用性问题。


火龙果软件/UML软件工程组织致力于提高您的软件工程实践能力,我们不断地吸取业界的宝贵经验,向您提供经过数百家企业验证的有效的工程技术实践经验,同时关注最新的理论进展,帮助您“领跑您所在行业的软件世界”。
资源网站: UML软件工程组织