9-1 代码测试介绍

所以,你写了一个程序,它能编译通过,甚至看起来还能运行!接下来呢?

这要看情况。如果你写的是只运行一次就丢弃的程序,那你就大功告成了。这种情况下,程序未必适用于所有场景——只要它能满足你需要的那个场景,而且你只打算运行一次,那就足够了。

如果你的程序完全线性(不含if或switch等条件语句),不接受任何输入,且能输出正确结果,那么你大概已经完成了。这种情况下,通过运行程序并验证输出,你实际上已经完成了对整个程序的测试。你或许需要在不同系统上编译运行程序以确保行为一致(若出现异常,很可能存在未定义行为的代码,只是在初始系统上偶然生效)。

但更常见的情况是:你编写的程序需要多次运行,包含循环和条件逻辑,并接受某种用户输入。你可能还编写了可复用在未来其他程序中的函数。你可能经历过功能范围蔓延scope creep,即添加了最初未规划的新功能。甚至可能计划将程序分发给他人(他们很可能尝试你未曾考虑过的操作)。这种情况下,你必须验证程序在各种条件下是否如预期运行——这需要主动进行测试。

程序在特定输入下运行正常,并不意味着它在所有情况下都能正确工作。

软件测试software testing(也称软件验证software validation)就是验证软件是否真正按预期运行的过程。


测试的挑战

在探讨具体测试代码的方法之前,我们先来分析为何全面测试程序如此困难。

请看这个简单的程序:

#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;
}

image

假设使用4字节整数,要显式测试该程序所有可能的输入组合,需要运行程序18,446,744,073,709,551,616次(约18京次)。显然这是不可行的任务!

每次我们请求用户输入,或在代码中设置条件语句时,程序的执行路径就会以某种乘法因子倍增。对于除最简单程序之外的所有程序,显式测试所有输入组合几乎立即变得不可能。

此时你的直觉应该告诉你:根本无需运行18京次来验证程序正确性。你可以合理推断:若案例1对某个满足x>y的x,y值有效,则对所有满足x>y的x,y组合都应有效。由此可见,我们实际上只需运行该程序约三次(分别验证compare()函数中的三种情况),就能高度确信其按预期工作。类似技巧还有很多,能大幅减少测试次数,使测试工作变得可控。

关于测试方法论可探讨的内容甚多——事实上足以撰写整章论述。但鉴于此非C++特有的主题,我们将聚焦于开发者测试自身代码的视角,进行简要非正式的介绍。在接下来的几个小节中,我们将探讨测试代码时应考虑的若干实用要点。


将程序拆解为小块进行测试

假设某汽车制造商正在打造一款定制概念车。你认为他们会采取以下哪种做法?

a) 在安装前单独制造(或采购)并测试每个汽车部件。当部件功能得到验证后,将其集成到车内并重新测试以确保集成正常。最后对整车进行测试,作为最终验证以确认一切正常。

b) 将所有部件一次性组装成整车,直至最后才进行首次整体测试。

显然选项a)更合理。然而许多新手程序员却像选项b)那样编写代码!

在方案b)中,若任何汽车部件出现故障,维修人员必须对整车进行诊断才能定位问题——故障点可能存在于任何环节。单一症状可能由多种原因引发——例如汽车无法启动是火花塞故障、蓄电池问题、燃油泵故障还是其他原因?这将导致大量时间浪费在精准定位故障点及制定解决方案上。一旦发现问题,后果可能灾难性——某处的改动可能引发多处连锁反应(变化)。例如燃油泵容量不足可能导致发动机重新设计,进而引发车架重新设计。最糟糕的情况是,为解决最初的小问题,最终可能需要重新设计整车的大部分结构!

方案a)中,公司采取边测试边推进的策略。若任何部件开箱即出现故障,他们能立即发现并修复/更换。所有部件在独立验证正常运作前不会集成到整车,且集成后会立即重新测试。这样能尽早发现意外问题,此时问题尚小且易于解决。

待整车组装完成时,厂商对车辆的可靠性应有充分信心——毕竟所有部件都经过独立测试和初始集成验证。此时虽仍可能出现意外问题,但前期测试已将风险降至最低。

上述类比同样适用于程序开发,但不知为何,新手程序员常忽视这一点。更明智的做法是编写小型函数(或类),编译后立即测试。这样一旦出错,就能锁定在最近编译/测试后修改的少量代码中,大大缩小排查范围,显著减少调试时间。

将代码分割为独立单元进行测试以验证其正确性,称为单元测试unit testing。每个单元测试unit test都旨在确保该单元特定行为的正确性。

最佳实践
将程序拆分为小而明确的单元(函数或类),频繁编译,并在编写过程中持续测试代码。

如果程序较短且接受用户输入,尝试多种用户输入可能就足够了。但随着程序越来越复杂,这种方法就显得不够充分,在将函数或类集成到程序主体之前进行单独测试更有价值。

那么我们该如何对代码进行单元测试呢?


非正式测试

测试代码的一种方式是在编写程序时进行非正式测试。完成一段代码单元(函数、类或其他离散的代码“包”)后,可编写测试代码验证该单元功能,待测试通过后再删除测试代码。例如,针对下面的isLowerVowel()函数,可编写如下测试代码:

#include <iostream>

// We want to test the following function
// For simplicity, we'll ignore that 'y' is sometimes counted as a vowel
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') << '\n'; // temporary test code, should produce 1
    std::cout << isLowerVowel('q') << '\n'; // temporary test code, should produce 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;
    }
}

// Not called from anywhere right now
// But here if you want to retest things later
void testVowel()
{
    std::cout << isLowerVowel('a') << '\n'; // temporary test code, should produce 1
    std::cout << isLowerVowel('q') << '\n'; // temporary test code, should produce 0
}

int main()
{
    return 0;
}

随着你创建更多测试,只需将它们添加到 testVowel() 函数中即可。


自动化测试函数

上述测试函数存在一个问题:运行时需要手动验证结果。这要求您记住最坏情况下的预期答案(假设未记录),并手动将实际结果与预期结果进行比对。

我们可以通过编写包含测试用例和预期答案的测试函数来改进流程,让系统自动完成比对,从而省去人工操作。

#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')) 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),或返回未通过测试的测试编号,以便您排查故障原因。当需要回溯修改旧代码时,此功能尤为实用,可确保您不会意外破坏原有功能!

对于进阶读者

更优的方法是使用断言(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;
    }
}

// Program will halt on any failed test case
int testVowel()
{
#ifdef NDEBUG
    // If NDEBUG is defined, asserts are compiled out.
    // Since this function requires asserts to not be compiled out, we'll terminate the program if this function is called when NDEBUG is defined.
    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();

    // If we reached here, all tests must have passed
    std::cout << "All tests succeeded\n";

    return 0;
}

我们在第9.6节——assert和static_assert中讲解assert。


单元测试框架

由于编写函数来调用其他函数是如此常见且实用,因此存在专门设计的完整框架(称为单元测试框架unit testing frameworks),旨在简化单元测试的编写、维护和执行过程。由于这些涉及第三方软件,本文将不作详细介绍,但您应当了解它们的存在。


集成测试

当每个单元都经过独立测试后,即可将其整合到程序中并重新测试,以确保集成正确。这被称为集成测试。集成测试通常更为复杂——目前阶段,只需运行程序数次并抽查集成单元的行为即可。


测验时间

问题 #1

你应该在什么时候开始测试代码?

显示答案

只要你写了一个非平凡的函数。
posted @ 2026-03-01 09:52  游翔  阅读(1)  评论(0)    收藏  举报