大型的软件程序(即超过500条语句的程序)需要特别的测试对策。在本章中我们将探讨构建大型程序测试的第一个步骤:模块测试。

模块测试(或单元测试)是对程序中的单个子程序、子程序或过程进行测试的过程,也就是说,一开始并不是对整个程序进行测试,而是首先将注意力集中在对构成程序的较小模块的测试上面。这样做的动机有三个。首先,由于模块测试的注意力一开始集中在程序的较小单元上,因此它是一种管理组合的测试元素的手段。其次,模块测试减轻了调试(准确定位并纠正某个已知错误的过程)的难度,这是因为一旦某个错误被发现出来,我们就知道它在哪个具体的模块中。第三,模块测试通过为我们提供同时测试多个模块的可能,将并行工程引入软件测试中。

模块测试的目的是将模块的功能与定义模块的功能规格说明或接口规格说明进行比较。为了再次强调所有测试过程的目的,这里的测试目标不是为了说明模块符合其规格说明,而是为了揭示出模块与其规格说明存在着矛盾。在本章中,我们从以下三个方面来探讨模块测试:

1.测试用例的设计方式

2.模块测试及集成的顺序

3.对执行模块测试的建议

一、测试用例设计

在为模块测试设计测试用例时,需要使用两种类型的信息:模块的规格说明和模块的源代码。规格说明一般都规定了模块的输入和输出参数以及模块的功能。

模块测试总体上是面向白盒测试的。其中一个原因是如果对大一点的软件进行测试,例如一个完整的程序(其实是后续的测试过程中所针对的对象),白盒测试不容易展开。第二个原因是,后续的测试过程着眼于发现其他类型的错误(举例来说,这些错误不一定与程序的逻辑结构有关,比如程序未能满足其用户需求)。因此,模块测试的测试用例的设计过程如下:使用一种或多种白盒测试方法分析模块的逻辑结构,然后使用黑盒测试方法对照模块的规格说明以补充测试用例。

无论采用哪种逻辑覆盖方法,第一步都是要列举程序中所有的条件判断。

二、增量测试

这里需要考虑的问题是:软件测试是否应先独立地测试每个模块,然后再将这些模块组装成完整的程序?还是先将下一步要测试的模块组装到测试完成的模块集合中,然后再进行测试?第一种方法称为非增量测试或“崩溃”测试,而第二种方法称为增量测试或集成。

单元测试框架unittest 单元测试模块_自底向上

图1.包含6个模块的程序范例

如图1所示的程序可作为一个例子,矩形框代表程序的6个模块(子程序或过程),连接模块间的线条代表程序的控制层次,也就是说,模块A调用模块B、C和D,模块B调用模块E等等。作为传统方法的非增量测试是按如下方式进行的:首先,对6个模块中的每一个模块进行单独的模块测试,将每个模块视为一个独立实体。根据环境(例如,是人机交互式的,还是使用批处理计算工具)和参与人数,这些模块可以同时或按次序进行测试。最后,将这些模块组装或集成为完整的程序。

测试单独的模块需要一个特殊的驱动模块(driver module)和一个或多个桩模块(stub module)。举例来说,测试模块B,首先要设计测试用例,然后将测试用例作为输入参数由驱动模块传递给模块B。驱动模块是人们编写的一个小模块,用来将测试用例驱动或传输到被测模块中(也可以用测试工具替代)。驱动模块还必须向测试人员显示模块B的结果。此外,由于模块B调用了模块E,所以还必须使用一个额外的组件,该组件在模块B调用模块E时接受模块B的控制指令。这就由桩模块来完成,它是一个被命名为“E”的特殊模块,用来模拟模块E的功能。当所有6个模块的模块测试都完成之后,就将这些模块组装成完整的程序。

另一种可选择的方法是增量测试。不同于独立地测试每个模块,增量测试首先将下一个要测试的模块组装到前面已经测试过的模块集合中去。

现在要给出对图1所示的程序进行增量测试的步骤还为时太早,因为还有大量可能的增量方法。一个关键问题是我们究竟是从程序的顶部开始,还是从底部开始进行测试。由于这个问题将在下一节中讨论,我们暂且假设从底部开始测试。第一步先测试模块E、C和F,可以并行测试(由三个人进行),也可串行进行。请注意,我们必须要为每个模块准备一个驱动模块,但不是桩模块。下一步是测试模块B和D,但不是单独地测试它们,而是分别将其与模块E和F组装在一起。换言之,要测试模块B,应编写驱动模块并集成测试用例,将模块B和E组合起来测试。将下一个要测试的模块组装到前面已经测试过的模块集合或子集中去,这个增长的过程会一致进行到测试完最后一个模块(本例中是模块A)为止。请注意,这个过程也可以自顶向下进行。

