标签(空格分隔): 深入浅出之动态测试方法


人工动态方法

人工动态方法,可以真正检测代码的业务逻辑功能,其关注点是“什么样的输入,执行了什么代码,产生了什么样的输出”,主要用于发现算法错误和部分算法错误,是最主要的代码级测试手段。

从人工动态方法的定义中,你可以很清楚地看出:代码级测试的人工动态测试方法,其实就是单元测试所采用的方法。所以,下面的分享,我会从单元测试方法的角度展开。

如果有一些代码基础,那么你在学习单元测试框架或者工具时,会感觉单元测试很简单啊,一点都不难:

  • 无非就是用驱动代码去调用被测函数,并根据代码的功能逻辑选择必要的输入数据的组合,然后验证执行被测函数后得到的结果是否符合预期。

但是,一旦要在实际项目中开展单元测试时,你会发现有很多实际的问题需要解决。

了解单元测试的难点:

  • 单元测试用例“输入参数”的复杂性;
    单元测试用例“预期输出”的复杂性;
    关联依赖的代码不可用。

单元测试用例“输入参数”的复杂性

提到“输入参数”的复杂性,你应该已经记起了,我在前面的分享中提到过:如果你认为单元测试的输入参数只有被测函数的输入参数的话,那你就把事情想得过于简单了。

其实,这也是源于我们在学习单元测试框架时,单元测试用例的输入数据一般都是被测函数的输入参数,所以我们的第一印象会觉得单元测试其实很简单。

但是到了实际项目时,你会发现单元测试太复杂了,因为测试用例设计时需要考虑的“输入参数”已经完全超乎想象了。

结合一些代码示例和你详细聊聊这些输入参数吧。

第一,被测试函数的输入参数

  • 这是最典型,也是最好理解的单元测试输入数据类型。假如你的被测函数是下面这段代码中的形式,那么函数输入参数 a 和 b 的不同取值以及取值的组合就构成了单元测试的输入数据。
int someFunc(int a, int b)
{
  …
}

第二,被测试函数内部需要读取的全局静态变量

  • 如果被测函数内部使用了该函数作用域以外的变量,那么这个变量也是被测函数的输入参数。
    下面这段代码中,被测函数 Func_SUT 的内部实现中使用了全局变量 someGlobalVariable,并且会根据 someGlobalVariable 的取值去执行 FuncA() 和 FuncB() 这不同的代码分支。

在做单元测试时,为了能够覆盖这两个分支,你就必须构造 someGlobalVariable 的不同取值,那么自然而然,这个 someGlobalVariable 就成为了被测函数的输入参数。
所以,在这段代码中,单元测试的输入参数不仅包括 Func_SUT 函数的输入参数 a,还包括全局变量 someGlobalVariable。

bool someGlobalVariable = true;
void Func_SUT(int a)
{
  ...
  if(someGlobalVariable == true)
  {
    FuncA();
  }
  else
  {
    FuncB();
  }
  ...
}

第三,被测试函数内部需要读取的类成员变量

如果你能理解“被测函数内部需要读取的全局静态变量”是单元测试的输入参数,那么“被测试函数内部需要读取的类成员变量”也是单元测试的输入参数就不难理解了。因为,类成员变量对被测试函数来讲,也可以看做是全局变量。

我们一起看一段代码。这段代码中,变量 someClassVariable 是类 someClass 的成员变量,类的成员函数 Func_SUT 是被测函数。Func_SUT 函数,根据 someClassVariable 的取值不同,会执行两个不同的代码分支。同样地,单元测试想要覆盖这两个分支,就必须提供 someClassVariable 的不同取值,所以 someClassVariable 对于被测函数 Func_SUT 来说也是输入参数。

class someClass{
  ...
  bool someClassVariable = true;
  ...
  void Func_SUT(int a)
  {
    ...
    if(someClassVariable == true)
    {
      FuncA();
    }
    else
    {
      FuncB();
    }
    ...
  }
  ...
}

第四,函数内部调用子函数获得的数据

“函数内部调用子函数获得的数据”也是单元测试的输入数据,从字面上可能不太好理解,那我就通过一段代码,和你详细说说这是怎么回事吧。

