持续集成科学软件


22

我不是软件工程师。我是地球科学领域的博士生。

大约两年前,我开始编写科学软件。我从未使用过持续集成(CI),主要是因为起初我不知道它的存在,而且我是唯一使用此软件的人。

现在,由于该软件的基础正在运行,因此其他人开始对它产生兴趣并希望为该软件做出贡献。计划是其他大学的其他人正在实施核心软件的添加。(我担心他们会引入错误)。此外,该软件变得非常复杂,并且变得越来越难以测试,我也计划继续进行开发。

由于这两个原因,我现在越来越多地考虑使用CI。因为我从未接受过软件工程师的培训,而且周围的人都没有听说过CI(我们是科学家,所以没有程序员),所以我很难开始我的项目。

我有几个问题想向我寻求建议:

首先简要说明该软件的工作方式:

  • 该软件由一个包含所有必需设置的.xml文件控制。您只需通过将路径传递到.xml文件作为输入参数来启动软件,它就会运行并创建带有结果的几个文件。一次运行可能需要30秒钟。

  • 这是一个科学软件。几乎所有的函数都有多个输入参数,它们的类型主要是非常复杂的类。我有多个具有大型目录的.txt文件,用于创建这些类的实例。

现在让我们来问我的问题:

  1. 单元测试,集成测试,端到端测试?:我的软件现在大约有30.000行代码,具有数百个功能和约80个类。开始为数百种已经实现的功能编写单元测试对我来说有点奇怪。因此,我考虑过简单地创建一些测试用例。准备10-20个不同的.xml文件,然后运行该软件。我猜这就是所谓的端到端测试?我经常读到您不应该这样做,但是如果您已经拥有可以运行的软件,那么也许可以开始吗?还是尝试将CI添加到已经运行的软件中只是愚蠢的想法。

  2. 如果功能参数难以创建,如何编写单元测试? 假设我有一个函数double fun(vector<Class_A> a, vector<Class_B>),通常,我需要首先读取多个文本文件来创建类型为Class_A和的对象Class_B。我考虑过要创建一些虚拟功能,例如Class_A create_dummy_object()不读取文本文件。我还考虑过实现某种序列化。(我不打算测试类对象的创建,因为它们仅依赖于多个文本文件)

  3. 如果结果变化很大,如何编写测试?我的软件利用了大型蒙特卡洛模拟并可以迭代地工作。通常,您需要进行约1000次迭代,并且每次迭代都基于Monte Carlo模拟创建约500-20.000个对象实例。如果一次迭代的一个结果只有一点点不同,则整个即将到来的迭代将完全不同。您如何处理这种情况?我认为这对端到端测试来说很重要,因为最终结果变化很大?

对于CI的其他建议,我们深表感谢。



1
您怎么知道您的软件运行正常?您能找到一种自动执行该检查的方法,以便可以在每次更改时运行它吗?将CI引入现有项目时,这应该是您的第一步。
巴特·范·英根·谢瑙

首先如何确保软件产生可接受的结果?是什么让您确定它实际上“有效”?这两个问题的答案都将为您提供大量材料,以现在和将来测试您的软件。
Polygnome

Answers:


23

由于复杂的主题以及典型的科学开发过程(即对其进行破解,直到其正常运行,通常不会导致可测试的设计),测试科学软件非常困难。考虑到科学应该是可复制的,这有点讽刺。与“普通”软件相比,什么变化不是测试是否有用(是!),而是哪种测试是合适的。

处理随机性:您的软件的所有运行都必须是可复制的。如果使用蒙特卡洛技术,则必须为随机数生成器提供特定的种子。

  • 例如,当使用rand()依赖于全局状态的C 函数时,很容易忘记这一点。
  • 理想情况下,随机数生成器作为显式对象通过函数传递。C ++ 11的random标准库标头使这变得容易得多。
  • 我发现创建第二个RNG有用,而不是在软件的各个模块之间共享随机状态,该第二个RNG由第一个RNG中的随机数作为种子。然后,如果其他模块对RNG的请求数量发生更改,则第一个RNG生成的序列保持不变。

