[LearnCPP]5-11如何测试你的程序[译]

262 阅读13分钟

你已经写了一个程序,他通过了编译,并且似乎已经开始工作!下一步怎么办?

嗯,这要看情况决定。如果你写一个程序仅仅只运行一次就被丢弃,那么你已经完成了。在这种情况下,即便你的程序不能满足每种情况,也没有关系(如果它能在你需要的那种情况下正常运行),并且你仅仅只会运行它一次,那么你已经完成了所有工作。

如果你的程序是完全线性的(没有条件,例如if或者switch语句),不会获取输入,并且能够产生正确的答案,那么你的工作也完成了。在这种情况下,你已经通过运行并且验证结果来测试了整个程序。

但是使用C++,更多的情况下你写一个程序的目的是要运行很多次,并且使用了循环和逻辑条件,并且接收用户输入。你也可能写了一些函数会在其他程序中被复用(reues)。也许你甚至会发布这个程序给其他人(他们可能会尝试一些你没想到的情况)。在这种情况下,你确实应该验证你的程序是否像你想象的那样,在许多不同的条件下,那样的话你需要一些主动(proactive)的测试。

你的程序仅仅在某个特定等等输入下工作,不意味着他能在所有条件下工作。

Software verification

(a.k.a. software testing) 是决定你的程序是否能在所有条件下按照期望运行的一个程序。

测试挑战(testing chanllenge)

在我们开始讲一些确切的方法来测试你的代码之前,让我们讨论一下为什么完全测试是困难的。

思考一下这个简单的程序:

#include <iostream>
 
void compare(int x, int y)
{
 if (x > y)
  std::cout << x << " is greater than " << y << '\n'; // case 1
 else if (x < y)
  std::cout << x << " is less than " << y << '\n'; // case 2
 else
  std::cout << x << " is equal to " << y << '\n'; // case 3
}
 
int main()
{
 std::cout << "Enter a number: ";
 int x;
 std::cin >> x;
 
 std::cout << "Enter another number: ";
 int y;
 std::cin >> y;
  
  compare(x, y);
}

假设一个4字节的整型变量,明确地使用每一个可能的输入来测试这个程序将会要求你运行这个程序18,446,744,073,709,551,616次。可以那并不是一个容易的工作!

每一次我们向用户要求输入,或者在代码中有一个附加条件,都会成为一些因子增加程序可能执行的方式。对于所单的程序,想要显式地(explicity)测试所有输入组合是站不住脚的。

现在你的直觉应该告诉你,你没必要运行上方程序18 quintillion 次来确保它能正常工作。你可以归纳为,如果程序在某一对x,y | x > y正常工作,那么它应该在任何一对xy | x > y时工作。有了这样的假设之后,我们很显然只需要运行3次(每个分支运行一次)来确保程序可以如期工作。也有一些其他相似的技巧,我们可以用来显著减少运行测试的次数。为了使得测试的可实施性。

有非常多的测试方式——事实上,我们可以花一整章节来讲测试。但是由于它不是C++中明确的主题。我们将坚持做一个简短的非正式的介绍,覆盖了你作为程序员可以用来测试你自己的代码。在接下来的几项中,我们将会讨论一些你应该思考的实际的事情,当你测试你的代码时。

如何测试你的代码:非正式测试

大多数程序员做非正式测试,当他们写程序上时。在写了一个代码单元(一个函数,一个类,或者其他独立的代码块,包)后,程序员将会写一个单元测试来测试刚刚编写的代码,然后紧接着删除这个测试,当测试通过后。例如,为下方的isLowerVowel()函数,你可能会写如下的代码:

#include <iostream>
 
bool isLowerVowel(char c)
{
    switch (c)
    {
    case 'a':
    case 'e':
    case 'i':
    case 'o':
    case 'u':
        return true;
    default:
        return false;
    }
}
 
int main()
{
    std::cout << isLowerVowel('a'); // temporary test code, should produce 1
    std::cout << isLowerVowel('q'); // temporary test code, should produce 0
 
    return 0;
}

如果结果返回为1和0,那么你已经测试完成了。你知道你的函数起作用了,因此你可以删除那段测试代码,并且继续编程。

测试小贴士#1:以小的、良好定义(well defined)单元函数为单位编程,并且编译以以同样的方式

想象一个自动制造机器正在构建一个定制的概念车辆。你认为他们会采用以下哪个方式?

a) 单独构建(或者购买)并且测试车辆的每个组件在安装之前,一旦组件正常工作了,组合成为一辆车并且重新测试它来确保整体正常工作。在最后,测试整辆车,最后所有的看起来都很棒。

b) 直接将所有的组件构造成一辆车,然后测试整辆车直到首次正确运行。

