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

假设使用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
你应该在什么时候开始测试代码?
显示答案
只要你写了一个非平凡的函数。

浙公网安备 33010602011771号