Post

TDD_in_Embedded_C/C++

本文讲解关于嵌入式系统单元测试的视频。演讲中举了移动电话设备的例子,但可以想象将其应用于设备控制器软件。重要的主题包括自动化单元测试框架、单元测试哲学(外部接口与实现的区别)、模拟、将设备控制器中的硬件/板级支持功能分成单独的代码单元,以及C++中的测试接缝。视频链接为 https://www.youtube.com/watch?v=vQJaRWpvgx8 。

最下方为字幕,机翻太离谱了,重新翻译了一下。

单元测试应该具有的一组标准

1.它们应该很快

​ 如果测试很慢,开发人员就会减少运行它的频率。

2.测试也应该彼此独立

​ 可以以任何顺序运行,它们之间不应该有依赖关系。每次有很多测试时测试都应该给出相同的结果。

​ 要特别注意全局变量,单例模式等等,在测试这些的时候要特别小心。

3.测试中不要依赖于硬件

​ 实际硬件运作可能产生不同的行为,也有可能失败,也有可能成功。

4.测试不要出现任何不确定的行为

​ 不要在测试中启动线程或生成其它处理器之类的东西,它应当是自我验证的。

跨平台测试

嵌入式设备RAM有限,在上面跑单元测试框架会占用大量资源。可以尝试在PC上或者资源丰富的平台上进行单元测试。在PC上还可以使用一些sanitizers之列的工具。

两种TDD方式

1.经典TDD

经典的TDD,我们使用(stubs)、(dummies)和(spies)

经典TDD使用状态验证,测试中有两种类型的对象,第一种是系统正在测试的对象,这种情况下是一个名为target的order类实例,第二种类型的对象是协作对象(collaborator),这些是系统正在测试的对象的依赖项,这种情况下,它是一个实现可移动库存接口的对象,它需要履行订单。我使用一个可移动库存桩对象作为协作者来满足依赖性,测试是否通过或失败是通过验证系统正在测试的对象或其协作者之一的状态来完成的。因此,在这种情况下,它使用了协作者,所以经典测试只关心系统的最终状态,不关心实际是如何实现该状态的。如果可行的话,你可以只使用实际的对象作为协作者,这样测试的系统将完全相同。

2.Mock TDD

mocks

另一种TDD风格是模拟(Mock)TDD,而模拟TDD使用的是所谓的行为验证。系统正在测试的是相同的,这次我使用模拟对象来实现可移动库存接口作为协作者,并且我使用了Google Mock来实现它,模拟通常编写起来比较冗长,通常会使用一些框架来辅助。在Maki风格中的真正区别在于你指定了协作者所期望的行为,所以在这里我说必须调用has inventory,然后调用remove inventory。Maki的测试人员关心行为是如何实现的,并且在编写测试时考虑这一点,将行为规定在测试中将你的测试与你的实现耦合在一起,因此这意味着以后的重构可能会更加困难,因为你的更改更有可能实际上破坏一个测试。

总之,经典TDD的优点是它不指定代码如何工作,它只检查最终状态,并且这样更容易进行重构。缺点是单一的代码更改可能会破坏很多测试,特别是如果你实际上将实际对象用作协作者,还可能存在封装和可测试性之间的权衡。模拟TDD的优点是不正确的代码更改只会破坏与该代码更改相关的测试,Mock TDD在编写新测试时让你思考如何实际实现代码,一些人将其视为优点,一些人将其视为缺点,但主要的缺点是测试与实现耦合在一起,这使得重构变得更加困难。

现在我倾向于根据我正在测试的层次切换我的TDD风格,所以当我测试与硬件相关的任何内容时,我使用Mock TDD,因为我的代码的行为通常由我与硬件的交互方式决定,所以如果我有像SPI设备之类的东西,我知道我需要设置芯片选择线来执行一些读写操作,然后将芯片选择线设置为高电平,所以我发现在测试中规定该行为使测试更加清晰。而对于其他一切,我使用经典的测试,所以我更喜欢我的测试不与我的实现耦合,这样我就可以更轻松地进行重构。

依赖注入

在C和C++中,有三种时间可以插入测试替身,分别是编译时、链接时和运行时,每个时点都可以使用各种技术。

1.编译时

