单元测试可以帮助您编写更好的嵌入式软件。如果您对自己的单元测试的好处感兴趣,那么您应该从测试驱动开发(TDD)开始。
什么是TDD?
测试驱动开发(TDD)是编写软件的迭代过程,其中单元测试是在实现之前开发的。这是一个紧密的反馈循环,包括以下步骤:
写一个单元测试,看它失败。
编写足够的代码来通过测试。
改进代码(不改变其行为)。
对于测试从失败(红色)到传递(绿色)的方式,这些步骤通常被称为“红色,绿色,重构”,最后有机会改进代码和测试(重构)。在开发期间,该循环一遍又一遍地重复数百或数千次。
在这个过程中,编写测试是推动 软件开发的动力。您可以在编写代码之前考虑代码要执行的操作,并将该想法保存在单元测试中。只有这样你才能编写下一段代码。这迫使您非常清楚您希望代码执行的操作。
通过每次通过测试,您可以更自信地确保软件正常运行。而且,由于每一段代码都是由测试驱动的,因此最终会得到很好的测试覆盖率 - 使用单元测试测试的代码量。
不要浪费你的时间编写不可测试的代码
单元测试的一个问题 - 特别是当你刚刚开始时 - 是你可能最终编写难以测试的代码。
例如,您可能有一些需要访问的内部状态,但您不想公开它。或者,您测试的单元可能存在许多难以模拟的复杂依赖项。
编写可测试的代码需要经验,但是你怎么能得到它?嗯,事实证明,如果你从TDD开始, 你不需要那种经验。 首先编写测试时,不能编写不可测试的代码。
你从一开始就会成功,所以你更有可能真正采用单元测试作为练习。想象一下两个场景:
场景1:您编写了大量代码,然后尝试弄清楚如何测试它。当你无法快速弄明白时,你放弃了,因为你有软件可以运送!也许你已经了解了如何让你的代码下次更容易测试。
场景2:您对某些软件模块有创意,但您不确定如何测试它。所以,你花了一点时间搞清楚如何编写第一个测试。然后你写一些代码让它通过。好的!你刚刚写了第一次单元测试。干得好,你刚刚学到了一些东西。重复,直到您有一个完全经过单元测试的模块。恭喜......你刚刚学到了很多关于单元测试的知识。
TDD是一种体验放大器。你从实践中学习。TDD鼓励您做正确的事情,以便您更快地学习。学的越多,编写单元测试就越好。
以测试为导向的思维方式
在试驾时,你会想到你编写的代码有点不同。 您只需担心软件要做的下一件事 ,而不是试图跟踪您希望软件执行 的 所有操作。让我们看一个例子来说明。
讨论TDD的一个我最喜欢的例子是 命令解析器,因为它在很多嵌入式系统中使用。通常,您希望您的系统能够与外部世界交谈,以便它实际上可以做有趣的事情。这可能只是用于配置的简单串行接口,也可能是与其他设备或Internet的连接。
根据我的经验,这些类型的接口确实可以从单元测试中受益。它们通常是定制的,并且可以很快变得复杂 - 通过代码的许多路径和许多错误案例来处理。并且,由于这是系统的外部接口,因此您不能总是期望另一端的人表现得很好。通过一些单元测试,您可以确保一切按预期工作 - 并处理所有错误情况。
考虑一个带有简单命令解析器的嵌入式系统。它接收来自某个地方的字符流(例如,可能是串行或USB,但我们的解析器实际上并不关心半导体社区)并且在接收到特定字符序列时执行某些操作。在这种情况下,系统中有一个可以控制的扬声器。
大多数嵌入式软件开发人员的第一直觉是开始在command_parser.c中编写一大堆代码。测试驱动的方法是不同的。
第一步是: 写一个测试,看它失败。为了编写测试,您需要弄清楚您希望命令解析器做的第一件事。如果有一个协议规范(哈,对!)你可能会看一下。如果没有,您可以立即决定您需要首先执行的代码。这个怎么样?
收到“m”字符后,扬声器静音。
好吧,这是一个简单,小巧,明确定义的功能。让我们编写一个单元测试,如果执行此代码的话,它将通过。
哇,这只是一个单一的测试,但这里有很多设计决定。
为命令解析器定义了一个新函数:command_parser_put_char()。这就是将字符输入命令解析器的方式,以及如何传入“m”进行测试。
还有另一个为扬声器模块定义的新功能:speaker_mute()。这是扬声器的实际静音。您知道在调用此函数时测试已通过。
由于这是一个单元测试,因此将单独测试command_parser,并且不会调用speaker_mute()的真实版本。相反,将提供模拟函数(可能包括在内mock_speaker.h),并且EXPECT_CALL宏是用于使用任何模拟机制的替代。它将失败测试speaker_mute()是否未调用该函数。
请注意,这些功能都不存在。但是......你刚刚定义了你想要的确切行为,你有一个明确的方法来测试它。如果你现在要进行测试,它肯定会失败。实际上,它将无法编译,因为函数不存在。
现在进行第二步:编写足够的代码来通过测试。现在是时候编写一些代码了!以下是command_parser_put_char()进行测试通过所需的最简单的代码 :
请注意,您还需要设置模拟speaker_mute()。具体细节取决于您在项目中如何使用模拟。
测试现在应该通过......但请注意,我们甚至不检查我们收到的字符!这似乎有点愚蠢,但TDD的目标之一是最大化未完成的工作量。
现在这是一个微不足道的例子。但是,当代码变得更复杂时,任何 实际上没有编写的代码 都会使您的应用程序更简单易懂(更好)。当你只做你想做的工作时,那些关心时间表和预算的人也会更开心。
TDD周期的最后一步是重构,您 可以在不改变其行为的情况下改进代码。此步骤的关键是您已经有单元测试来验证行为。因此,您可以自由地尝试更改代码,因为如果您更改了行为,失败的测试会立即告诉您。但是,由于这只是第一次测试,所以还没有太多改进。
命令解析器的其余部分通过重复TDD循环来实现。那么,你希望你的命令解析器下一步做什么?怎么样:
收到“u”字符后,扬声器将取消静音。
好吧,这是另一个好的。这是一个测试:
当你改进命令解析器实现以通过测试时,它可能看起来像这样:
现在处理错误案件怎么样?如果收到意外的角色怎么办?
收到意外字符时,扬声器静音状态不变。
这里有足够的代码使这个测试通过:
你有什么想重构的吗?如果您更喜欢switch语句,可以继续更改它:
嗯,这个改变是否打破了什么?没有汗,只是运行你的测试找出来。
从这里开始,您只需继续运行TDD循环 - 添加测试和功能 - 直到命令解析器执行您需要的所有操作。
作为练习,请考虑有另一个命令允许您设置音量级别。也许一个“v”后跟一个数字。你会怎么写测试?这也将引入新的错误案例。如果号码不是有效的怎么办?如果你得到一个“v”,紧接着是“m?”怎么办?您可以看到这可能很快变得复杂。但是你可以为每一个错误案例编写一个测试!在每种情况下,您都确切地知道命令解析器应该如何表现 - 如果不是,单元测试会让您知道。
将复杂问题简化为更简单的问题
构建命令解析器(或任何软件模块)是一项复杂的任务。如果您在开始之前尝试设想完成的模块,则可能很困难。如果您实现以前从未做过的事情,尤其如此,因为您没有应用任何设计模式的经验。一下子将所有这些塞进你的大脑会导致很高的认知负担。
但是测试驱动的方法可以减少你的认知负担,释放你的大脑来编写一些非常棒的软件。看看我们在命令解析器示例中做了些什么。在每一步中,我们只关注要添加的下一部分功能 - 而不是我们将来可能添加的所有功能。将所有其他问题推迟到以后允许您将所有注意力集中在一件事情上。
工具
所以TDD很棒,对吧?困难的一个问题是嵌入式软件(即C)测试工具并不是那么好。当你在做TDD,你创建和运行测试,所有的时间。这意味着您需要添加新测试并运行它们。如果这些事情都很困难,你可能会感到沮丧并放弃。
虽然它变得越来越好。Ceedling(使用Unity和CMock)等工具可以帮助您快速启动和运行。Ceedling提供自动测试发现,模拟生成和测试执行,让您的生活更轻松。
TDD未在嵌入式软件中广泛使用。如果您开始尝试使用TDD,那么您将会突破嵌入式软件开发的优势(您可以与您的应用程序和Web开发人员朋友讨论它!)。仍然有很多东西需要学习,但是你将会改进自己和你的代码。我想你会喜欢你发现的东西。