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


89

我坚信使用验证完整程序的测试(例如收敛测试)的价值,包括一套自动化的回归测试。在阅读了一些编程书籍之后,我也感到I,我“应该”编写单元测试(即,用于验证单个函数正确性并且不等于运行整个代码来解决问题的测试)。 。但是,单元测试似乎并不总是与科学规范相适应,并且最终会感到虚假或浪费时间。

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


2
这是一个悬而未决的问题,不是吗?
qubyte

2
与所有“规则”一样,总是需要进行批判性思考。问问自己,某个例程是否具有明显的单元测试方法。如果没有,那么在那个时候要么单元测试就没有意义,要么代码的设计很差。理想情况下,一个例程执行的一项任务应尽可能独立于其他例程,但必须偶尔进行权衡。
Lagerbaer 2011年


Answers:


85

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

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

根据我的经验,通过考虑测试进行开发,最终会得到更简洁的界面,更集中的类和模块以及通常更多的SOLID可测试代码。

每当我使用没有单元测试的遗留代码而不得不手动测试某些东西时,我一直在想:“如果此代码已经具有单元测试,这将更快。” 每当我不得不尝试向具有高耦合性的代码中添加单元测试功能时,我总是在想:“如果以非耦合的方式编写,这将变得更加容易”。

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

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

在较新的实验室中,我通常可以通过以下方式添加功能:在我的办公桌上离线开发它,仅模拟那些立即需要的东西,然后只花很短的时间在实验室中,以解决所有未解决的问题。 -线。

为了清楚起见,由于@ naught101问...

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

但是,即使在开发探索性代码的情况下,我也可以从编纂假设的过程中看到巨大的好处,并且能够查看这些假设如何随着时间演变。


7
马克,您在说什么代码?可重用模型?我发现,这种原理并不是真的适用于探索性数据分析代码之类的东西,在该情况下,您确实需要跳很多步,并且通常从不希望在其他任何地方重用该代码。
naught101 '09

35

通常,由于问题的数学结构,与我从事的业务代码相比,科学代码往往具有连锁功能的星座。因此,我认为单个功能的单元测试不是非常有效。但是,我确实认为有一类有效的单元测试,并且与整个程序测试的不同之处还在于它们针对特定的功能。

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

我最喜欢的数值单元测试示例是测试有限元实现的收敛速度。这绝对不简单,但是它采用了已知的PDE解决方案,在减小网格尺寸遇到了一些问题,然后将误差范数拟合到曲线,其中是收敛速度。我使用Python处理PETSc中的泊松问题。我不是在寻找像回归中的差异,而是为给定元素指定了特别的比率。C h r r rhChrrr

来自PyLith的单元测试的另外两个示例是:点定位,这是一个易于生成综合结果的单一函数;以及在网格中创建零体积内聚单元,其中涉及多个函数,但解决了网格的一个外接部分。代码中的功能。

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


4
Wikipedia说:“单元测试,也称为组件测试,是指通常在功能级别上验证特定代码部分的功能的测试。” 有限元代码中的收敛测试显然不能作为单元测试,因为它们涉及许多功能。
大卫·凯奇森2011年

这就是为什么我在帖子的顶部清楚地表明了我对单元测试的广泛看法,而“通常”的意思恰恰是这样。
Matt Knepley 2011年

我的问题是在更广泛接受的单元测试定义的意义上提出的。我现在已经在问题中明确指出了这一点。
大卫·凯奇森2011年

我已经澄清了答案
马特·克奈普利

您后面的示例与我的意图有关。
大卫·凯奇森2011年

28