使用C++接口是在C++中插入测试替身的最简单方法,但是它们也有一些小缺点。虚函数调用比直接调用方法要慢,这可能是一个问题,特别是在代码的时间关键部分。而且,由于虚函数引起的vtable会占用ROM或RAM中的空间,所以在非常有限的空间系统中可能会引发问题。

2.链接时

接下来要讨论的技术是链接其他对象文件,当涉及到速度或代码问题时,我们使用这种技术。虽然我在这里使用的示例是用C编写的,但在C++中使用这种技术也是一样的。使用这种技术时,依赖关系接口只是I²C代码的函数声明,我们不希望有任何虚函数调用。被测系统只需调用此依赖关系以获得返回值。这种技术的诀窍在于,在链接时切换实现依赖关系的代码。

因为依赖关系在链接状态下被注入,所以在实际代码中不需要注入它,因此被测系统可以直接调用依赖关系,这意味着没有虚函数调用会减慢代码的速度或占用额外的代码空间或RAM。

所以通过链接其他对象文件来插入测试替身可以消除代码时间关键部分的虚函数调用,同时节省额外的代码空间。但它会增加构建系统的复杂性。这只是一个简要的总结,我们在C++中使用C++接口来插入测试替身,在时间关键的代码部分虚函数调用变得过于昂贵时,我们使用链接其他对象文件的方法。

中文字幕:

好的,你好,我叫拜伦,我在一家叫做Prefix Software的公司工作,我们位于康沃尔的雷德鲁斯。我们提供嵌入式软件开发服务,也就是为其他公司编写软件,我们实际上没有自己的产品。我们在2009年引入了敏捷开发实践,从那以后,我们已经交付了大约30个项目,项目的规模各不相同,有些项目只持续了几周,有些项目则需要几年的时间。

在这次演讲中,我不会过多关注TDD循环的具体内容,已经有很多关于这方面的演讲了。我将谈谈在听完这些演讲后,我希望在应用这些实践到我的领域时应该知道的一些事情,对我来说,这特别涉及到嵌入式开发,而且我只有一点勇气,我会边讲边回答问题,所以时间可能会不太准确。

那么,我所说的嵌入式软件是什么意思呢?嵌入式软件是一种不运行在台式计算机上的软件,而是运行在微控制器上的软件。对于我们来说,我们通常开发的系统是单核的,处理器频率在8兆赫兹到200兆赫兹之间,RAM的大小从256字节到512千字节不等,闪存的大小最多为250千字节,这是最大的代码大小。为了给你一个概念,最新的三星手机配备了2.15千兆赫兹的处理器,拥有4千兆字节的RAM,而如今平均的Android应用程序实际上只有18兆字节。

我想大多数人都熟悉TDD的循环,首先编写一个失败的测试,运行所有测试,看新的测试是否失败,然后进行小的更改以使其通过,再次运行所有测试以确保它们都通过,然后进行重构以改进代码和测试,最后再次运行以确保没有破坏任何东西。

也许不太为人所知的是第一个助记符,它定义了单元测试应具备的一组标准。首先,它们应该快速运行,如果它们运行缓慢,会对开发人员实际运行它们产生障碍,他们会运行得更少。它们应该是隔离的,这通常意味着两件事,首先,测试应该隔离失败的原因,开发人员不应该去尝试弄清楚是什么导致了测试失败,这应该从测试名称和断言消息中是显而易见的。测试还应该相互独立,因此它们可以以任何顺序运行,它们之间不应该有任何依赖关系。它们应该是可重复的,测试应该每次都给出相同的结果。有很多原因会导致测试间歇性地失败,特别是对于我们来说,依赖于超出处理器之外的硬件,或者PCB上的其他东西,可能会导致问题。它可能不会每次都返回相同的值,这将导致测试偶尔失败。这意味着我们实际上不能依赖于测试中的外部硬件。在测试中,您也不应该有任何非确定性行为,不要在测试中启动线程或生成其他进程。测试应该是自我验证的,它们应该通过自身来指示部分失败,不需要人工来决定测试是否通过或失败,最后,它们应该及时。对于TDD,这意味着必须在编写代码之前编写它们,如果您正在进行其他测试实践,最好在编写代码时尽早编写测试。

让我们展示一下如何满足第一个标准,我将讨论一下我们运行测试的位置,以保持它们的快速性,我们的TDD风格如何影响我们测试的验证,我将展示一些如何插入测试替身的示例,以及这如何保持我们的测试的隔离性和可重复性,最后,我将简要查看一些我们的其他测试实践,这些实践是我们从中受益的额外内容。