下面是几个显而易见的结论:

1、非增量测试所需的工作量要多一些。对于图1所示的程序,需要准备5个驱动模块和5个桩模块(假设顶部的模块不需要驱动模块)。自底向上的增量测试需要5个驱动模块,但不需要桩模块。自顶向下的增量测试需要5个桩模块,但不需要驱动模块。增量测试所需的工作量要少一些,因为使用了前面测试过的模块来取代非增量测试中所需要的驱动模块(如果从顶部开始测试)或桩模块(如果从底部开始测试)。

2.如果使用了增量测试,可以较早地发现模块中与不匹配接口、不正确假设相关的编程错误。这是由于今早地对模块组合进行了集成测试。然而,如果采用非增量测试,只有到了测试过程的最后阶段,模块之间才能“互相看到”。

3.因此,如果使用了增量测试,调试会进行的容易一些。我们假定存在着与模块间接口或假设相关的编程错误(根据经验而来的合理假设),那么,如果使用非增量测试,直到整个程序组装之后,这些错误才会浮现出来。到了这个时候,我们就难以定位,因为它可能存在于程序内部的任何位置。相反,如果使用增量测试,这种类型的错误就很容易发现,因为该错误很可能与最近添加的模块有关。

4.增量测试会将测试进行的更彻底。如果当前正在测试模块B,要么是模块E,要么是模块A(取决于测试是从底部还是从顶部开始的)被当做结果而执行。虽然模块E或模块A先前已经进行了完全的测试,但将其作为B的模块测试结果而执行,则会诱发出一个新的情况,可能会暴露出先前测试过的模块E或模块A中存在的一个新缺陷。另一方面,如果使用的是费增量测试,对模块B的测试仅影响到其本身。换言之,增量测试使用先前测试过的模块,取代了非增量测试中使用的桩模块或驱动模块。因此,到最后一个模块测试完成时,实际的模块经受到了更多的检验。

5.非增量测试所占用的机器时间显得少一些。如果使用自底向上的方法测试图1中的模块A,在执行A的过程中,模块B、C、D、E和F也会执行到。而在对模块A的非增量测试中,仅会执行模块B、C和E的桩模块。自顶向下的增量测试的情况也是如此。如果测试的是模块F,那么在执行模块F时还会执行模块A、B、C、D和E,而在对模块F的非增量测试中,仅有模块F的驱动模块与其一起执行。因此,完成一次增量测试所需执行的机器指令,显然多于采用非增量测试方法所需的指令。但此消彼长的是,非增量测试要比增量测试需要更多的驱动模块和桩模块,开发这些驱动模块和桩模块是要占用机器时间的。

6.模块测试阶段开始时,如果使用的是非增量测试,就会有更多的机会进行并行操作(也就是说,所有的模块可以同时测试)。对于大型的软件项目(模块和人员都很多),这可能十分重要,因为在模块测试开始之时,项目的人员数量常常处于最高峰。

总的来说,第1条~第4条结论是增量测试的优点,而第5、6条结论是其不利之处。考虑到计算机行业当前的趋势(硬件成本已经降低而且势必会持续下去,硬件的功能不断增加,而人力劳动成本和软件错误的代价在不断增长),再考虑到错误发现得越早,改正它的成本也越低,我们会看到第1条至第4条结论的重要性日益突出,而第5条结论越来越显得不那么重要。如果有一个缺点的话,第6条结论似乎确是一个薄弱的缺点。从而我们可以得出结论,增量测试要更好一些。

三、自顶向下测试与自底向上测试

在上一节结论的基础上,即增量测试要优于非增量测试,本节将讨论两种增量测试策略:自顶向下的测试和自底向上的测试。然而在讨论它们之前,先要澄清几个误解。

首先,“自顶向下的测试”、“自顶向下的开发”和“自顶向下的设计”常用作近义词。“”“自顶向下的测试”和“自顶向下的开发”确实是同义词(表示安排模块的编码和测试顺序的策略),但“自顶向下的设计”则完全不同并且是独立的概念,按自顶向下模式设计的程序既可使用自顶向下的方式,也可使用自底向上的方式进行增量测试。

其次,自底向上的测试(或自底向上的开发)常被错误地当作非增量测试。原因在于自底向上的测试的开展方式与非增量测试是相同的(即对底层或终端模块进行测试),但是就如我们从上节看到的那样,自底向上的测试是一种增量测试。

