章节目录

代码测试简介

本节阅读量:

现在,您已经编写了一个程序,它可以编译,甚至看起来可以工作!现在怎么办?

嗯,这要看情况。如果您编写的是只运行一次、用完就丢弃的程序,那么工作基本就算完成了。在这种情况下,程序不能在所有情况下都正常工作可能并不重要——如果它能在您需要的那一种情况下工作,并且只运行一次,那么事情就结束了。

如果您的程序是完全线性的(没有条件判断,例如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'; // 情况 1
    else if (x < y)
        std::cout << x << " is less than " << y << '\n'; // 情况 2
    else
        std::cout << x << " is equal to " << y << '\n'; // 情况 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字节整数,如果使用每一种可能的输入组合来显式测试该程序,就需要运行程序18446744073709551616(约1.8千亿亿)次。显然,这不是一项可行的任务!

每增加一次用户输入,或每增加一个条件,程序可能的输入组合都会按指数级增长。除了最简单的程序之外,几乎不可能显式测试每一种输入组合。

现在,直觉应该告诉我们,真的不需要运行上面的程序1.8千亿亿次才能确认它能工作。我们可以合理地得出结论:如果情况1适用于一对满足x > y的x和y值,那么它也应该适用于任意满足x > y的一对x和y。考虑到这一点,很明显,只需要运行该程序大约三次(分别验证compare()函数中的三种情况),就可以获得较高的信心,确认它能按预期工作。我们还可以使用其他类似技巧,显著减少必须测试的次数,从而让测试变得可管理。

关于测试方法有很多内容可写——事实上,完全可以写一整章来讨论它。但由于它不是C++特有的主题,因此这里只做一个简短的非正式介绍,并从开发人员测试自己代码的角度进行讲解。在接下来的几个小节中,将讨论测试代码时应该考虑的一些实际问题。


在小代码段中测试程序

考虑一家正在制造定制概念车的汽车制造商。你认为他们会选择哪种做法?a)在安装之前,单独构建(或购买)并测试每个汽车组件。一旦组件被证明可以工作,就将其集成到汽车中并重新测试,以确保集成正常。最后,测试整辆汽车,作为最终验证。b)一次性用所有部件造出一辆汽车,然后才第一次测试整车。

很明显,选项a是更好的选择。然而,许多新程序员编写代码的方式更像选项b!

在案例b中,如果任何汽车部件不能按预期工作,就不得不诊断整辆汽车,以确定问题在哪里——但问题可能出现在任何地方。相同症状也可能有许多原因:例如,汽车无法启动,是因为火花塞、蓄电池、燃油泵,还是其他部件出了故障?这会浪费大量时间来定位问题并决定如何处理。发现问题后的后果也可能很严重——一个地方的变化可能会在多个其他地方造成“连锁反应”。例如,燃油泵太小可能导致发动机需要重新设计,而发动机变化又可能导致车架重新设计。在最坏的情况下,最终可能为了适应最初的一个小问题,而重新设计汽车的很大一部分!

在案例a中,公司会在制造过程中持续测试。如果某个部件一开始就是坏的,就能立刻发现并修复或更换。在证明某个部件自身能够正常工作之前,不会把它集成到汽车中。确认正常后,再将该部件集成到汽车中,并立即重新测试。这样,任何意外问题都会尽早被发现,而且此时它们通常仍是容易修复的小问题。

当开始组装整辆汽车时,应该有充分理由相信汽车会正常工作——毕竟,所有部件都已经单独测试过,并且在最初集成时也测试过。此时仍然可能发现意外问题,但通过前面的所有测试,风险已经被降到了最低。

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

单独测试代码的一小部分,以确保这个代码“单元”是正确的,这称为单元测试(unit testing)。每个单元测试都旨在确保某个单元的特定行为是正确的。

如果程序很短并接受用户输入,那么尝试各种用户输入可能就足够了。但随着程序越来越长,这种方式就不够了。在将单个函数或类集成到程序其余部分之前测试它们,会更有价值。

那么我们如何在单元中测试代码呢?


非正式测试

测试代码的一种方法是在编写程序时进行非正式测试。在编写代码单元(函数、类或其他离散的代码“包”)后,可以编写一些代码来测试刚刚添加的单元,然后在测试通过后删除测试代码。例如,对于以下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>

// 想要测试这个函数
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') << '\n'; // 应该产出 1
    std::cout << isLowerVowel('q') << '\n'; // 应该产出 0

    return 0;
}

如果结果返回为1和0,那么就可以基本认定isLowerVowel没有问题。您知道您的函数适用于一些基本情况,并且可以通过查看代码合理地推断,它将适用于未测试的情况(“e”、“i”、“o”和“u”)。因此,可以删除该临时测试代码,并继续编程。


保留测试代码

尽管编写临时测试是测试某些代码的快速而简单的方法,但它没有考虑到后续可能还需要再次测试相同代码。也许您修改了一个函数来添加新功能,并希望确保没有破坏任何已经正常工作的功能。出于这个原因,保存测试会更合理,以便将来可以再次运行它们。例如,您可以将测试移动到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;
    }
}

// 不会被调用到
// 允许我们稍后可以再次使用
void testVowel()
{
    std::cout << isLowerVowel('a') << '\n'; // 临时测试代码,应该产出 1
    std::cout << isLowerVowel('q') << '\n'; // 临时测试代码,应该产出 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
31
32
33
34
35
36
#include <iostream>

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

// 如果对应的测试样例失败,返回对应的编号, 全部测试通过,返回0
int testVowel()
{
    if (!isLowerVowel('a')) return 1;
    if (isLowerVowel('q')) return 2;

    return 0;
}

int main()
{
    int result { testVowel() };
    if (result != 0)
        std::cout << "testVowel() test " << result << " failed.\n";
    else
        std::cout << "testVowel() tests passed.\n";

    return 0;
}

现在,您可以随时调用testVowel()来重新确认没有破坏任何东西。测试函数会为您完成所有工作,返回“一切正常”信号(返回值0)或未通过的测试编号。当测试未通过时,您可以明确知道原因。当返回并修改旧代码时,这特别有用,可以确保您没有意外破坏已有功能!


单元测试框架

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


集成测试

一旦每个单元都经过隔离测试,就可以将它们集成到程序中并重新测试,以确保它们正确协作。这称为集成测试。集成测试往往更复杂——目前,运行程序几次并抽查集成后的行为就足够了。


8.15 第八章总结

上一节

9.1 代码覆盖率

下一节