所以我们发现部署和运行测试需要很长时间,而且实际上并没有为我们关心的测试结果输出添加任何价值,而是获取这些结果所花费的时间引起了问题。与任何减慢开发速度的事情一样,他们开始尝试优化这个过程。所以在我们的情况下,优化的方式是编写更大的测试。更大的测试会导致需要更大的代码更改才能通过,增加了未察觉地引入错误的机会。对我们来说,在目标平台上进行测试的最大优势实际上是我们获得了准确的测试结果,如果你的代码库非常小或者部署速度非常快,那么它也会起作用。在我们的前两个项目中,我们的代码库实际上非常小,当我们启动新的流程时,我们会在小型项目上尝试它们。对我们来说,有三个主要的缺点,所以我不指望任何人都会阅读这些,如果你以后不会再看这些幻灯片,大多数优点都是因为我们依赖目标平台,所以较慢的反馈我已经提到了,正如我在开头提到的,我们的系统通常具有非常有限的代码空间和RAM,我们的测试至少与我们的代码一样大,如果不是更大,并且我们必须部署测试、代码和单元测试框架,所以除非你的代码库非常小,否则这将成为一个问题,你将不得不将其拆分,每个开发人员都需要一套目标硬件,所以这可以以开发套件的形式存在,当我们开发产品时,硬件通常也在同时开发,所以它经常会损坏,不一定会给你预期的结果,它通常很昂贵,有时甚至会被损坏或者炸毁,所以依赖硬件可能会引发各种问题,现在我们实际上不再专门在目标平台上运行测试,所以当测试目标开始引发问题时,我们转而在开发PC上进行测试,这消除了测试部署和运行缓慢的问题,我们实际上认为这非常棒,我们获得了想要的快速反馈,不再存在代码库和RAM问题,我们消除了对目标硬件的需求,我们的代码更具可移植性,所以现在我们需要双重目标,因为我们想要部署到目标上并在开发PC上运行,所以我们已经进行了移植工作,我们还能够使用目标编译器不支持的语言特性,所以对于那些不在嵌入式领域工作的人来说,嵌入式编译器在语言特性方面往往比桌面版本落后一些,所以这在进行测试时非常有趣,它使我们的测试能够更加表达性,但也带来了一些问题,我们在一个平台上测试代码,但在完全不同的平台上运行它,所以有时候在开发PC上所有测试都通过了,但在目标系统上却无法正常工作,对我们来说,这通常是由于某种打包问题引起的,因为我们当时的工作,但这些错误变得更加难以发现。

我们在目标设备上运行代码的频率较低,因此不同运行之间的更改更大,我们不再有那种小幅度增量的情况,使问题出现的地方明显可见。另一个可能的问题是,您可以编写在目标平台上无法编译的代码,所以我们当时用的是C语言,这通常不会给我们带来问题,但随着C++ 11和14的发布以及即将发布的C++ 17,有很大可能您会使用在开发平台上有效但在目标编译器上无效的功能。最终,我们决定采用双重目标化的测试方法,这在今天非常常见。为了做到这一点,我们采用了一种修改后的TDD(测试驱动开发)循环。我们开始像平常一样,编写一个失败的测试,然后进行小幅更改和重构。当所有测试都通过后,我会使用目标编译器进行编译,这可以检查是否引入了目标平台不支持的内容。在这一点上,如果我在过去15分钟内运行过我的测试,我会重新开始这个循环,否则我会将测试部署到目标设备并在那里运行它们。这确保了我没有引入仅在我的开发系统上工作的东西,而且每隔15分钟部署和运行一次可以减少对目标设备上缓慢的部署和运行的影响,因此这仍然具有快速的特点。