最后,由于两种策略都属于增量测试,因此增量测试的优点在这里就不再赘述,仅讨论自顶向下测试与自底向上测试的差异。

1.自顶向下的测试

自顶向下的测试是从程序的顶部或初始模块开始。测试开始之后,挑选哪一个后续模块进行增量测试没有唯一正确的方法,唯一的原则是:要成为合乎条件的下一个模块,至少一个该模块的从属模块(调用它的模块)事先经过了测试。

我们用图2来说明这种测试策略。A至L代表程序的12个模块。假定模块J包含程序的I/O读操作,而模块I包含I/O写操作。

单元测试框架unittest 单元测试模块_自底向上_02

第一步是测试模块A,测试要求必须编写出代表B、C和D的桩模块。遗憾的是,我们经常会错误理解桩模块的生成。作为佐证,我们可能经常会听到这样的说法,“一个桩模块仅需要写一条‘我们进行到了这一步’的消息”、“在很多情况下,模拟的桩模块仅仅只是存在而不起任何作用”。在大多数情况下,这些说法都是错误的。由于模块A调用模块B,模块A就需要模块B执行一些操作,这些操作很可能就是返回给模块A的结果(输出参数)。如果桩模块仅仅只是返回了控制,或显示一条出错信心却没有返回一个有意义的结果,模块A就会发生失效,这并不是由于模块A存在错误,而是因为桩模块未能模拟出相应的模块。此外,桩模块仅仅返回一个“已经连通(wired-in)”的结论是不够的。举例来说,让我们考虑编写一个桩模块,代表一个平方根程序,一个数据库表搜索程序,一个“获取相关主文件记录”程序或诸如此类的程序等。如果这个桩模块仅仅返回一条固定的“已经连通”输出,却没有返回调用模块此次调用所希望的特定值,那么调用模块将会发生失效或是产生一个混乱的结果。因此,编写桩模块是很关键的。

另一个需要考虑的地方是采取什么样的形式将测试用例提交给程序,这是一个非常重要的问题,大多数对自顶向下测试的研究都没有提到这一点。在我们给出的例子中,存在这样的问题:如何向模块A提交测试用例?由于在典型的程序中,顶部模块既不接收输入参数,也不执行输入/输出操作,因此问题的答案不是显而易见的。答案是:测试数据是通过其一个或多个桩模块提交给模块(此处为模块A)的。为了说明这一点,假设模块B、C和D的功能如下:

B--获取事务文件的概要。

C--判断每周的状态是否满足限额。

D--生成每周总结报告。

那么自桩模块B返回的一个事务概要就是模块A的一个测试用例。桩模块D可能将其输入数据写到打印机的语句,这样就可以检查每一个测试的结果。

在本程序中还存在另一个问题。由于假设模块A仅调用模块B一次,问题是如何将多个测试用例提交给模块A。一个解决方法是编写出桩模块B的多个版本,每一个版本都将一个各不相同的有效测试数据集返回给模块A。为了执行这些测试用例,程序需要执行多次,每次都使用桩模块B的不同版本。另一种可选择的方法是将测试数据放置在外部文件中,由桩模块B读取并返回给模块A。根据前面的讨论,对于任何一种情况,开发桩模块通常要比实际理解的更为困难。而且,由于程序的特点所致,通过被测模块之下的多个桩模块来传送测试数据常常是必需的(即被测模块通过调用多个桩模块来获得要处理的测试数据)。

模块A测试完成之后,就用一个实际的模块代替其中的一个桩模块,而该模块需要的桩模块也被添加进来。举例来说,图3就显示了该程序的下一个版本

 

单元测试框架unittest 单元测试模块_模块测试_03

测试完顶部模块之后,接下来可能的测试序列有很多。举例来说,如果我们要执行所有的测试序列,大量可能的模块序列中的四个序列如下:

1.A B C D E F G H I J K L

2.A B E F J C G K D H L I

3.A D H I K L C G B F J E

4.A B F J D I E C G K H L

如果可以进行并行测试,可能还有其他的选择。举例来说,模块A测试结束之后,一位程序员可能会选取模块A,测试模块A-B的组合,另一位程序员可能会测试模块A-C的组合,而第三位程序员可能会测试模块A-D的组合,总的来说,不存在最佳的模块序列,但却有下面可供考虑的两项指南:

1.如果程序中存在关键部分(例如模块G),那么在设计模块序列时就应将这些关键模块尽可能早地添加进去。所谓“关键部分”可能是某个复杂的模块、某个采用新算法的模块或某个被怀疑容易发生错误的模块。