集成测试非常好。他们擅长验证软件的不同部分是否可以正确配合运行,并适合于运行具体方案。

  • 作为最低质量标准,“它不会崩溃”已经是一个很好的测试结果。
  • 为了获得更好的结果,您还必须对照一些基准检查结果。但是,这些检查必须具有一定的容忍度,例如考虑到舍入误差。比较摘要统计信息而不是完整的数据行也可能会有帮助。
  • 如果根据基线进行检查过于脆弱,请检查输出是否有效并满足一些常规属性。这些可以是一般性的(“所选位置必须相距至少2公里”)或特定于场景,例如“所选位置必须在此区域内”。

运行集成测试时,最好将测试运行程序编写为单独的程序或脚本。该测试运行器执行必要的设置,运行要测试的可执行文件,检查所有结果,然后进行清理。

单元测试样式检查可能很难插入科学软件中,因为该软件并非为此目的而设计的。特别是,当被测系统具有许多外部依赖项/交互时,单元测试将变得很困难。如果该软件不是纯粹面向对象的,则通常不可能模拟/存根那些依赖项。我发现最好避免这种软件的单元测试,除了纯数学函数和实用函数。

即使是几次测试也比没有测试要好。结合检查“必须编译”,这已经是持续集成的良好开端。您随时可以回来,以后再添加更多测试。然后,您可以确定更可能破坏代码区域的优先级,例如因为它们获得更多的开发活动。要查看单元测试未涵盖代码的哪些部分,可以使用代码覆盖工具。

手动测试: 尤其是对于复杂的问题域,您将无法自动测试所有内容。例如,我目前正在研究随机搜索问题。如果我测试我的软件始终能产生相同的结果,那么我在不中断测试的情况下无法对其进行改进。取而代之的是,我使手动测试变得更容易:我以固定的种子运行软件并获得可视化效果结果(根据您的喜好,R,Python / Pyplot和Matlab均可轻松获得数据集的高质量可视化效果)。我可以使用该可视化文件来验证事情并没有犯错。同样,至少在我可以选择要记录的事件类型的情况下,通过记录输出来跟踪软件的进度可能是一种可行的手动测试技术。


7

开始为数百种已经实现的功能编写单元测试对我来说有点奇怪。

您将(通常)在更改上述功能时编写测试。您无需坐下来为现有功能编写数百个单元测试,这将(主要是)浪费时间。该软件可以正常运行(可能)。这些测试的目的是确保将来的更改不会破坏旧的行为。如果您再也没有更改过某个特定功能,那么花时间对其进行测试可能就永远不值得(因为它目前正在工作,一直在工作并且很可能会继续工作)。我建议阅读有效使用旧版代码由Michael Feathers在这方面发表。他有一些伟大的通用策略来测试已经存在的事物,包括依赖项打破技术,特性测试(将复制/粘贴函数输出到测试套件中以确保您保持回归行为)以及更多其他内容。

如果功能参数难以创建,如何编写单元测试?

理想情况下,您不需要。而是使参数更易于创建(因此使设计更易于测试)。诚然,设计更改需要花费时间,而在像您这样的遗留项目上进行这些重构可能很困难。TDD(测试驱动开发)可以提供帮助。如果很难创建参数,则以测试优先样式编写测试会遇到很多麻烦。

在短期内,请使用模拟程序,但要提防模拟地狱以及长期使用它们所带来的问题。但是,随着我逐渐成为一名软件工程师,我已经意识到模拟几乎总是一种迷你味,它试图解决一些更大的问题而不解决核心问题。我喜欢将其称为“草皮包装纸”,因为如果您在地毯上的一些狗屎上放一块锡纸,它仍然会发臭。您要做的实际上是站起来,sc便,将其扔进垃圾桶,然后取出垃圾。显然,这是更多的工作,并且您可能会冒一些粪便的风险,但从长远来看,这对您和您的健康都有好处。如果您只包裹那些便便,就不想在房子里住更长的时间。cks子在本质上是相似的。

例如,如果Class_A由于必须读取700个文件而难以实例化,则可以对其进行模拟。接下来你知道,你的模拟变得过时,而真正 Class_A做一些事情比模拟很大的不同,和你的测试仍然通过,即使他们应该要失败。更好的解决方案是分解Class_A更易于使用/测试的组件,然后测试这些组件。也许编写一个集成测试,该集成测试实际上会击中磁盘并确保Class_A整体运行。或者也许只是有一个构造函数Class_A,您可以用一个简单的字符串实例化(表示您的数据),而不必从磁盘读取。