所以这仍然具有我们想要的快速反馈,还有一些其他优点。我们仍然有更具可移植性的代码,使用两种不同的编译器编译往往会引发额外的警告,其中一个编译器可能会经常捕获到另一个编译器不会提醒您的问题。我们还能够运行动态代码分析,所以如果我们使用某种动态内存分配,我们可以在开发PC上进行内存泄漏检测,开发PC上的编译器往往比嵌入式编译器具有更好的动态代码分析能力,还允许我们运行“sanitizers”,我稍后会回到这一点上。双重目标化也有一些缺点,所以你仍然需要目标硬件,现在只能使用实际由两种编译器支持的语言,如果你使用C并且使用Visual Studio作为桌面编译器,那么你将被限制在C 89的功能上,你还需要维护两个构建,但通过在两者上使用相同的构建系统并仅切换编译器和链接器,可以将此最小化。我提到了“sanitizers”,“sanitizers”是编译器通过添加到二进制文件中的运行时检查,它们可以检查各种问题,从越界访问到未初始化读取和未定义行为。它们目前由clang和GCC提供,但并不是每个平台都支持它们,我不确定Visual Studio的可用性情况。当我在开发平台上运行测试时,我会使用它们,但需要注意的是,它们会减慢代码的执行速度,其中一些甚至可以慢到10倍,所以编译并在生产代码中留下它们不是一个好主意。为了演示它们实际可以做什么,让我们看一个例子。这段代码在第11行分配了一块内存,在第13行将其传递给了一个排序数组函数,该函数不是真正地对其进行排序,而是在第6行释放了内存。然后,主函数在第15行访问了相同的内存块,所以我们有一个称为“堆使用后释放”的问题。如果我实际编译这段代码,并使用底部的命令运行它,你是否敢猜测它实际会做什么?有两个选项,实际上它不会是随机的,因为这是“scallop”而不是“malloc”。实际上,在我的系统上,我使用clang运行它,它只会将零输出到控制台。

所以我们在释放内存后访问了它,但实际上没有进行运行时检查,检查实际上出错了,所以它只是正常工作。所以如果我们打开底部的命令行选项,使用所谓的”sanitizer”,然后再次运行程序,我们会在控制台上看到以下输出。这里有很多详细信息,第一次看到这些信息时可能有点令人生畏,但重要信息大部分都在消息的前三分之一。通常,这些输出在控制台上是有颜色突出显示的,但我的复制粘贴能力在此时不起作用,所以如果我突出显示重要部分,我们可以更容易地看到这告诉我们什么。第一行告诉我们发生了什么,我们有一个堆使用后释放,以及它实际发生的地址。接下来的部分提供了更多关于实际发生情况的细节,包括大小、位置以及释放它的代码的堆栈跟踪。最后,我们获得了代码的堆栈跟踪,该代码最初分配了内存,所以它在K.c的主函数中的第11行分配了内存。在开发PC上运行测试时使用”sanitizers”可以为您提供额外的反馈,这可能是您仅从测试中无法获得的反馈。如何分解项目将决定维护和测试的难易程度,所以我们使用了简单的分层方法,我们会随着时间的推移以垂直切片方式进行开发,并通常有一些类似于这些层次。对于我们来说,随着我们深入到各个层次,测试变得越来越慢,因为我们越来越接近硬件。我们倾向于有一个非常薄的处理器层,我们不会实际进行单元测试,这个层次只设置处理器寄存器,我们发现测试这个层次相对不太有效,测试最终基本上反映了通常是一个不好的迹象的代码,我们尝试将这段代码的O(N²)或更低的水平,我们试图保持低耦合度,因为类之间的耦合度越高,测试代码就越难以测试,最后我们试图坚守”SOLID”原则,我发现”单一职责原则”和”依赖反转原则”是”SOLID”原则中最有用的两个,即使你对其他原则一无所知,这两个原则也将对你有所帮助,我不会在这里详细讨论它们,因为这将需要一个完整的演讲,有很多文章可以阅读,底部还提到了Robert Martin的书,是一个很好的信息来源。在某个时候,我们的单元测试将会依赖于硬件,所以在这个例子中,如果我正在测试BMA 150加速度计,并且我按照生产代码中的依赖关系设置依赖关系,那么我依赖于树莓派的I²C。为了保持我的测试可重复性,我需要去除对树莓派I²C的依赖性,因此我需要决定两件事,首先我将用什么来替代这个依赖性,其次我将如何替代这个依赖性。我将用一种称为”test double”的东西来替代这个依赖性。”test double”是为了测试目的而创建的对象,以满足测试的需要。我将使用Martin Fowler在他的”mocks and stubs”文章中使用的术语,尽管我认为他实际上是从一本书中偷的。我们使用的四种最常见类型的”test double”是”stubs”或”spies”,它们提供方法的固定返回值,或者记录传递给方法的参数。”dummies”只是为了满足依赖关系,以创建系统进行测试,而不会在测试中实际使用。

