测试是传统软件开发的最后一步。整个软件开发过程,需要收集要求、进行高层次的设计、详细设计、创建代码、进行部分单元测试,然后集成,最后才开始最终测试。
最佳的开发实践应包含代码检查这个步骤。然而代码检查一般只能找出70%的系统错误,因此完美的测试环节绝对必不可少。测试就像个复式记帐系统,可以确保将缺陷扼杀在最终推出的产品之前。
在所有其它的工程实践中,测试都被视为基本环节。比如,在美国,每一座联邦政府出资修建的桥都必须经过大量的风洞测试。而在软件领域,测试并没有很受重视。尽管测试是所有工程实践准则的关键部分,但编写测试程序却感觉是在浪费时间。好在嵌入式系统设计界内的许多领域已经将测试作为其工作的核心部分,他们认识到将这个关键步骤放在项目末期极不明智,因而主张同步地编写测试程序和应用程序。
嵌入式系统软件测试在诸多方面都与应用软件测试一样。不过,应用测试与嵌入式系统测试之间还是存在一些重要差异。嵌入式开发人员一般会用到基于硬件的测试工具,而这类工具通常不会用于应用开发过程中。此外,嵌入式系统一般都有些独一无二的特性,这些特性应该在测试计划中得以体现。本文将介绍测试和测试案例开发的基础知识,并指出整个嵌入式系统测试工作的特有细节。
何时测试以及如何测试
从图1可以看出,在可行的条件下,测试应尽早展开。一般来讲,最早的测试是由最初的开发人员进行的模块或单元测试。遗憾的是,开发人员大多对如何建构一整套测试例程以进行测试所知不足。由于精心设计的测试例程通常直到集成测试时才能使用,因此许多在单元测试过程中就能找出的缺陷直到集成测试时才会被发现。比如,硅谷的一家大型网络设备厂商为找出其软件集成问题的关键原因,进行了一项研究。这家厂商发现,在项目集成阶段找出的缺陷中,有70%是由在集成之前从没被执行过的程序所产生的。
图1:改正问题的成本。
单元测试:开发人员在单独进行模块级测试时一般是编写存根代码(stub code)取代余下的系统软硬件。在开发周期的这个环节,测试主要侧重于代码的逻辑性能。
通常,开发人员会分别使用某些平均值、高值或低值、以及某些超出范围的值(以测试代码的异常处理功能)进行测试。但这些基于“黑匣子”的测试仅能对模块中整个代码的一部分进行测试。
回归测试:测试不应是一劳永逸的。每次修改程序后都应该重新进行测试,以确保这些更改不会无意中“误伤”某些不相关的行为。
称为回归测试的这类测试,一般是通过测试脚本自动进行的。比如,如果你设计了一组100个输入/输出(I/O)测试,回归测试脚本会自动执行这100个测试,然后将输出与一组“黄金标准”输出进行对比。每次对代码的任何部分进行修改时,都要对包含被修改代码的整个程序运行整套回归测试程序包,以确保修改过程中不会“误伤”其余代码。
测试什么
因为没有一个实际的测试集可以证明一个程序是正确的,因此关键问题变成了哪个测试子集最有可能检测到最多的错误。选择合适的测试例程的问题被称为测试例程设计。虽然存在数十种测试案例的设计方法,但它们通常可归为两种截然不同的方法:功能测试和覆盖测试。
功能测试(也称为黑匣子测试)选择可评估实现与需求规格符合程度的测试。覆盖测试(也称为白匣子测试)选择可执行代码某些部分的测试例程。(过后,将详细讨论这两种方法。)
这两种测试都是对嵌入式设计进行严格测试所必需的。其中,覆盖测试表示代码的稳定性,所以这种测试是用于已经完成或将近完成的产品的。另一方面,可在编写要求文档时,同时编写功能测试。
事实上,从功能测试开始入手,可以最大限度地降低重复劳动和重写测试案例的工作。因此,在我看来,要先考虑功能测试。
每个人都同意先编写功能测试这个观点,有人认为,功能测试在系统集成阶段(而不是在单元测试时)最有用。以下是整合功能测试和覆盖测试方法的一个简单处理流程:
找出哪些功能未被功能测试完全覆盖。
找出每个功能的哪些部分没被执行。
找出需要哪些额外的覆盖测试。
运行新增的额外测试。
重复以上步骤。
何时停止测试?
最通用的停止标准(按可靠性排序)如下:
老板命令停止测试
新的测试周期找到的新缺陷少于X个
在没有发现任何新缺陷的情况下已经满足了某个覆盖阀限
无论你多么彻底地测试了程序,都无法保证找出所有缺陷。这引发了另一个有趣的问题:你可容忍多少缺陷?假设在极端软件压力测试过程中,你发现系统每进行大约20小时的测试就会锁定。你仔细地检查程序,但是仍无法找出这个错误的根源。这个时候你应该交付产品吗?
多少测试才“足够好”?这个我说不好。但遵循一些久经时间考验的规则总是好的:“如果方法Z预估Y行代码中的缺陷少于X个,那么就可放心地发布程序了。”也许有一天会出现这种标准。编程行业仍然相对年轻,还达不到类似建筑业那样的成熟度。
许多厚厚的建筑手册和大本规范是多年经验的结晶,它们可为建筑师、土木工程师和结构工程师提供按工期在预算内、建造一栋安全建筑所需的全部信息。偶尔虽仍会有建筑倒塌,但毕竟很少见。在编程行业制定出类似标准前,“多少测试才足够?”就是个主观判断问题。
选择测试案例
在理想情况下,你可能想要测试程序中每一个可能的行为。这意味着每一种可能的输入组合或者每一种可能的判定路径至少测试一次。
这是个崇高但完全不切实际的目标。比如,Glen Ford Myers在其《软件测试的艺术》一书中就描述了一个只用五个判定条件就可有1014个不同执行路径的小程序。他指出,如果你能够每五分钟就能编写、执行并验证一个测试例程的话,那么全面彻底地测试完这个小程序需要10亿年时间。
显然,理想的状况是无法实现的,因此你必须采用接近这种理想状况的标准。如你所见,功能测试与覆盖测试相结合可以提供合理的次优选择方案。基本方法是选择最有可能发现错误的测试(一部分功能测试,一部分覆盖测试)。
1.功能测试
功能测试一般称为黑匣子测试,因为在编写功能测试的测试例程时并没有涉及实际的代码。换句话说,没有触及到“匣子内”。嵌入式系统有输入和输出,并在输入和输出之间执行某些算法。黑匣子测试是根据对哪些输入应该是可接受的以及这些输入应与输出有何种关系的了解来进行的。黑匣子测试完全不了解输入与输出之间的算法是如何实现的。黑匣子测试的示例包括:
压力测试:有意使输入通道、内存缓冲器、磁盘控制器、存储器管理系统等过载的测试
边界值测试:表示特定范围内的“边界”的输入(例如,对于整数输入而言,是最大和最小整数以及-1、0、+1);以及应使输出在输出范围的类似边界出现跨变的输入值。
异常测试:能触发故障模式或异常模式的测试。
错误推测:根据以前的软件测试经验或者从测试类似程序获得的经验进行的测试。
随机测试:通常,这是效率最低的一种测试方法,但却仍然广泛用于评估用户界面代码的鲁棒性。
性能测试:由于性能预期是产品要求的一部分,因此性能分析属于功能测试的范畴。
由于黑匣子测试仅取决于程序要求及其I/O行为,因此一旦完成功能要求的编写,即可开发这类测试。这使得黑匣子测试例程的开发可以与余下的系统设计同步进行。
与所有测试一样,功能测试应被设计得具有破坏性,也即,要试图证明程序无法工作。这包括使输入通道过载、随意地敲打键盘,以及故意地做程序员认为会破坏其程序的所有事情。
作为研发产品经理,这是我的主要测试方法之一。如果产品在经过40个小时的极限测试(abuse testing)后,并没发现任何严重或者致命的缺陷,那么就可以发布这个产品了。如果找到了一个重大的缺陷,那么修正这个缺陷后,还必须重复前面的测试步骤。
2.覆盖测试
功能测试的缺点是其很少执行全部代码。覆盖测试则试图规避这个缺点,它采用的方法是(理想地)确保每一条代码语句、判定点或者判定路径都至少被测试一次。覆盖测试还可以显示已经访问的数据空间大小。
覆盖测试也称为白匣子测试或玻璃匣子测试,这类测试的设计需要全面了解软件的实现方式,也就是说,它要“看到匣子里面”。白匣子测试利用了源代码所能提供的方便。
白匣子测试充分借力了程序员对程序API、内部控制结构的知识,分享了程序员的异常处理能力。由于白匣子测试取决于具体的实现决策,因此要到应用代码完成后,才能动手设计这类测试。
从嵌入式系统的角度来看,覆盖测试是最重要的测试,这是因为只要你把握已在多大程度上对代码进行了测试,你就可很好地预警出现未发现缺陷的风险。白匣子测试的示例包括:
语句覆盖:选择的测试案例可以至少将程序中的每一条语句执行一次。
判定或分支覆盖:选择的测试例程可以使每一个分支(条件为真和假的路径)至少执行一次。
条件覆盖:选择的测试例程可以强制判定中的每一个条件(项)都包含所有可能的逻辑值。
理论上,白匣子测试可以利用或控制所需的任何对象来执行其测试。因此,白匣子测试可能使用JTAG接口强制设定特定的存储器值作为测试的一部分。实践上,白匣子测试可以分析逻辑分析仪报告的执行路径。
3.灰匣子测试
由于白匣子测试可以深入代码内部,因此与黑匣子测试相比,这类测试的维护成本更高。只要要求和I/O关系保持稳定,黑匣子测试就会一直有效;但每次修改代码后,可能都需要重新进行白匣子测试。因此成本效益最高的白匣子测试一般是那些在不深入编程细节的情况下利用实现知识进行的测试。
较少涉及代码细节的测试有时也称为灰匣子测试。当与“错误推测”配合使用时,灰匣子测试非常有效。如果你知道(或者至少猜到)代码中的弱点在哪里,那么你就可以设计出对这些弱点“施压”的测试案例。
因为这些测试覆盖了代码的特定部分,因此这些测试是灰匣子测试;因为这些测试是根据可能会出现哪些错误的猜测而选择的,因此这些测试是错误推测测试。
在整合新功能与稳定的旧代码库时,这种测试策略非常有用。由于代码库已经过全面的测试,因此将测试重点集中在新、旧代码交集处可以起到事半功倍的效果。