void Func_SUT(int a)
  {
  bool toggle = FuncX(a);
  if(toggle == true)
  {
    FuncA();
  }
  else
  {
    FuncB();
  }
}

函数 Func_SUT 是被测函数,它的内部调用了函数 FuncX,函数 FuncX 的返回值是 bool 类型,并且赋值给了内部变量 toggle,之后的代码会根据变量 toggle 的取值来决定执行哪个代码分支。

那么,从输入数据的角度来看,函数 FuncX 的调用为被测函数 Func_SUT 提供了数据,也就是这里的变量 toggle,后续代码逻辑会根据变量 toggle 的取值执行不同的分支。所以,从这个角度来看,被测函数内部调用子函数获得的数据也是单元测试的输入参数。

这里还有一个小细节,被测函数 Func_SUT 的输入参数 a,在内部实现上只是传递给了内部调用的函数 FuncX,而并没有在其他地方被使用,我们把这类用于传递给子函数的输入参数称为“间接输入参数”。

  • 这里需要注意的是,有些情况下“间接输入参数”反而不是输入参数。
    就以这段代码为例,如果我们发现通过变量 a 的取值很难控制 FuncX 的返回值(也就是说,当通过间接输入参数的取值去控制内部调用函数的取值,以达到控制代码内部执行路径比较困难)时,我们会直接对 FuncX(a) 打桩,用桩代码来控制函数 FuncX 返回的是 true 还是 false。
  • 这样一来,原本的变量 a 其实就没有任何作用了。那么,此时变量 a 虽然是被测函数的输入参数,但却并不是单元测试的输入参数。

第五,函数内部调用子函数改写的数据

理解了前面几种单元测试的输入参数类型后,“函数内部调用子函数改写的数据”也是单元测试中被测函数的输入参数就好解释了。

比如,当被测函数内部调用的子函数改写了全局变量或者类的成员变量,而这个被改写的全局变量或者类的成员变量又会在被测函数内部被使用,那么“函数内部调用子函数改写的数据”也就成为了被测函数的输入参数了。

第六,嵌入式系统中,在中断调用中改写的数据

嵌入式系统中,在中断调用中改写的数据有时候也会成为被测函数的输入参数,这和“函数内部调用子函数改写的数据也是单元测试中的输入参数”类似,在某些中断事件发生并执行中断函数时,中断函数很可能会改写某个寄存器的值,但是被测函数的后续代码还要基于这个寄存器的值进行分支判断,那么这个被中断调用改写的数据也就成了被测函数的输入参数。

其实在实际工程项目中,除了这六种输入参数,还有很多输入参数。在这里,我详细分析这六种输入参数的目的,一来是帮你理解到底什么样的数据是单元测试的输入数据,二来也是希望你可以从本质上认识单元测试的输入参数,那么在以后遇到相关问题时,你也可以做到触类旁通,不会再踌躇无措。

单元测试用例“预期输出”的复杂性

同样地,单元测试用例的“预期输出”,也绝对不仅仅是函数返回值这么简单。通常来讲,“预期输出”应该包括被测函数执行完成后所改写的所有数据,主要包括:被测函数的返回值,被测函数的输出参数,被测函数所改写的成员变量和全局变量,被测函数中进行的文件更新、数据库更新、消息队列更新等。

第一,被测函数的返回值

这是最直观的预期输出。比如,加法函数 int add(int a, int a) 的返回值就是预期输出。

第二,被测函数的输出参数

要理解“被测函数的输出参数”是预期输出,最关键的是要理解什么是函数的输出参数。如果你有 C 语言背景,那么你很容易就可以理解这个概念了。

我们一起来看一段代码。被测函数 add 包含三个参数,其中 a 和 b 是输入参数,而 sum 是个指针,指向了一个地址空间。
如果被测函数的代码对 sum 指向的空间进行了赋值操作,那么在被测函数外,你可以通过访问 sum 指向的空间来获得被测函数内所赋的值,相当于你把函数内部的值输出到了函数外,所以 sum 对于函数 add 来讲其实是用于输出加法结果的,那么显然这个 sum 就是我们的“预期输出”。

如果你还没有理解的话,可以在百度上搜索一下“C 语言的参数传递机制”

void add(int a, int b,int *sum)
{
  *sum = a + b;
}
void main()
{
  int a, b,sum;
  a = 10;
  b = 8;
  add(a, b, &sum);
  printf("sum = %d \n", sum);
}