伪装(Fakes)提供了一个工作版本的依赖关系,但以一种虚假的方式,比如内存中的EEPROM或内存中的数据库。我们使用的最后一种可测试性是模拟(Mocks),这些是预先编程的,定义了一组预期的方法调用所定义的预期行为,并且模拟验证这些方法调用是否真的发生了。我们使用哪种类型的测试替身取决于我们的TDD(测试驱动开发)风格,对于经典的TDD,我们使用桩(stubs)、哑巴(dummies)和锁(spies),而对于所谓的”mawkish TDD”,令人惊讶的是,我们使用模拟(mocks)。

所以值得决定你将使用哪种TDD风格,这实际上是我在采用TDD的过程中发现的一个非常晚的事情,直到几年前我从未真正听说过这个。

为了说明差异,我将使用Martin Fowler的文章中的示例,但为C++重新编写。所以我尝试从一个可移动库存中填充一个订单对象,这个库存由我的仓库对象实现,我有一个示例场景,假设仓库有50个苹果库存和一个20个苹果的订单,当我完成一个订单时,我们的仓库将有30个苹果库存。因此,我们正在检查当订单被履行时,我们是否从仓库中取出了正确数量的东西。

经典TDD使用状态验证,测试中有两种类型的对象,第一种是系统正在测试的对象,这种情况下是一个名为target的order类实例,第二种类型的对象是协作对象(collaborator),这些是系统正在测试的对象的依赖项,这种情况下,它是一个实现可移动库存接口的对象,它需要履行订单。我使用一个可移动库存桩对象作为协作者来满足依赖性,测试是否通过或失败是通过验证系统正在测试的对象或其协作者之一的状态来完成的。

因此,在这种情况下,它使用了协作者,所以经典测试只关心系统的最终状态,不关心实际是如何实现该状态的。如果可行的话,你可以只使用实际的对象作为协作者,这样测试的系统将完全相同。这一次我使用一个实际的仓库对象作为协作者,并设置了一个初始库存,最后验证仓库协作者的最终状态是否符合预期。使用真实对象进行测试的优点是你不需要创建测试替身来运行测试,这意味着经典测试人员往往对他们所说的”单元”有稍微更广泛的定义。然而,以这种方式进行测试也有一些缺点,因此实际破坏系统逻辑的代码更容易导致大量测试失败。如果你只是进行非常小的代码更改,这不是问题,因为很容易确定哪个引起了问题。以这种方式进行测试还可能导致轻微的封装损失,封装性可能因可测试性而受到损害,或者添加方法来检查验证测试。在这个示例中,我的仓库对象提供了一个名为”how many in stock”的方法,但实际上不需要它来实现其他功能。

这仅仅是为了验证测试,另一种TDD风格是模拟(Mock)TDD,而模拟TDD使用的是所谓的行为验证。系统正在测试的是相同的,这次我使用模拟对象来实现可移动库存接口作为协作者,并且我使用了Google Mock来实现它,模拟通常编写起来比较冗长,通常会使用一些框架来辅助。在Maki风格中的真正区别在于你指定了协作者所期望的行为,所以在这里我说必须调用has inventory,然后调用remove inventory。Maki的测试人员关心行为是如何实现的,并且在编写测试时考虑这一点,将行为规定在测试中将你的测试与你的实现耦合在一起,因此这意味着以后的重构可能会更加困难,因为你的更改更有可能实际上破坏一个测试。

总之,经典TDD的优点是它不指定代码如何工作,它只检查最终状态,并且这样更容易进行重构。缺点是单一的代码更改可能会破坏很多测试,特别是如果你实际上将实际对象用作协作者,还可能存在封装和可测试性之间的权衡。模拟TDD的优点是不正确的代码更改只会破坏与该代码更改相关的测试,Mock TDD在编写新测试时让你思考如何实际实现代码,一些人将其视为优点,一些人将其视为缺点,但主要的缺点是测试与实现耦合在一起,这使得重构变得更加困难。

现在我倾向于根据我正在测试的层次切换我的TDD风格,所以当我测试与硬件相关的任何内容时,我使用Mock TDD,因为我的代码的行为通常由我与硬件的交互方式决定,所以如果我有像SPI设备之类的东西,我知道我需要设置芯片选择线来执行一些读写操作,然后将芯片选择线设置为高电平,所以我发现在测试中规定该行为使测试更加清晰。而对于其他一切,我使用经典的测试,所以我更喜欢我的测试不与我的实现耦合,这样我就可以更轻松地进行重构。

