代码测试入门

你已经写完了一段程序,它编译通过,看起来也能运行!接下来该怎么办?

答案取决于具体情况。如果你的程序只需一次性运行,随后便可弃置,那么你已代码覆盖率经大功告成。在这种场景下,程序未必需要对所有情况均正确——只要对那一次所需的输入正确即可。

倘若程序完全线性(没有任何条件语句如 if 或 switch),不接受任何输入,且输出结果正确,那么通常也可以宣告完成。此时,你已通过一次运行并验证输出来完成了整个程序的测试。你或许还应在若干不同的系统上编译并运行程序,以确保其行为一致(若结果不一致,往往意味着代码中存在未定义行为,而你的初始系统恰好“蒙对了”)。

然而,绝大多数情况下,你写的程序需要多次运行,包含循环与条件逻辑,并接受某种形式的用户输入。你可能还编写了将来可复用的函数或类。随着需求蔓延,你或许又添加了一些原本未计划的功能,甚至可能打算将程序分发给他人(他们往往会尝试你未曾设想的用法)。在这种情况下,你确实应当验证程序在各种条件下均如预期般工作——这就必须主动进行测试。

程序在某一组输入下运行无误,并不能保证其在所有情况下都正确。

软件测试(又称软件验证)即判定软件是否按预期工作的过程。

测试之难

在讨论具体的测试方法之前,先说明为何全面测试程序十分困难。

以下列简单程序为例:

#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 字节整型,如要穷举所有输入组合,需运行程序 18,446,744,073,709,551,616(约 18 百亿亿)次。显然,这根本不现实!

每增加一次用户输入,或每引入一个条件分支,程序可能的执行路径便以乘法方式激增。除最简单程序外,穷举测试所有输入组合几乎立刻变得不可行。

直觉告诉我们,其实并不需要运行 18 百亿亿次。你或许可以合理推断:只要在某一对 x、y 满足 x > y 时情况 1 正确,则对所有 x > y 的组合都应正确。由此可见,我们大概只需运行三次(分别触发 compare() 中的三种情况)即可对程序的正确性抱有足够信心。类似技巧可显著减少所需测试次数,使测试工作变得可控。

测试方法论可长篇累牍——完全可为此另写一章。但因其并非 C++ 特有,下文仅从开发者自测角度作简要而非正式的介绍。接下来几小节将阐述测试代码时应考虑的若干实务事项。

分块测试程序

设想一家汽车制造商正在打造一辆定制概念车。你认为他们会:
a) 先单独构建(或采购)并测试每个零部件,确认无误后再集成到整车,并再次测试以验证集成成功;最终整车装配完毕后,再进行整体校验,确保一切正常。
b) 一次性把所有零部件组装成整车,直到最后再一次性测试。

显然 a) 更合理。然而,许多初学者却按 b) 的方式写代码!

在情形 b) 中,一旦某零部件异常,技师需要排查整辆车才能定位问题——症状可能对应多种原因:无法启动究竟是火花塞、蓄电池、燃油泵,还是其他部件故障?这将浪费大量时间定位问题,且后果可能严重——小修改引发连锁反应。例如,燃油泵尺寸不足或导致发动机重新设计,进而牵动车架变更;最坏情况下,为迁就一个初始小缺陷,可能需要重新设计整车大半部分!

在情形 a) 中,企业边做边测。任何零部件一旦有问题即可立即发现、修复或替换;未经单体验证的部件绝不集成上车,且每完成一步集成便随即重测。如此,可尽早发现意外问题,趁其尚小、易改。

整车最终装配完成时,企业已有充分信心认为整车可正常运行——毕竟所有零部件均已单独及集成验证。虽仍可能在最后阶段发现新问题,但前期测试已将风险降至最低。

该类比同样适用于程序开发,尽管许多初学者并未意识到这一点。你应把代码拆分为若干小函数(或类),编写后即刻编译测试。如此,一旦出错,必定位在最近修改的少量代码中,搜索范围小,调试时间亦大幅缩短。

