是否值得为科研代码编写单元测试?

计算科学 编程范式 测试
2021-12-13 19:19:59

我坚信使用验证完整程序的测试(例如收敛测试)的价值,包括一组自动化的回归测试在阅读了一些编程书籍之后,我有一种挥之不去的感觉,我也“应该”编写单元测试(即,验证单个函数的正确性并且不等于运行整个代码来解决问题的测试)以及. 然而,单元测试似乎并不总是符合科学代码,最终会让人觉得做作或浪费时间。

我们应该为研究代码编写单元测试吗?

4个回答

多年来,我一直误解我没有足够的时间为我的代码编写单元测试。当我确实编写测试时,它们是臃肿而沉重的东西,这只会鼓励我认为我应该只在知道需要它们时才编写单元测试。

然后我开始使用测试驱动开发,我发现它是一个完整的启示。我现在坚信我没有时间写单元测试

根据我的经验,通过在开发时考虑到测试,您最终会得到更清晰的接口、更集中的类和模块以及通常更可靠、可测试的代码。

每次我使用没有单元测试并且必须手动测试某些东西的遗留代码时,我一直在想“如果这段代码已经进行了单元测试,这会快得多”。每次我必须尝试将单元测试功能添加到具有高耦合性的代码中时,我一直在想“如果它以解耦的方式编写会容易得多”。

比较和对比我支持的两个实验站。一个已经存在了一段时间并且有大量遗留代码,而另一个相对较新。

在向旧实验室添加功能时,通常需要深入实验室并花费大量时间研究他们需要的功能的含义以及如何在不影响任何其他功能的情况下添加该功能。代码根本没有设置为允许离线测试,所以几乎所有东西都必须在线开发。如果我确实尝试离线开发,那么我最终会得到比合理的更多的模拟对象。

在较新的实验室中,我通常可以通过在我的办公桌上离线开发来添加功能,只模拟那些立即需要的东西,然后只在实验室中花费很短的时间,解决任何没有解决的剩余问题-线。

为了清楚起见,自从@naught101询问...

我倾向于研究实验控制和数据采集软件,并进行一些临时数据分析,因此 TDD 与修订控制的结合有助于记录底层实验硬件的变化以及数据收集需求随时间的变化。

然而,即使在开发探索性代码的情况下,我也可以从编写假设中看到显着的好处,以及看到这些假设如何随着时间的推移而演变的能力。

科学代码往往比我研究过的商业代码更频繁地具有联锁功能,这通常是由于问题的数学结构。因此,我不认为针对单个功能的单元测试非常有效。但是,我确实认为有一类单元测试是有效的,并且仍然与整个程序测试有很大不同,因为它们针对特定的功能。

我只是简要地定义了这些测试的含义。当对代码进行更改时,回归测试会查找现有行为的更改(以某种方式验证)。单元测试运行一段代码并检查它是否根据规范给出了所需的输出。它们并没有那么不同,因为最初的回归测试一个单元测试,因为我必须确定输出是否有效。

我最喜欢的数值单元测试示例是测试有限元实现的收敛速度。这绝对不简单,但它需要一个 PDE 的已知解,在减小网格大小 $h$ 时运行几个问题,然后将误差范数拟合到曲线 $C h^r$ ,其中 $r$ 是收敛速度. 我使用 Python 为 PETSc 中的泊松问题执行此操作。我不是在寻找差异,就像在回归中一样,而是为给定元素指定的特别费率 $r$。

来自PyLith的另外两个单元测试示例是点定位,它是一个易于生成合成结果的单一功能,以及在网格中创建零体积内聚单元,它涉及多个功能,但解决了一个限制性的代码中的功能。

有许多此类测试,包括守恒和一致性测试。该操作与回归没有什么不同(您运行测试并根据标准检查输出),但标准输出来自规范而不是先前的运行。

自从我在Code Complete, 2nd edition中阅读了关于测试驱动开发的内容后,我就使用了一个单元测试框架作为我开发策略的一部分,由于我编写的各种测试都是诊断性的,因此通过减少调试时间显着提高了我的工作效率。作为一个附带的好处,我对我的科学结果更有信心,并且多次使用我的单元测试来捍卫我的结果。如果单元测试中出现错误,我通常可以很快找出原因。如果我的应用程序崩溃并且我的所有单元测试都通过了,我会进行代码覆盖分析以查看我的代码的哪些部分没有被执行,并使用调试器逐步检查代码以查明错误的来源。然后我写了一个新的测试来确保这个 bug 得到修复。

我编写的许多测试都不是纯单元测试。严格定义,单元测试应该执行一个功能的功能。当我可以使用模拟数据轻松测试单个函数时,我会这样做。其他时候,我不能轻易地模拟我需要编写一个测试给定函数的功能所需的数据,所以我将在集成测试中与其他函数一起测试该函数。集成测试一次测试多个函数的行为。正如马特所指出的,科学代码通常是一系列互锁函数,但通常情况下,某些函数会按顺序调用,并且可以编写单元测试来测试中间步骤的输出。例如,如果我的生产代码依次调用五个函数,我将编写五个测试。第一个测试将只调用第一个函数(所以它是一个单元测试)。然后第二个测试将调用第一个和第二个函数,第三个测试将调用前三个函数,依此类推。即使我可以为代码中的每个函数编写单元测试,我还是会编写集成测试,因为当程序的各种模块化部分组合在一起时会出现错误。最后,在编写了我认为需要的所有单元测试和集成测试之后,我 将我的案例研究包装在单元测试中并将它们用于回归测试,因为我希望我的结果是可重复的。如果它们不可重复,并且我得到不同的结果,我想知道为什么。回归测试的失败可能不是一个真正的问题,但它会迫使我弄清楚新结果是否至少与旧结果一样值得信赖。

与单元测试一起使用的还有静态代码分析、内存调试器以及使用编译器警告标志进行编译以捕获简单错误和未使用的代码。

以我的经验,随着科学研究代码复杂性的增加,需要在编程中采用非常模块化的方法。对于具有大型和古老基础(f77任何人?)的代码来说,这可能会很痛苦,但有必要向前推进。随着模块围绕代码的特定方面构建(对于 CFD 应用程序,考虑边界条件或热力学),单元测试对于验证新实现、隔离问题和进一步的软件开发非常有价值。

这些单元测试应该比代码验证低一级(我可以恢复波动方程的解析解吗?),比代码验证低两级(我可以在湍流管道流中预测正确的峰值 RMS 值),只需确保编程(参数是否正确传递,指针是否指向正确的东西?)和“数学”(此子例程计算摩擦系数。如果我输入一组数字并手动计算解,例程会产生相同的结果吗?结果?)是正确的。基本上比编译器可以发现的高一级,即基本语法错误。

我肯定会推荐它至少用于您应用程序中的一些关键模块。但是,必须意识到这是非常乏味和耗时的,所以除非你有无限的人力,否则我不会推荐它用于 100% 的复杂代码。