我已经研究了如何替代依赖关系,但如何替代它们以及对我的代码和测试有什么影响呢?在C和C++中,有三种时间可以插入测试替身,分别是编译时、链接时和运行时,每个时点都可以使用各种技术。

这不是一个详尽无遗的列表,这只是一些例子,我将展示这两种技术的示例。其中一种技术是我们经常使用的,也可能是在C++中最常见的做法,另一种是我们专门用来解决嵌入式系统中的问题的技术。为了说明两者之间的区别,我需要一些代码,这些代码是我为一门教授大学毕业生敏捷软件开发实践的课程编写的。我们给他们一个开发监控系统的项目,该系统会跟踪人在各种体育活动中的手臂运动。系统使用了BMA 150加速度计和各种其他传感器来监测运动,这个项目的目标平台是树莓派(Raspberry Pi)。BMA 150是一个I²C设备,你不需要担心什么是I²C,它只是一种串行通信协议。代码和测试的设置如下:被测试的系统是BMA 150加速度计驱动程序,它有一个依赖项,即I²C端口。在测试时,我想使用一个模拟的I²C,而在构建最终应用程序时,我想使用树莓派的I²C和实际应用程序代码。我要讲的第一种测试替身插入技术是C++接口,这是我们在C++开发中最常用的技术,我们用它来在代码中插入可测试性。当使用C++接口时,依赖关系接口是一个抽象的C++类,其方法都是纯虚拟的,所以在这个例子中,它声明了三个方法,这些方法允许设置设备地址、从设备读取和向设备写入。我要注入的模拟I²C是从I²C接口派生的,它定义了需要实现接口的方法,并且作为一个模拟对象,它需要提供一组设置期望行为的方法,最后,它需要验证所有应该被调用的方法是否都被调用了,为此它有一个verify方法,插入在verify方法中的断言如果没有抛出异常,那么我喜欢在模拟对象的析构函数中调用verify函数,这确保了模拟对象在离开作用域时总是进行验证,如果我忘记调用verify方法,它实际上不会执行验证。所以现在我有了编写测试所需的一切。

首先,我会创建一个测试替身的实例,然后为其提供一组预期的方法调用。接下来,我创建被测系统并注入测试替身,这是我可以控制的测试替身,所以我可以验证我的测试是否通过或失败。然后,我测试被测系统,如果方法返回一些值,我检查它们是否正确,而模拟对象会在超出范围时自动验证所有预期的方法调用,也就是在测试结束时。

现在我有了测试,可以编写一些代码。BMA 150加速度计依赖于I²C接口,所以我需要提供一个实现该接口的对象。在这里,我使用了构造函数中的一个非常简单的依赖注入技术。这是实际允许我插入测试替身的代码。当你这样做并在实际代码中运行它时,你需要小心对象的生命周期,确保I²C在BMA 150加速度计之前不会被销毁,否则会出现各种有趣的问题。BMA 150加速度计对象现在可以在读取加速度时使用注入的依赖项。BMA 150加速度计不与I²C接口的特定实现耦合在一起,我可以自由地提供任何我想要的实现。

使用C++接口是在C++中插入测试替身的最简单方法,但是它们也有一些小缺点。虚函数调用比直接调用方法要慢,这可能是一个问题,特别是在代码的时间关键部分。而且,由于虚函数引起的vtable会占用ROM或RAM中的空间,所以在非常有限的空间系统中可能会引发问题。

接下来要讨论的技术是链接其他对象文件,当涉及到速度或代码问题时,我们使用这种技术。虽然我在这里使用的示例是用C编写的,但在C++中使用这种技术也是一样的。使用这种技术时,依赖关系接口只是I²C代码的函数声明,我们不希望有任何虚函数调用。被测系统只需调用此依赖关系以获得返回值。这种技术的诀窍在于,在链接时切换实现依赖关系的代码。BMA 150加速度计对象对两个文件都是相同的,但当我想测试代码时,我会链接模拟I²C和测试对象文件,当我想编写最终应用程序时,我会链接树莓派I²C和应用程序代码。这里重要的部分是这两个文件包含相同函数的不同定义。可以使用makefile轻松实现这一点。当我想构建测试时,我会链接模拟I²C对象文件和测试对象文件,这实际上是在运行某些测试替身的控制下。当我想构建应用程序时,我会链接树莓派对象文件、树莓派I²C对象文件和应用程序对象文件,在两种构建中,我都链接相同的BMA 150加速度计对象文件,因此我尽可能地测试了最终应用程序中实际会出现的代码。