自从我阅读第二版Code Complete中的测试驱动开发以来,就一直使用单元测试框架作为开发策略的一部分,由于我编写的各种测试都是诊断性的,因此它减少了调试时间,从而大大提高了我的生产率。附带的好处是,我对自己的科学结果更有信心,并多次使用单元测试来捍卫我的结果。如果单元测试中有错误,我通常可以很快找出原因。如果我的应用程序崩溃并且我的所有单元测试都通过了,我将进行代码覆盖率分析,以查看未执行代码的哪些部分,并使用调试器逐步遍历代码以查明错误源。然后,我编写了一个新测试以确保该错误保持不变。

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

除单元测试外,还值得进行静态代码分析,内存调试器以及使用编译器警告标志进行编译以捕获简单错误和未使用的代码。



您是否认为集成测试足够,还是您还需要编写单独的单元测试?
siamii

我将在可能且可行的地方编写单独的单元测试。它使调试更容易,并强制执行解耦的代码(这就是您想要的)。
Geoff Oxberry

19

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

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

我肯定会为您的应用程序中的至少一些关键模块推荐它。但是,人们必须意识到它非常繁琐且耗时,因此除非您拥有无限的人力,否则我不建议您将它用于100%的复杂代码。


您是否有任何具体示例或标准来选择要进行哪些单元测试(哪些没有进行单元测试)?
David Ketcheson 2011年

@DavidKetcheson我的经验受到我们使用的应用程序和语言的限制。因此,对于我们的大约200k行(大部分为F90)的通用CFD代码,我们在过去一两年中一直在尝试真正隔离代码的某些功能。创建一个模块并在代码中使用它并不能实现这一目标,因此必须对这些模块进行真正的比较,并实际上将它们制成库。因此,只有很少的USE语句以及与其余代码的所有连接都是通过常规调用完成的。您可以对单元例程以及库的其余部分进行单元测试。
FrenchKheldar 2011年

@DavidKetcheson就像我在回答中说的那样,边界条件和热力学是我们代码的两个方面,我们设法对其进行真正隔离,因此对它们进行单元测试是有意义的。从更一般的角度来看,我将从一些小事情入手,然后尝试干净地做。理想情况下,这是一个2人的工作。一个人编写了描述接口的例程和文档,另一个人则应该编写单元测试,理想情况下,无需查看源代码并且仅通过接口描述即可。这样就可以测试例程的意图,但是我意识到这不是一件容易的事情。
FrenchKheldar 2011年

1
除了单元测试之外,为什么不包括其他类型的软件测试(集成,系统)?除了时间和成本之外,这不是最完整的技术解决方案吗?我的参考文献是1(第3.4.2节)和2(第5页)。换句话说,源代码不应该通过传统的软件测试级别3(“测试级别”)进行测试吗?
ximiki

14

出于多种原因,科学代码的单元测试很有用。

特别是三个:

  • 单元测试可帮助其他人理解您的代码约束。基本上,单元测试是一种文档形式。

  • 单元测试检查以确保单个代码单元返回正确的结果,并检查以确保修改细节后程序的行为不会改变。

  • 使用单元测试可以更轻松地模块化您的研究代码。如果您开始尝试将代码定位在新平台上,例如您有兴趣对其进行并行化或在GPGPU机器上运行它,则这尤其重要。

最重要的是,单元测试使您有信心使用代码生成的研究结果是有效且可验证的。

我注意到您在问题中提到了回归测试。在许多情况下,回归测试是通过自动,定期执行单元测试和/或集成测试来实现的(单元测试和/或集成测试可以测试代码段在组合时能否正常工作;在科学计算中,这通常是通过将输出与实验数据或测试结果进行比较来完成的)受信任的早期程序的结果)。听起来您已经在大型复杂组件级别成功使用集成测试或单元测试。

我要说的是,随着研究代码变得越来越复杂,并且依赖于其他人的代码和库,了解错误在何处发生非常重要。单元测试使错误更容易查明。

在我与人合着的《科学计算的最佳实践》的论文的第7节“错误计划”中,您可能会发现描述,证据和参考资料很有用-它还引入了防御性编程的补充概念。


9

