跳至内容
Topos Uranos

摘要

一个能编译并输出某种合理结果的程序,并不因此就算经过了测试。本课教人有方法地测试:从规格说明出发,按区域和边界推导出用例;把它们汇集到一个自动化测试程序中,由它统计失败并报告;用 assert 断言程序正确时不可能发生的事,用 static_assert 在编译时断言 constexpr 函数必须算出的结果;并在启用检测器的情况下运行测试,检测器能发现正确结果所掩盖的未定义行为。这一切都建立在一个告诫之上:测试可以表明错误的存在,却永远不能表明错误不存在。

学习目标

  1. 从函数的规格说明推导测试用例:其输入的划分、划分之间的边界和特殊情况,以及后置条件在每种情况下要求的结果。
  2. 编写一个自动化测试程序,把得到的结果与预期结果比较,报告每一次失败而不中止,最后输出一行总结,并以反映这一总结的退出码结束。
  3. 用 assert 检查前置条件和不变式,并解释为什么用 -DNDEBUG 编译时它会消失,以及为什么它的条件不能有副作用。
  4. 把 static_assert 与 constexpr 函数结合起来,在编译时测试结果,并在启用检测器的情况下运行测试,以发现结果所不能揭示的未定义行为。

什么是测试

测试一个程序,就是用选定的输入运行它,并把它的行为与它应有的行为相比较。每个测试用例由两部分组成:一个满足前置条件的输入,以及后置条件对该输入要求的结果,这个结果须事先通过另一条途径算出(手算、用另一个公式、查一张已知的表)。没有预期结果就没有测试,只有一次运行:看一眼输出,觉得它“合理”,那是一种印象,而不是一次比较。

然而,一个 int 参数的函数可以接受四十多亿个输入,两个参数的函数约有 1.8⋅10191.8 \cdot 10^{19} 对输入;任何一组测试都无法把它们全部走一遍。若是如此,测试只考察了可能情况中极小的一部分,即使全部通过,也推不出函数是正确的。艾兹格·迪杰斯特拉在 1970 年这样表述:测试可以表明错误的存在,却永远不能表明错误不存在。这正是《数理逻辑 I》课程在验证个别情形与证明之间所作的区分:验证一个公式对某些值为真,并不能证明它对所有值都为真,而一个证明(在本课程中,就是 设计正确的循环 一课中的不变式)一次就覆盖所有情形。测试不能取代这种推理:它们找出的是推理中遗漏的错误。

由此得出选择用例的准则。既然测试无法确认正确性,它的价值就在于发现错误,而好的测试用例是最有可能失败的那个。作者因为知道它会通过才选的用例,什么也说明不了。下面的程序用一个应当返回非空向量中最大元素的函数来说明这一点。

C++ 程序
#include <print>#include <vector>​// 一组数据中的最大元素。// 前置条件:values 不为空。// 后置条件:返回 values 中的最大元素。int largest(const std::vector<int>& values){    int best{0};    for (const int v : values) {        if (v > best) {            best = v;        }    }    return best;}​int main(){    std::println("{}", largest({3, 9, 4}));    std::println("{}", largest({7}));    std::println("{}", largest({1, 2, 3, 4}));    std::println("{}", largest({-5, -2, -8}));}
输出
9740

前三个用例是任何人都会先写的,函数也都通过了:9、7 和 4 都正确。第四个却得到 0,这个值甚至不在向量中,而应当得到 −2-2。错误在第 9 行:best 从 0 开始,而不是从第一个元素开始,因此只有当某个元素为正数或零时,函数才正确。典型用例都具有这一性质,却没有人有意选择它;第四个用例则有意违反它。修正方法是 int best{values[0]};,前置条件使它合法,因为前置条件保证 values[0] 存在。