看起来似乎很明显,选择a)是更好的选择,并且现在,许多新编程的人想b)那样写代码!

在 b) 这种情况下,如果车辆的任何一个部分不按照预期共工作,技术员将会不得不诊断整辆车,来确定到底哪儿错了。然而问题可能出在任何位置。同一个症状(symptom)可能会有许多造成的原因。——例如,这辆车无法启动是因为错误的火花塞、电池、燃料泵或者其他任何其他情况。这样会浪费不少时间来确定问题究竟在哪里,究竟发生了什么。并且如果一个问题被发现了,结果可能是灾难性的——一个区域中某处的改变可能会造成涟漪效应(ripple effects),在其他的地方。例如,一个燃料泵太小可能会硬气整个引擎重新设计,可能会重新设计整个车辆架构。在这个错误的情况下,你可能最终会重新设计整个巨大的车,仅仅因为期初的一个小问题。

在 a) 这种情况下,公司开始就进行测试,一旦发现任何组件无法使用,他们将会立刻知道并且修复/替换它。没有任何组件会集成进整体,直到他能保证正常运作。到需要运行整个车辆时,他们应该能非常有自信保证整个车辆将会工作——毕竟,所有的局部组件都经过了测试,尽管仍然有可能一些错误将会发生当连接所有模块的时候,但是这需要调试和潜在有问题的点将会少的多。

上面的例子在程序中也适用,经管应为一些原因,新手程序员通常不会意识到这一点。你最好写小的函数,并且立即编译和测试它们。以这样的方式,你将会知道问题一定在很小的,你改动过的范围内距离上次编译/测试。那意味仅仅需要检查非常少的地方,并且花费更少的时间去debug。

**规则:**经常编译,并且测试任何的函数,当你编写他们的时候。

测试小贴士#2:目标为100%的语句覆盖率

**语句覆盖率(statement coverage)**这个术语指程序中的语句在测试中已经检验的占比。

思考如下函数

int foo(int x, int y)
{
    bool z = y;
    if (x > y)
    {
        z = x;
    }
    return z;
}

调用这个函数foo(1,0)将会给你完整的语句覆盖率,对这个函数来讲,函数中每一个语句都会被检验。

对于 isLowerVowel()函数来讲

bool isLowerVowel(char c)
{
    switch (c) // statement 1
    {
    case 'a':
    case 'e':
    case 'i':
    case 'o':
    case 'u':
        return true; // statement 2
    default:
        return false; // statement 3
    }
}

这个函数将会要求两次调用来测试所有的分支,因为没有一个方式可以经过两个语句,在一次调用中。

**规则:**确保你的测试集中每一个函数中的语句。

测试小贴士#3:目标为100%分支覆盖率

分支覆盖率指经过的分支所占总分支的百分比,分别通过正向(affirmative)测试和反向(negative)测试。一个if语句会带来两个分支——一个是true分支一个是false分支(尽管也许没有相符的语句去执行),一个switch可以由许多个分支。

int foo(int x, int y)
{
    bool z = y;
    if (x > y)
    {
        z = x;
    }
    return z;
}

之前的foo(1,0)提供了100%的语句通过率,并且检验了正向测试用例,但是那仅仅给我们50%的分支覆盖率。我们需要再调用一次foo(0,1)来测试这个测试用例,当测试if内的语句没有执行。

bool isLowerVowel(char c)
{
    switch (c)
    {
    case 'a':
    case 'e':
    case 'i':
    case 'o':
    case 'u':
        return true;
    default:
        return false;
    }
}

isLowerVowel()这个函数中,两次调用例如isLowerVowel('a')isLowerVowel('q')是必须的,来提供100%的分支覆盖率(不同的情况但是进入同一个语句块之无需分别测试——如果一个起作用,他们全都将起作用)

重新查看上方这个比较函数:

void compare(int x, int y)
{
 if (x > y)
  std::cout << x << " is greater than " << y << '\n'; // case 1
 else if (x < y)
  std::cout << x << " is less than " << y << '\n'; // case 2
 else
  std::cout << x << " is equal to " << y << '\n'; // case 3
}

需要3次调用才能达到100的分支覆盖率,compare(1,0)测试正向用例,对第一个if语句来说。compare(0,1)测试了第一个if语句的反向测试用例和第二个if语句的正向测试用例。compare(0,0)测试了第二个语句的反向测试用例,和else语句的测试。这样,我们可以说这个函数是可测试的,通过三次调用(而不是18quintillion次)。

**规则:**测试每一个分支让他们至少分别true一次,和false一次。

测试小贴士#4:目标是100%循环覆盖