在我deal.II类我教的软件,没有测试无法正常工作(并继续强调一点,我故意说:“ 不会无法正常工作”,而不是“ 可能无法正常工作)。

当然,我遵循这句口头禅-这就是处理方法.II每次提交都会运行2500个测试;-)

更严重的是,我认为Matt已经很好地定义了这两类测试。我们为较低级别的内容编写单元测试,并且自然而然地发展为针对较高级别的内容进行回归测试。我认为我无法画出一个清晰的边界来将我们的测试从一侧或另一侧分开,当然有很多人在有人看过输出并认为它在很大程度上是合理的(单元测试?)。而不考虑准确性的最后一点(回归测试?)。


为什么在软件测试中提出相对于传统级别的这种层次结构(较低的单位,较高的回归)?
ximiki

@ximiki-我不是故意的。我的意思是说测试存在于一个频谱中,该频谱包括您链接中列出的所有类别。
Wolfgang Bangerth '17

8

是的,没有。当然,要对用来简化生活的基本工具集的基本例程进行单元测试,例如转换例程,字符串映射,基本物理和数学等。在计算类或函数时,它们通常需要较长的运行时间,并且您实际上可能更喜欢将它们作为功能测试而不是作为单元进行测试。同样,对那些级别和用途将发生很大变化(例如出于优化目的)或内部细节由于任何原因而将发生更改的类和实体进行大量的单元测试和压力测试。最典型的示例是包装从磁盘映射的巨大矩阵的类。


7

绝对!

什么,那还不够吗?

在科学编程中,我们比其他任何种类的开发都基于尝试匹配物理系统。除了测试之外,您怎么知道您是否还做了?在甚至开始编码之前,请决定如何使用代码并计算出一些示例运行。尝试抓住任何可能的边缘情况。以模块化方式进行操作-例如,对于神经网络,您可以对单个神经元进行一组测试,而对完整的神经网络进行一组测试。这样,当您开始编写代码时,可以确保在开始使用网络之前您的神经元能够正常工作。像这样分阶段进行工作意味着,当您遇到问题时,只有最新的“阶段”代码要测试,较早的阶段已经过测试。

另外,一旦完成测试,如果您需要用其他语言(例如转换为CUDA)重写代码,或者即使您只是对其进行更新,则已经有测试用例,可以使用它们来编写确保程序的两个版本都以相同的方式工作。


+1:“按这样的阶段进行工作意味着,当您遇到问题时,只有要测试的最新“阶段”代码已被测试。
ximiki

5

是。

无需单元测试就可以编写任何代码的想法令人厌恶。除非您证明代码正确,然后证明证明正确= P。


3
...然后您证明该证明是正确的证明,并且...这是一个深深的兔子洞。
JM

2
乌龟一直降下来,使Dijkstra感到自豪!
aterrel 2011年

2
只需解决一般情况,然后让您的证明证明自己是正确的!乌龟圆环!
伊森2011年

5

我会务实地而不是教条地解决这个问题。问自己一个问题:“函数X可能出什么问题?” 想象一下,当您在代码中引入一些典型的错误时,输出会发生什么:错误的前置因子,错误的索引等等,然后编写可能检测到这种错误的单元测试。如果对于给定的功能,如果不重复该功能本身的代码就无法编写此类测试,则不要-而是考虑下一个更高级别的测试。

科学代码中的单元测试(或实际上是任何测试)的一个更为重要的问题是如何处理浮点运算的不确定性。据我所知,目前还没有好的通用解决方案。


无论是手动进行测试还是使用单元测试自动进行测试,浮点表示都存在完全相同的问题。我会强烈建议理查德·哈里斯的优秀系列的文章ACCU超载杂志
Mark Booth,

“如果对于给定的函数,如果不重复该函数本身的代码就无法编写此类测试,那么就不要”。你能详细说明吗?一个例子对我来说很清楚。
ximiki

5