因为依赖关系在链接状态下被注入,所以在实际代码中不需要注入它,因此被测系统可以直接调用依赖关系,这意味着没有虚函数调用会减慢代码的速度或占用额外的代码空间或RAM。

所以通过链接其他对象文件来插入测试替身可以消除代码时间关键部分的虚函数调用,同时节省额外的代码空间。但它会增加构建系统的复杂性。这只是一个简要的总结,我们在C++中使用C++接口来插入测试替身,在时间关键的代码部分虚函数调用变得过于昂贵时,我们使用链接其他对象文件的方法。

所以,正如我之前提到的,硬件通常很短缺,因此当硬件不足时,我们使用构建服务器在目标平台上运行我们的测试。这是可能的,因为我们使用git作为源代码控制管理工具,而且所有的开发人员都有代码的分支。我们的构建服务器监视所有这些分支,当开发人员将分支推送到分支时,构建服务器将下载、构建并部署到目标,然后运行测试。开发人员可以通过弹出通知或IDE中的通知来获取构建结果,或者如果他们想要的话,可以查看信息屏。

我们有两种不同类型的集成测试。一种是用于检查我们的代码如何与硬件交互的集成测试。所示的照片是我为项目之一构建的集成测试设置。这些测试实际上检查我们的代码是否正确地与硬件通信。直到现在,我们完全隔离了硬件,所以我们不知道它是否正常工作。这些测试确保我们的硬件实际上是工作的。我们还有在模拟环境中运行的集成测试,这些测试检查无法使用硬件测试的交互。通常情况下,错误条件很难模拟,尤其是当你不希望它出现时。

最后一个实践是关于如何加速系统测试的,这是我们一直在尝试的一种技术。我们开始尝试这种技术,是因为在测试我们的一个项目时出现了问题。该项目是用于从水中去除杂质的,因此它具有需要几个小时才能运行的进程,你必须按下一个按钮,然后它会坐在那里几小时,不时地打开和关闭泵和阀门,这实际上减慢了一些反馈循环。我们有许多层反馈循环,以帮助我们监测代码质量,确保不会引入错误。第一个反馈循环是其他人的测试,通常在约五秒内给我们反馈。我们希望至少每五分钟运行一次单元测试。我们运行集成测试大约每半个小时一次,因为它们需要一段时间来运行。从提交代码进行代码审查到实际获得反馈通常需要大约半天的时间。对于新代码的系统测试,我们曾经需要一两天的时间,这实际上并不理想,这是由于那些长时间的进程引起的,但远远最慢的反馈来自我们的冒犯测试,有时候甚至需要一周的时间来运行,即使它们是自动化的。我们的冒犯测试通常不会在较低级别的驱动程序中发现错误,而是在我们更改应用程序层中的某些子过程时会发现错误,而这些更改不应该出现在所有子过程中。这是我们的一个较简单的系统测试,可以手动或自动运行,通常使用右侧的代码编译来运行。

为了获得更快的反馈,我们改变了测试实际运行的内容,我们删除了与硬件交互的代码,并用一个虚假的BSP层替换它。这个虚假的BSP层通常是我们为单元测试创建的测试替身、模拟或存根。这些测试现在纯粹在开发PC上运行,这意味着我们能够更短的时间内从系统测试中获得反馈。但唯一的问题是,我们只使用这种技术来检查我们的代码是否有问题,因为我们没有将其运行在真实系统上,所以我们不能说它一定是工作的,但它确实是一个很好的指示器,用来判断是否有问题。

总结一下,我已经讲解了如何通过双重目标来保持我们的测试运行速度,看了一下我们如何根据需要调整TDD风格,以及这如何影响我们测试的验证。我还介绍了如何插入测试替身以保持我们的测试隔离和可重复性,以及我们的其他一些测试实践。感谢您的倾听。

This post is licensed under CC BY 4.0 by the author.

Comments powered by Disqus.