如果结果变化很大,如何编写测试?

一些提示:

1)使用反函数(或更笼统地说,基于属性的测试)。多少钱[1,2,3,4,5]?不知道。什么ifft(fft([1,2,3,4,5]))啊 应该是[1,2,3,4,5](或接近它,可能会出现浮点错误)。

2)使用“已知”断言。如果编写行列式函数,可能很难说出100x100矩阵的行列式。但是您确实知道单位矩阵的行列式为1,即使它是100x100。您还知道函数应该在不可逆矩阵上返回0(例如100x100全部为0)。

3)使用粗断言而不是精确断言。不久前,我写了一些代码,通过生成联系点来注册两个图片,这些联系点在图片之间创建映射并在它们之间进行扭曲以使其匹配。它可以在亚像素级别注册。您如何测试呢?像:

EXPECT_TRUE(reg(img1, img2).size() < min(img1.size(), img2.size()))

由于您只能在重叠的部分上注册,因此注册的图像必须小于或等于您的最小图像),并且:

scale = 255
EXPECT_PIXEL_EQ_WITH_TOLERANCE(reg(img, img), img, .05*scale)

由于注册到自己的图片应该与自己接近,但是由于手头的算法,您可能会遇到比浮点错误更多的错误,因此只需检查每个像素的有效范围为+/- 5%(0-255是常见范围(灰度)。大小至少应相同。您甚至可以进行烟雾测试(即调用它并确保它不会崩溃)。通常,此技术对于无法(轻松地)先于运行测试计算出最终结果的大型测试更好。

4)为您的RNG使用或存储随机数种子。

运行确实需要可重现。但是,错误的是,获得可重复运行的唯一方法是为随机数生成器提供特定的种子。有时随机性测试很有价值。我已经看到了科学代码中的错误,这些错误会在随机生成的退化案例中出现。不必总是使用相同的种子调用函数,而是生成一个随机种子,然后使用该种子并记录种子的值。这样,每次运行都有一个不同的随机种子,但是如果发生崩溃,则可以使用已记录的调试种子重新运行结果。我实际上已经在实践中使用了它,它消除了一个错误,所以我想我会提到它。缺点:您必须记录您的测试运行。上行空间:正确性和错误提示。

HTH。


2
  1. 测试类型

    • 开始为已经实现的数百个功能编写单元测试对我来说有点奇怪

      换个角度思考一下:如果一个补丁涉及多个功能而破坏了您的一项端到端测试,那么您将如何找出问题所在?

      它更容易写比整个程序各个功能单元测试。确保对单个功能有很好的覆盖范围要容易得多。当您确定单元测试将捕获您遇到的任何极端情况时,重构函数要容易得多。

      对于已经使用遗留代码库工作的任何人,为已经存在的功能编写单元测试是完全正常的。首先,它们是确认您对功能的理解的一种好方法,并且一旦编写,它们便是发现行为意外变化的好方法。

    • 端到端测试也值得。如果它们更容易编写,则一定要先做这些,然后临时添加单元测试,以涵盖您最担心其他功能中断的功能。您不必一次完成所有操作。

    • 是的,将CI添加到现有软件是明智且正常的。

  2. 如何编写单元测试

    如果您的对象确实很昂贵和/或很复杂,请编写模拟。您可以将使用模拟的测试与使用真实对象的测试分开链接,而不是使用多态。

    无论如何,您应该有一些简单的创建实例的方法-创建虚拟实例的函数很常见-但是对实际创建过程进行测试也是明智的。

  3. 结果可变

    您必须对结果有一些不变性。测试这些值,而不是单个数值。

    如果您的蒙特卡罗代码接受它作为参数,则可以提供一个模拟的伪随机数生成器,这至少可以使一个众所周知的算法可预测结果,但是它很脆弱,除非它每次字面上都返回相同的数字。