我为Tangurena感到遗憾-在这里,口头禅是“未经测试的代码是破损的代码”,它来自老板。我只想补充一些细节,而不是重复进行单元测试的所有好理由。

  • 应该测试内存使用情况。应该测试每个分配内存的功能,以确保将数据存储和检索到该内存中的功能在做正确的事情。这在GPU世界中甚至更为重要。
  • 尽管前面已经简要提到过,但是测试极端情况非常重要。以与测试任何计算结果相同的方式来考虑这些测试。当输入参数或数据超出可接受范围时,请确保代码在边缘运行并正常运行(无论如何在模拟中定义)。编写此类测试所涉及的想法有助于提高您的工作效率,这可能是您很少找到写过单元测试但对过程没有帮助的人的原因之一。
  • 使用测试框架(如Geoff提到的那样,它提供了一个不错的链接)。我将BOOST测试框架与CMake的CTest系统结合使用,可以推荐它为快速编写单元测试(以及验证和回归测试)的简便方法。

+1:“当输入参数或数据超出可接受范围时,请确保代码在边缘运行,并且正常运行(无论如何在模拟中定义)。”
ximiki

5

我已经使用单元测试在几个小规模的代码(即单个程序员)上取得了良好的效果,包括我在粒子物理学中的论文分析代码的第三版

前两个版本由于自身的重量和互连的增加而崩溃。

其他人写道,模块之间的交互通常是打破科学编码的地方,并且它们是正确的。但是,当您可以确定性地证明每个模块在执行预期的工作时,诊断这些问题会容易得多。


3

在开发化学求解器(用于复杂的地质领域)时,我使用了一种稍微不同的方法,即所谓的“ 通过复制和粘贴代码段进行单元测试”

在这段时间内,为嵌入大型化学系统建模器中的原始代码构建测试工具是不可行的。

但是,我能够编写出越来越复杂的代码片段,以显示化学式的(Boost Spirit)解析器是如何工作的,作为针对不同表达式的单元测试。

最终的,最复杂的单元测试非常接近系统中所需的代码,而不必将代码更改为可模拟的。因此,我能够跨单元测试代码进行复制。

使得这不仅仅是学习练习和真正的回归套件的还有两个因素-单元测试保留在主要来源中,并作为该应用程序的其他测试的一部分运行(是的,它们确实从Boost中获得了副作用) 2年后改变了精神)-由于复制和粘贴的代码在实际应用程序中已进行了最小限度的修改,因此它可以包含注释,以回溯到单元测试,以帮助某人保持同步。


2

对于更大的代码库,高级内容的测试(不一定是单元测试)很有用。某些更简单算法的单元测试也很有用,以确保您的代码没有废话,因为您的辅助函数正在使用sin而不是cos

但是对于整体研究代码而言,编写和维护测试非常困难。在没有有意义的中间结果的情况下,算法往往会很大,中间结果可能会进行明显的测试,并且往往需要很长时间才能运行出结果。当然,您可以针对具有良好结果的参考运行进行测试,但是就单元测试而言,这并不是一个很好的测试。

结果通常是真实解的近似值。虽然您可以测试简单的功能(如果它们在一定程度上精确到一定程度),但是很难验证某个结果网格是否正确,这是用户(您)之前通过目视检查所评估的。

在这种情况下,自动化测试通常具有太高的成本/收益比。我建议更好的方法:编写测试程序。例如,我编写了一个中等大小的python脚本来创建有关结果的数据,例如边缘大小和网格角度的直方图,最大和最小三角形的面积及其比例等。

我既可以用它在正常运行期间,以评估输入和输出网格使用它之后,我改变了算法有一个全面的检查。当我更改算法时,我并不总是知道新的结果是否更好,因为通常没有绝对的量度哪个近似是最好的。但是通过生成这样的度量标准,我可以说出一些因素,比如“新版本最终具有更好的角度比,但收敛速度更差”会更好。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.