2.在设计模块序列时,应将I/O模块尽可能早地添加进来。

第一项指南的动机非常清楚,但第二项指南的动机则需要进一步的讨论。回想一下,桩模块的问题就是一部分桩模块须包含测试用例,而另一部分桩模块则须将其输入写到打印机中或显示出来。然而,接收程序输入的模块一旦被添加进来,测试用例的描述就相当简单了,其采用的形式就与最终程序接收的输入一样(例如,通过事务文件或终端)。相似地,一旦执行程序输出功能的模块被添加进来,桩模块中就可能无需再放置输出测试用例结果的代码。因此,如果模块J和模块I是I/O模块,而模块G执行某些关键操作,那么增长序列可能是:

A B F J D I C G E K H L

而第6个增量之后,程序可能是如图4所示的形式。

 

单元测试框架unittest 单元测试模块_自底向上_04

一旦到达了如图4所示的中间阶段,测试用例的描述以及测试结果的检查就简单化了。由于有一个程序实际运行的框架版本,也就是执行实际的输入和输出操作,就带来了另一个好处。然而,桩模块依然模拟着部分“内幕”。这个早期的程序版本有以下优点:

  •  可以使我们发现人为因素的错误和问题。
  • 可以将程序演示给最终用户看。
  • 证明程序的整体设计是合理的。
  • 起到精神上的鼓舞作用。

然而另一方面,自顶向下策略还有一些严重缺陷。假定我们当前的测试状态如图4所示,下一步是用模块H取代桩模块H。这时(或更早一些)我们所要做的是使用本章前面所述的方法,为H设计一个测试用例集。但是请注意,这些测试用例采用的是向模块J的实际程序输入的形式。这带来了一些问题。

首先,由于在模块J和模块H之间存在着中间模块(即模块F、B、A和D),我们会发现无法将测试过模块H中所有预先确定的情况的测试用例提交到模块J中去。举例来说,如果H是如图4所示的BOUNS模块,由于中间模块D的存在,就无法生成部分用例。

其次,由于H和程序中测试数据引入点之间存在着“距离”,即使存在着测试全部状态的可能性,要决定往模块J中输入什么样的数据来测试到H中的所有状态,通常也是一项困难的脑力劳动。

最后,由于一个测试显示出来的输出可能来自于一个与被测模块相距甚远的模块,要将显示出来的输出与此模块的实际执行情况联系起来非常困难,甚至是不可能的。想象一下将模块E添加到图4中,每个测试用例的结果都取决于检查模块I的输出,但是由于存在着中间模块,要推演出模块E的实际输出(即返回给模块B的数据)可能是很困难的。
自顶向下的测试策略取决于其使用的方法,可能还存在两个更深层次的问题。人们会偶尔感觉到它可能与程序的设计阶段重叠。举例来说,如果我们正在设计如图2所示的程序,可能会觉得在最先的两个层次设计完成之后,在下面层次的设计进行的同时就可以对模块A至模块D进行编码和测试了。正如我们在其他地方所强调的那样,这往往不是明智之举。程序设计是一个迭代的过程,这意味着当我们在设计程序结构的较低层次时,可能会对较高层次进行合理的变更或改进。如果程序的较高层次已经完成了编码和测试,那么这些理想的改进就会被摒弃,最终成为一个不明智的决策。

实践中时常会发生的一个终极问题是,在进行到下一个模块前未能穷举测试此模块。这来自于两个原因:一是由于将测试数据嵌入桩模块中存在困难,二是由于程序的较高层次通常会为较低层次提供资源。在图2中,我们看到,对模块A的测试需要用到针对模块B的多个版本的桩模块。在实践中,我们会倾向于说“由于这需要投入很多工作,我现在就不执行模块A的所有测试用例,一直等到将模块J添加到程序中,此时引入测试用例就容易多了,我会记得在那时完成对模块A的测试”。当然,这里的问题是到了那个较晚的时间点,我们可能会忘记模块A中剩下的测试。另外,因为较高的层次常常会提供资源给较低层次(例如打开文件)使用,有时除非到了使用资源的低层次模块测试完成之后,我们很难判断这些资源提供得是否正确(例如,文件是否以正确的属性打开)。

2.自底向上的测试

下面讨论自底向上的增量测试策略。在大多数情况下,自底向上的策略与自顶向下的策略是相对立的,自顶向下的测试的优点成为自底向上测试的缺点,而自顶向下测试的缺点又成为自底向上测试的优点。正因为这一点,我们对自底向上测试的介绍就简短一些。

