Skip to content

7.12 - 代码测试

Key Takeaway

当程序可以编译,甚至可以运行后,还有什么工作需要做呢?

这要看情况。如果该程序只运行一次并被丢弃,那么不需要做其他事情了。在这种情况下,你的程序是否能适用于所有情况可能并不重要——如果它能满足你当前任务的需要,并且你也只需要运行它一次,那么任务就已经完成了。

如果你的程序完全是线性的(没有条件,如“If语句”或“switch语句”),不接受输入,并产生正确的答案,那么你就完成了。在本例中,你已经通过运行和验证输出测试了整个程序。

但更有可能的情况是,你的程序需要被多次运行,而且该程序使用循环和条件逻辑,能够接受某种类型的用户输入。你可能已经编写了可以在将来的其他程序中重用的函数,也可能经添加了一些最初没有计划的新功能。也许还打算将这个程序分发给其他人(用户可能会做出你意料之外的操作)。在这种情况下,我们必须验证程序在各种各样的条件下都能正确工作——这需要测试来保证。

仅仅因为你的程序适用于一组输入并不意味着它在所有情况下都能正确工作。

软件验证(也称为软件测试)是确定软件在所有情况下是否按预期工作的过程。

有关测试的难题

在讨论测试代码的实用方法之前,让我们先谈谈为什么全面测试程序并不容易。

考虑这个简单的程序:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#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);

    return 0;
}

假设有一个4字节的整数,用每种可能的输入组合显式地测试这个程序将需要运行程序18,446,744,073,709,551,616次(~18亿亿次)。显然这是不可行的。

当程序要求用户输入或者在代码中存在条件判断时,程序可以执行的方式的就相应地变多了。除了最简单的程序之外,几乎不可能显式地测试每种输入组合。

现在,直觉告诉我们,其实并不需要真的运行程序18亿亿次才能确保它可以正确工作。你可以合理地得出结论,如果情况1适用于一对 xy 值,其中 x > y ,那么它应该适用于任何一对满足 x>yxy 组合。鉴于此,程序实际上只需要运行这个大约三次(在函数 compare() 中分别运行一次),就可以高度确信它能按预期工作。为了使测试易于管理,我们还可以使用其他类似的技巧来大幅减少测试的次数。

关于测试方法有很多东西可以写——事实上,我们可以为此写一整章。但由于这不是C++教程应该关注的事情,所以我们只会简单介绍作为程序员应该如何测试自己的代码。在接下来的几个小节中,我们将讨论一些在测试代码时应该考虑的实际问题。

化整为零进行测试

假设你是一家汽车制造商,正在制造一辆客户概念车。那么你会怎么做呢? a) 在组装前制造 (或购买) 并测试各个零部件,如果零部件可以正常工作,则将其组装到汽车上,确保仍然可以正常工作。最后,对整车进行测试,确保所有部分都可以正常工作。 b) 用所有的部件一气呵成地造出一辆车,然后在最后对整个部件进行第一次测试。

很明显选项a)是更好的选择。然而,许多新程序员会按照 b) 描述的方法编写代码!

在b)情况下,如果汽车中有任何部件没有按照预期工作,工程师就必须对整辆车进行诊断,以确定哪里出了问题——问题可能在任何地方。一个症状可能有很多原因——例如,由于火花塞、电池、燃油泵或其他原因,汽车无法启动?这无疑会浪费大量时间。如果发现了问题,后果可能是灾难性的——一处修改可能会在其他多个地方引起“涟漪效应”(变化)。例如,燃油泵太小可能会导致发动机重新设计,从而导致汽车框架的重新设计。在最坏的情况下,你可能会重新设计汽车的一个很大的部分,只是为了适应最初的一个小问题!很明显选项a)是更好的选择。然而,许多新程序员编写类似选项b)的代码!

对于 a) 的情况,汽车公司一边组装一边测试。如果任何零件是坏的,他们会立即知道,并能修复/更换它。任何东西都不会被集成到汽车中,直到它被证明可以工作,然后一旦它被集成到汽车中,就会再次进行测试。这样,任何意外的问题都可以尽早发现,而它们仍然是可以轻松解决的小问题。