1
  1. 添加CI绝不是愚蠢的主意。从经验中我知道,当您有一个开源项目,人们可以自由地做出贡献时,这就是方法。如果代码破坏了您的程序,CI允许您阻止人们添加或更改代码,因此对于拥有一个有效的代码库而言,这几乎是无价之宝。

    在考虑测试时,您当然可以提供一些端到端测试(我认为这是集成测试的子类别),以确保您的代码流按应有的方式运行。您应该至少提供一些基本的单元测试,以确保函数输出正确的值,因为集成测试的一部分可以补偿测试期间发生的其他错误。

  2. 创建测试对象确实是相当困难且费力的。您想制作虚拟对象是正确的。这些对象应该具有一些默认值,但属于边缘情况,您肯定知道其值应该是什么输出。

  3. 有关此主题的书籍存在的问题是CI(以及devop的其他部分)的发展如此迅速,几个月后,书籍中的任何内容可能都会过时。我不知道有什么书可以帮助您,但Google应该像往常一样成为您的救星。

  4. 您应该自己多次运行测试并进行统计分析。这样,您可以实施一些测试用例,其中取多次运行的中位数/平均值并将其与分析进行比较,以了解哪些值是正确的。

一些技巧:

  • 在您的GIT平台中使用CI工具的集成,以防止损坏的代码进入您的代码库。
  • 在其他开发人员进行同行评审之前,请停止合并代码。这使得错误更容易被发现,并且再次阻止了损坏的代码进入您的代码库。

1

amon的回复中已经提到了一些非常重要的观点。让我再添加一些:

1.科学软件和商业软件开发之间的差异

当然,对于科学软件,通常通常将重点放在科学问题上。问题更多是在处理理论背景,找到最佳数值方法等方面。软件只是工作的一小部分,或多或少。

在大多数情况下,该软件只能由一个人或几个人编写。它通常是为特定项目编写的。项目完成并发布所有内容后,在许多情况下,不再需要该软件。

商业软件通常由大型团队在较长的时间内开发。这需要对架构,设计,单元测试,集成测试等进行大量规划。此规划需要大量的时间和经验。在科学环境中,通常没有时间这样做。

如果要将项目转换为类似于商业软件的软件,则应检查以下内容:

  • 窦您有时间和资源吗?
  • 该软件的长期前景如何?当您完成工作并离开大学时,该软件会发生什么?

2.端到端测试

如果该软件变得越来越复杂,并且有人在使用它,则必须进行测试。但是正如amon已经提到的,将单元测试添加到科学软件中是非常困难的。因此,您必须使用其他方法。

与大多数科学软件一样,随着您的软件从文件中获取输入,它非常适合创建多个示例输入和输出文件。您应该在每个发行版上自动运行这些测试,并将结果与​​样本进行比较。这可以很好地替代单元测试。您也可以通过这种方式进行集成测试。

当然,要获得可重复的结果,您应该为随机数生成器使用相同的种子,如amon所写。

这些示例应涵盖您软件的典型结果。这还应包括参数空间和数值算法的边缘情况。

您应该尝试查找不需要太多时间来运行但仍涵盖典型测试用例的示例。

3.持续整合

由于运行测试示例可能需要一些时间,因此我认为持续集成是不可行的。您可能需要与同事讨论其他部分。例如,它们必须与所使用的数值方法相匹配。

因此,在讨论了理论背景和数值方法,仔细测试等之后,我认为最好以定义明确的方式进行集成。

我认为对于连续集成具有某种自动性不是一个好主意。

顺便说一句,您是否正在使用版本控制系统?

4.测试您的数值算法

如果要比较数值结果,例如在检查测试输出时,则不应检查浮点数是否相等。总会有舍入错误。相反,请检查差异是否低于特定阈值。

这也是一个好主意,请针对不同的算法检查您的算法,或者以不同的方式提出科学问题并比较结果。如果使用两种或更多种独立的方式获得相同的结果,则表明您的理论和实现是正确的。

您可以在测试代码中进行这些测试,并为生产代码使用最快的算法。


0

我的建议是仔细选择您如何努力。在我的领域(生物信息学)中,最先进的算法变化如此之快,以至于将精力花在代码的错误校验上可能会更好地花在算法本身上。

也就是说,有价值的是:

  • 就算法而言,这是当时最好的方法吗?
  • 移植到不同的计算平台(不同的HPC环境,操作系统风格等)有多么容易
  • 健壮性-是否可以在MY数据集上运行?

您建立防弹代码库的本能是高尚的,但值得记住的是这不是商业产品。使它尽可能地便携,防错(针对您的用户类型),方便其他人参与,然后专注于算法本身

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.