自底向上的策略开始于程序中的终端模块(此类模块不再调用其他任何模块)。测试完这些模块之后,同样没有最佳的方法来挑选要进行增量测试的下一个模块,唯一正确的原则是,要成为合乎条件的下一个模块,该模块所有的从属模块(它调用的模块)都已经事先经过了测试。

回到图2,第一步是测试E、J、G、K、L和I中的部分或全部模块,既可以串行进行,也可以并行进行。要做到这一点,每一模块都需要一个特殊的驱动模块:既包含着有效的测试输入、调用被测模块且将输出显示出来(或将实际输出与预期输出作比较)的模块。有别于使用桩模块的情况,由于驱动模块可以交迭地调用被测模块,因此不需要为驱动模块提供多个版本。在大多数情况下,开发驱动模块要比开发桩模块更容易些。

如同前面的例子一样,影响测试序列的因素是模块的关键程度。如果我们觉得模块D和模块F最为关键,那么应该自底向上增量测试的某个中间状态可能如图5所示。接下来的步骤可能是测试模块E,然后再测试模块B,将模块B与先前测试过的模块E、F和J组装起来进行测试。

单元测试框架unittest 单元测试模块_单元测试框架unittest_05

自底向上策略的一个不足是,它没有早期程序框架的概念。事实上,直到最后一个模块(模块A)被添加进来,才形成了可工作的程序,也就是完整的程序。尽管I/O功能可以在整个程序集成之前进行测试(I/O模块在图5中用到),早期程序框架的优点在这里体现不出来。
自顶向下方法中无法建立所有测试环境的问题,在这里都不复存在。如果将驱动模块看作是一个测试探针的话,那么该探针是直接放入到被测模块中去的,不会受到中间模块的困扰。检查一下与自顶向下方法相关的其他问题,我们再也不会作出让设计和测试重叠的不明智决定,因为自底向上的测试要直到程序底层设计完成之后方才开始。同样,在没有测试完一个模块之前就开始另一个模块测试的问题也不会存在,这是因为使用自底向上的测试不再有如何将测试数据绑定到桩模块中去的烦恼。

3.比较

比如自顶向下的方法和自顶向上的方法,就像增量测试和非增量测试一样区分分明,那么比较起来很容易,但遗憾的是,情况并非如此。表1概况了它们之间相对的优点和不足(前面讨论过的两者皆有的优点除外,也就是增量测试的优点)。每种方法的第一个优点似乎是决定性的因素,但是也没有证据表明主要的缺陷会更容易发生在典型程序的顶部或底层。最保险的判断方法是,根据特定的被测程序,对表1中所示的各因素进行权衡。由于这里缺乏一个规程,自顶向下测试第四个缺点的严重后果,以及有可用的测试工具减少了对驱动模块而不是桩模块的需求,这样似乎给自底向上的策略带来了优势。

除此之外,自顶向下的方法和自底向上的方法很明显都不是唯一可能的增量测试策略。

单元测试框架unittest 单元测试模块_自底向上_06

4.执行测试
接下来介绍模块测试的其他部分如何实际进行测试。这里我们给出了一系列操作的提示和指南。

当测试用例造成模块输出的实际结果与预期结果不匹配的情况时,存在两个可能的解释:要么该模块存在错误,要么预期的结果不正确(测试用例不正确)。为了将这种混乱降低到最小程度,应在测试执行之前对测试用例集进行审核或检查(也就是说,应对测试用例进行测试)。

使用自动化测试工具可以使测试过程中的枯燥劳动减到最小。举例来说,现在已有测试工具可以降低我们对驱动模块的需求。流程分析工具可以列举出程序中的路径、找出从未被执行的语句(“不可达”代码),以及找出变量在赋值前被使用的实例。

在执行测试时,应该查找程序的副作用(即模块执行了某些不该执行操作的情况)。一般情况下,这些情况都是很难发现的,但如果在测试用例执行完之后,检查那些不应有变动的模块输入,可能会发现一些错误实例。

程序员不应测试自己编写的模块,而应交换模块进行测试;编写调用模块的程序员始终是测试被调用模块的最佳候选人。注意,这仅仅适用于测试;对模块的调试一般应当由编程人员本人进行。应避免随意丢弃测试用例,应将它们按某种格式记录下来,以便将来可以重新使用它们。如果发现某一部分模块存在大量错误,那么很有可能这些模块甚至包含着更多的错误,只是尚未检查出来而已。这样的模块应该进行更进一步的测试,可能还需要进行额外的代码走查或检查。最后,记住模块测试的目的不是证明模块能够正确的运行,而是证明模块中存在错误。