开始组装整辆车的时候,制造商应该对汽车能够正常工作有合理的信心——毕竟,所有的部件都已经单独测试过了,并且在初始集成时也进行了测试。在这一点上仍然有可能发现意外的问题,但是通过所有先前的测试将风险降到最低。

上面的类比也适用于程序,尽管由于某种原因,新程序员经常没有意识到这一点。最好是编写小函数(或类),然后立即编译和测试它们。这样,如果你犯了错误就会知道它一定是在上次编译/测试后更改的少量代码中。这意味着要查看的地方更少,花在调试上的时间也更少。

单独测试一小部分代码,以确保代码的“单元”是正确的,这称为单元测试。每个单元测试都被设计用来确保单元的特定行为是正确的。

最佳实践

用定义良好的小单元(函数或类)编写程序,频繁编译,并在运行时测试代码。

如果程序很短并且接受用户输入,那么尝试各种用户输入可能就足够了。但是随着程序变得越来越长,这就不够了,在将单个函数或类集成到程序的其余部分之前就对它们进行测试效果会更好。

那么我们如何以单位来测试代码呢?

非正式测试

测试代码的一种方法是在编写程序时进行非正式测试。在编写了一个代码单元(一个函数、一个类或其他一些离散的“包”代码)之后,可以编写一些代码来测试刚刚添加的单元,然后在测试通过后删除测试。例如,对于下面的isLowerVowel()函数,则可以编写以下代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include <iostream>

// We want to test the following function
bool isLowerVowel(char c)
{
    switch (c)
    {
    case 'a':
    case 'e':
    case 'i':
    case 'o':
    case 'u':
        return true;
    default:
        return false;
    }
}

int main()
{
    // So here's our temporary tests to validate it works
    std::cout << isLowerVowel('a'); // temporary test code, should produce 1
    std::cout << isLowerVowel('q'); // temporary test code, should produce 0

    return 0;
}

如果返回的结果是“1”和“0”,那么程序就是正确的。你的函数适用于一些基本情况,随后你可以通过查看代码合理地推断它也适用于那些尚未测试的情况(eiou )。因此,你可以删除临时测试代码,并继续编程。

保存测试

尽管编写临时的测试代码可以简单快速地测试程序,但如果稍后还需要测试程序的话,就不能依赖这些临时的测试代码了。当我们修改了一个函数并添加新的功能后,往往希望确保没有破坏程序的其他部分。出于这个原因,将测试代码保存起来以便将来可以再次运行是更加有意义的做法。例如,你可以将测试移动到 testVowel() 函数中,而不是删除你的临时测试代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#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 testVowel()
{
    std::cout << isLowerVowel('a'); // temporary test code, should produce 1
    std::cout << isLowerVowel('q'); // temporary test code, should produce 0
}

int main()
{
    return 0;
}

在创建更多测试时,可以将它们添加到 testVowel() 函数中。

自动化测试

上述测试函数的一个问题是,它依赖于你在运行时手动验证结果。这要求你要记住期望的结果是什么(假设您没有记录它),并手动将实际结果与预期结果进行比较。

我们可以通过编写一个包含测试和预期答案的测试函数来做得更好,这样我们就不必对它们进行人工比较了。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#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 testVowel()
{
    if (isLowerVowel('a') != true) return 1;
    if (isLowerVowel('q') != false) return 2;

    return 0;
}

int main()
{
    return 0;
}

这样一来,你就可以在任何时候调用 testVowel() 函数来测试程序,确保它始终能够正确运行。测试代码可以自动帮你测试所有用例,返回“all good”(返回值0),或者返回失败用例的序号。这样的测试在我们修改老代码时尤为重要,因为谁也不希望无意间破坏程序原本正常的功能!

单元测试框架

因为编写函数来执行其他函数是非常常见和有用的需求,所以有一些完整的框架(称为单元测试框架)旨在帮助简化编写、维护和执行单元测试的过程。因为这些涉及到第三方软件,所以我们在这里不做介绍,但是你应该知道它们的存在。

集成测试

一旦程序的每个单元都单独测试过,它们就可以被集成到你的程序中,并重新测试,以确保它们被正确地集成了。这就是所谓的集成测试。集成测试往往更加复杂——目前,运行几次程序并抽检集成单元的行为就足够了。