代码测试入门

2025-12-24 12:36:00

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

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

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

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

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

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

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

以下列简单程序为例:

#include

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

// 待测函数

// 为简化,忽略 '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

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

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 // for assert

#include // for std::abort

#include

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 的详细内容,请参见——Assert 与 static_assert。

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

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

测验问题 1你应在何时开始测试代码?