将代码的某一小部分隔离测试,以确认该“单元”行为正确,称为单元测试。每个单元测试旨在验证该单元的某特定行为符合预期。

最佳实践

以小型、定义清晰的单元(函数或类)为单位编写程序;频繁编译,并边写边测。

若程序较短且接受用户输入,尝试多种输入或许足够;但随着程序规模增长,仅靠整体运行越发不足,预先单独测试函数或类的价值便愈加显著。

如何分块测试代码?

非正式测试

一种做法是在编写程序时同步进行非正式测试。每完成一个代码单元(函数、类或其他离散“包”),即编写若干测试代码验证之,待测试通过后,再删除这些测试代码。例如,对以下 isLowerVowel() 函数,你可写:

#include <iostream>

// 待测函数
// 为简化,忽略 'y' 有时也算元音的情形
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,则函数通过基本测试。观察代码亦可合理推断未直接测试的 ’e’、‘i’、‘o’、‘u’ 也应无误。于是可删除这些临时测试代码,继续后续开发。

保留测试用例

尽管临时测试快捷方便,但日后你可能需再次测试同一代码。例如,修改函数以新增功能时,希望确保旧功能未被破坏。因此,将测试用例保留以备将来重测更为可取。与其删除,不如将测试移入 testVowel() 函数:

#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()。

自动化测试函数

上述 testVowel() 仍需人工核验结果,至少须记住期望值(若未注释),再比对实际输出。

我们可改进为:测试函数同时包含测试输入与期望结果,并自动比较,无需人工干预。

#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() 测试 " << result << " 失败。\n";
    else
        std::cout << "testVowel() 全部通过。\n";

    return 0;
}

如此,你可随时调用 testVowel() 以再次确认未破坏旧功能。测试例程自动给出“全部通过”或指出失败编号,便于定位问题。尤其适用于回溯修改旧代码时的回归测试。

进阶读者

更优方式是使用 assert,一旦测试失败即终止程序并打印错误信息,无需手动管理测试编号。

#include <cassert> // for assert
#include <cstdlib> // for std::abort
#include <iostream>

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

// 任何测试失败即终止程序
int testVowel()
{
#ifdef NDEBUG
    // 若定义 NDEBUG,则 assert 被编译器忽略。
    // 本函数依赖 assert,故若 NDEBUG 已定义,则直接退出。
    std::cerr << "Tests run with NDEBUG defined (asserts compiled out)";
    std::abort();
#endif

    assert(isLowerVowel('a'));
    assert(isLowerVowel('e'));
    assert(isLowerVowel('i'));
    assert(isLowerVowel('o'));
    assert(isLowerVowel('u'));
    assert(!isLowerVowel('b'));
    assert(!isLowerVowel('q'));
    assert(!isLowerVowel('y'));
    assert(!isLowerVowel('z'));

    return 0;
}

int main()
{
    testVowel();

    // 若执行至此,说明全部测试通过
    std::cout << "All tests succeeded\n";

    return 0;
}

关于 assert 的详细内容,请参见第 9.6 课——Assert 与 static_assert。

单元测试框架

由于编写函数来测试其他函数极为常见且有用,业界已出现一整套框架(称为单元测试框架),旨在简化单元测试的编写、维护与执行。因涉及第三方软件,此处不展开,但你应知晓其存在。

集成测试

当每个单元均已独立测试后,即可将其集成至程序并再次测试,以确保集成无误,此过程称为集成测试。集成测试通常更为复杂;现阶段,只需运行程序若干次并抽查集成单元的行为即可。

测验

问题 1

你应在何时开始测试代码?

关注公众号,回复"cpp-tutorial"

可领取价值199元的C++学习资料

公众号二维码

扫描上方二维码或搜索"cpp-tutorial"