第三,被测函数所改写的成员变量和全局变量

理解了单元测试用例“输入参数”的复杂性,“被测函数所改写的成员变量和全局变量”也是被测函数的“预期输出”就很好理解了,此时如果你的单元测试用例需要写断言来验证结果,那么这些被改写的成员变量和全局变量就是 assert 的对象。

第四,被测函数中进行的文件更新、数据库更新、消息队列更新等

这应该不难理解。
但在实际的单元测试实践中,因为测试解耦的需要,所以一般不会真正去做这些操作,而是借助对 Mock 对象的断言来验证是否发起了相关的操作。

关联依赖的代码不可用

什么是关联依赖的代码呢?
假设被测函数中调用了其他的函数,那么这些被调用的其他函数就是被测函数的关联依赖代码。

大型的软件项目通常是并行开发的,所以经常会出现被测函数关联依赖的代码未完成或者未测试的情况,也就是出现关联依赖的代码不可用的情况。那么,为了不影响被测函数的测试,我们往往会采用桩代码来模拟不可用的代码,并通过打桩补齐未定义部分。具体来讲,假定函数 A 调用了函数 B,而函数 B 由其他开发团队编写,且未实现,那么我们就可以用桩函数来代替函数 B,使函数 A 能够编译链接,并运行测试。桩函数要具有与原函数完全相同的原形,仅仅是内部实现不同,这样测试代码才能正确链接到桩函数。一般来讲桩函数主要有两个作用,一个是隔离和补齐,另一个是实现被测函数的逻辑控制。

用于实现隔离和补齐的桩函数实现比较简单,只需拷贝原函数的声明,加一个空的实现,可以通过编译链接就可以了。用于实现控制功能的桩函数是最常用的,实现起来也比较复杂,需要根据测试用例的需要,输出合适的数据作为被测函数的内部输入。

自动动态方法

我们先来回顾一下,什么是自动动态方法。自动动态方法是,基于代码自动生成边界测试用例并执行来捕捉潜在的异常、崩溃和超时的测试方法。

自动动态方法的重点是:如何实现边界测试用例的自动生成。

解决这个问题最简单直接的方法是,根据被测函数的输入参数生成可能的边界值。

具体来讲,任何数据类型都有自己的典型值和边界值,我们可以预先为它们设定好典型值和边界值,然后组合就可以生成了。

比如,函数 int func(int a, char *s),就可以按下面的三步来生成测试用例集。

  • 1.定义各种数据类型的典型值和边界值。 比如,int 类型可以定义一些值,如 int 的最小值、int 的最大值、0、1、-1 等;char* 类型也可以定义一些值,比如“”、“abcde”、“非英文字符串”等。

根据被测函数的原形,生成测试用例代码模板,比如下面这段伪代码:

try{
  int a= @a@;
  char *s = @s@;
  int ret = func(a, s);
}
catch{
  throw exception();
}

将参数 @a@和 @s@的各种取值循环组合,分别替换模板中的相应内容,即可生成用例集。

由于该方法不可能自动了解代码所要实现的功能逻辑,所以不会验证“预期输出”,而是通过 try…catch 来观察是否会引发代码的异常、崩溃和超时等具有边界特征的错误。

总结

代码级测试的动态测试方法,可以分为人工动态测试方法和自动动态测试方法。其中人工动态测试方式,是最常用的代码级测试方法,也是我们在进行单元测试时采用的方法。人工动态方法,也就是单元测试方法,通常看似简单,但在实际的工程实践中会遇到很多困难,总结来看这些困难可以概括为三大方面:单元测试用例“输入参数”的复杂性,表现在“输入参数”不是简单的函数输入参数。本质上讲,任何能够影响代码执行路径的参数,都是被测函数的输入参数。单元测试用例“预期输出”的复杂性,主要表现在“预期输出”应该包括被测函数执行完成后所改写的所有数据。关联依赖的代码不可用,需要我们采用桩代码来模拟不可用的代码,并通过打桩补齐未定义部分。而自动动态方法,需要重点讨论的是:如何实现边界测试用例的自动生成。解决这个问题最简单直接的方法是,根据被测函数的输入参数生成可能的边界值。