循环测试(有时候被称为”the 0, 1, 2 test“),是说如果你在代码中含有分支,你应该确保他能够正确的工作,无论是迭代0次,1次还是两次。如果它能正确的工作在2次迭代的样例下,他应该能正确的再2次以上的迭代次数。这三次测试因此能够覆盖所有的可能性(因为循环不能在条件为负数的情况下执行)。

思考:

#include <iostream>
int spam(int timesToPrint)
{
    for (int count=0; count < timesToPrint; ++count)
         std::cout << "Spam!!!";
}

为了测试函数中的循环,你应该调用它三次,sapm(0)来测试0次迭代样例,sapm(1)来测试一次迭代样例,然后sapm(2)来测试两次迭代样例,如果spam(2)正确的工作量,那么sapm(n)也应该是正确的工作当n>2时。

**规则:**使用0,1,2去测试保证你代码能在不同的工作迭代次数下正确的工作。

测试小特使#5:确保你会测试不同的类型的输入

当你写接收参数的函数时,或者当接收用户输入时,思考输入不同类型的会发生什么。在这种条件下,我们用术语**”类型“**来表示含有相似字符的输入集合。

例如,如果我写了一个函数去计算一个整数的平方根,我们应该用什么样例进行测试呢?你应该开始使用一些普通值例如,4。但是使用0和负数测试也是个不错的注意。

这里有一些基础类型测试的指导方案:

对于整型,确保你的你已经思考过你的函数将会如何处理负整型、0和正整型。对于用户输入来说,你应该也检查溢出,如果和那相关的话。

对于浮点数来说,确保你已经思考过你的函数在处理不同精确度问题(值会轻微的比预期大一些或小一些)。不错的测试值是0.1和-0.1(用来测试数字比预期稍微的大),或者-0.6和-0.6来(来测试数字比预期稍微的大)。

对于字符串来说,确保你已经考虑过,你的程序将会如何对待空字符串(仅仅一个null终止符),普通的非法字符串,带有空格的字符串,全是空格的字符串。如果你的函数传入了一个char类型的指针,也不要忘了去测试pullptr(如果你不理解这句话,因为你到目前位置还没学过)。

**规则:**测试不同类型的输入值来确保你的代码单元能处理他们。

如何测试你的代码:保存你的测试代码

编写测试代码并擦除他们,尽管已经足够应付临时测试了,但对于那些你期望在未来也能复用或者修改的,最好还是保存测试代码以便于你可以在未来运行他们。例如,你应该写一个test()函数,而不是编写后又擦除他们:

#include <iostream>
 
bool isLowerVowel(char c)
{
    switch (c)
    {
    case 'a':
    case 'e':
    case 'i':
    case 'o':
    case 'u':
        return true;
    default:
        return false;
    }
}
 
// not called from anywhere right now
// but here if you want to retest things later
void test()
{
    std::cout << isLowerVowel('a'); // temporary test code, should produce 1
    std::cout << isLowerVowel('q'); // temporary test code, should produce 0
}
 
int main()
{
    return 0;
}

如何测试你的代码:自动测试函数

上方测试函数要实现测试赖于你手动验证答案当你运行它时。你可以做的更好,通过编写一个函数包含测试样例和期待答案值。

#include <iostream>
 
bool isLowerVowel(char c)
{
    switch (c)
    {
    case 'a':
    case 'e':
    case 'i':
    case 'o':
    case 'u':
        return true;
    default:
        return false;
    }
}
 
// returns the number of the test that failed, or 0 if all tests passed
int test()
{
    if (isLowerVowel('a') != true) return 1;
    if (isLowerVowel('q') != false) return 2;
 
    return 0;
}
 
int main()
{
    return 0;
}

现在,你可以调用test()任何时候来重新验证你没有破坏任何事情,测试例程将会为你做所有工作。这尤其有用,当你回来修改老的代码时,确保你没有不小心破坏任何东西!

提问时间:

  1. 什么时候开始测试?

当你已经开始写一个简单的函数时

  1. 什么是分支覆盖?

分支覆盖是已经分别经过正向和反向测试样例检验的分支的占比。

  1. 如下的方程需要至少经过几次运行才能确保他正常工作?
bool isLowerVowel(char c, bool yIsVowel)
{
    switch (c)
    {
    case 'a':
    case 'e':
    case 'i':
    case 'o':
    case 'u':
        return true;
    case 'y':
        return yIsVowel;
    default:
        return false;
    }
}

4 次是最优的,一次会检验a/e/i/o/u case。一次会检验default case。一次会用来测试 isLowerVowel(‘y’, true),还有一次会用来检验isLowerVowel(‘y’, false)

原文地址:https://www.learncpp.com/cpp-tutorial/5-11-introduction-to-testing-your-code/

本文使用 mdnice 排版