极限编程的集成测试工具-Dunit
作者 陈省(hubdog)
简介
极限编程(Xtreme Program)的测试理念是,在开发程序的之前或者同时就要编写出相应的测试程序,而不是象通常那样等到测试阶段再来编写测试程序。 当程序完成时,它的测试程序也应该已经完成。从软件工程的普遍规律来看,每修正一个bug的同时会以20%的几率引入新的bug。而XP的测试方式是, 每当修改Bug或者重构代码后都要重复测试所有的测试案例,以免在修改代码时引入新的Bug,只有经过测试的重构才是有效的重构。而且,所有测试案 例的测试应该是全自动的,可以无需操作人员的介入,不论案例是否通过都将自动完成全部的测试。(注意:即使我们不使用XP的开发方式,对系统能够进行集成测试仍然是一个很好的做法。)
XP的测试框架中最重要的组成部分是TestCase(测试案例),TestResult(测试结果)和TestSuite(测试组)。其中TestSuite可以嵌套包含其它的 TestSuite和TestCase,构成一个简单的Composite模式。也就是说XP的测试是结构化的,是分层次的,类似于一颗树,在测试过程中我们可以选择测 试其中某些部分。基于上述的理念,极限编程创始人之一Kent Beck编写了JUnit测试框架用于Java的测试。下面我们将要介绍的DUnit则是一个基于极 限编程(Xtreme Program)理论的Delphi和Kylix集成测试框架。DUnit是一个开放源码的项目,它的官方网址位于http://sourceforge.net/projects/dunit
。还要说明的一点是DUnit是白盒测试工具,和基于脚本控制的程序进行交互的黑盒测试工具(如TestComplete, WinRunner等)是不同的。
文档目录结构
下面是DUnit开发包的目录结构:
目录名称 |
说明 | |||
DUnit |
| |||
|
src |
DUnit源代码 | ||
|
doc |
一些说明文档,还包括Time2Help生成Api帮助文档 | ||
|
Contrib |
非核心的一些工具 | ||
|
|
XPGen |
一个可以自动生成Test Case的工具。 | |
|
tests |
DUnit框架本身的测试案例 | ||
|
examples |
| ||
|
|
cmdline |
示范如何在控制台程序中使用DUnit | |
|
|
collection |
一个类似于Java的容器类的Delphi类的代码以及它的测试代码。 | |
|
|
registration |
演示几种注册测试案例方法的例子 | |
|
|
structure |
组织测试代码的方法 | |
|
|
|
diffunit |
将测试案例放在独立的单元中的示例 |
|
|
|
sameunit |
将测试案例代码和被测试的程序代码放在同一个单元中的示例 |
|
|
registry |
一个建立读写注册表的工具及其测试案例建立的教程。 | |
|
|
embeddable |
示范如果将 GUITestRunner嵌入到其他界面中的示例 | |
|
|
TListTest |
为Delphi中的TList类编写的测试案例 |
环境配置
要想使用DUnit,必须将DUnit的路径添加到Delphi的单元搜索路径中。在Delphi IDE中点击菜单Tools | Environment Options, 然后在弹出的对话框中选中Library页面,添加路径到Library Path中。见下图示意:
创建测试案例
现在假设你是一个测试人员,你的研发队伍中有人写了一个计算阶乘的函数,函数采用递归实现,代码如下:
unit Utility;
interface
function Factorial(N:Word):Integer;
implementation
function Factorial(N:Word):Integer;
begin
if N=0 then
Result:=1
else
Result:=N*Factorial(N-1);
end;
end.
你的任务是测试这个函数是否真的能够给出阶乘运算结果。首先新建一个测试工程,取名为ProjectTests,
然后将默认创建的窗体从项目中删除,添加DUnit的测试框架代码,代码示意如下:
program ProjectTests;
uses
TestFramework,
Forms,
GUITestRunner;
{$R *.RES}
begin
Application.Initialize;
GUITestRunner.RunRegisteredTests;
end.
然后是创建要测试的案例,每个测试案例都要定义为TTestCase的子类,TTestCase类定义在TestFramework.pas
单元中,现在新建一个单元,名为TestUnit,然后在Uses部分添加TestFramework,定义一个TTestCase的子类
TFirstTestCase类,同时我们还要添加测试方法TestFatorial(注意:通常定义测试方法的前缀为Test),代码
示意如下:
unit TestUnit;
interface
uses
TestFrameWork, Utility;
type
TFirstTestCase = class(TTestCase)
private
protected
published
procedure TestFactorial;
end;
implementation
{ TFirstTestCase }
procedure TFirstTestCase.TestFactorial;
begin
Assert(Factorial(3)=6, '阶乘运算错误!');
end;
initialization
TestFramework.RegisterTest('TestUnit Suite', TFirstTestCase.Suite);
end.
注意,每增加一个TestCase,我们都要将其注册到测试框架中,这需要在initialization部分增加测试案例注册代码:
TestFramework.RegisterTest('TestUnit Suite', TFirstTestCase.Suite);
另外,可以注意到测试方法TestFactorial必须是Published存取级别的方法,同时该方法必须没有任何的参数定义,
这是由于Delphi的RTTI(运行时类型信息)机制的限制。TestFactorial方法使用Assert断言来检查阶乘函数的结果是否正确。
下面我们就可以运行一下这个测试程序了,运行后会显示一个测试框架的GUI界面,如下图所示:
可以看到最上边的面板按层次关系列出了当前的测试案例,首先是TestSuite,然后是TestCase,最下面是测试方法。
每个节点前都有一个CheckBox,我们可以通过在测试案例前打勾来确定是否测试案例的范围。
点击界面上的按钮来运行测试,运行后的结果如下:
我们会发现,每个节点前有一个带颜色的方块,当没有运行时,所有节点的方块都为灰色,表示没有被测试。运行测试后,
如果该测试通过的话,方块变为绿色表示成功。下面的面板会显示所有的失败和异常,同时每个错误都会有相应的描述信息。
在上下两个面板之间的进度条表示测试的进展和成功的比率。
下面我们增加一个新的测试,故意让阶乘函数计算10000的阶乘,这时函数会因为溢出而抛出异常,来看DUnit如何检查这种
错误:
procedure
TFirstTestCase.TestFactorialLimit;
begin
//Check
函数会检查表达式是否为真
Check(Factorial(10000)>1, '
阶乘越界
'
);
end
;
运行结果示意图如下:
可以看到如果测试失败,方块就变为粉色,这时测试通过率降到了50%。
复合测试
前面的例子相对比较简单一些,下面我们考虑一个更复杂的场景。假设你觉得Delphi虽然提供了很多的字符串处理函数,
但是参数比较多,调用起来不是很方便,所以想创建一个TStringClass类,将常用的字符串函数封装起来。下面就是
TStringClass类的代码示意:
unit
StringClass;
interface
uses
Sysutils;
type
TStringClass=class(TObject)
private
FStr:string;
public
constructor
Create(AStr:String);
//
返回字符串的大写形式
function
UpperCase:string;
//
返回字符串的小写形式
function
LowerCase:string;
//
返回前后加引号的字符串形式
function
QuotedStr:string;
end;
implementation
{ TStringClass }
constructor
TStringClass.Create(AStr: String);
begin
FStr:=AStr;
end
;
function
TStringClass.LowerCase: string;
begin
Result:=SysUtils.LowerCase(FStr);
end
;
function
TStringClass.QuotedStr: string;
begin
Result:=SysUtils.QuotedStr(FStr);
end
;
function
TStringClass.UpperCase: string;
begin
Result:=SysUtils.UpperCase(FStr);
end
;
end
.
接下来我们写一个
TStringTestCase
类来进行测试,下面是类的代码:
unit
TestStringUnit;
interface
uses
TestFrameWork, StringClass;
type
TStringTestCase = class(TTestCase)
private
FStringClass:TStringClass;
protected
procedure
SetUp; override;
procedure TearDown; override;
published
procedure
TestUpperCase;
procedure TestLowerCase;
procedure TestQuotedStr;
end;
implementation
{ TStringTestCase }
procedure
TStringTestCase.SetUp;
begin
inherited
;
FStringClass:=TStringClass.Create('Test');
end
;
procedure
TStringTestCase.TearDown;
begin
inherited
;
FStringClass.Free;
end
;
procedure
TStringTestCase.TestLowerCase;
begin
Self.CheckEquals('test', FStringClass.LowerCase, 'LowerCase error!');
end
;
procedure
TStringTestCase.TestQuotedStr;
begin
Self.CheckEquals('''Test''', FStringClass.QuotedStr, 'QuotedStr error!');
end
;
procedure
TStringTestCase.TestUpperCase;
begin
Self.CheckEquals('TEST', FStringClass.UpperCase, 'UpperCase error');
end
;
initialization
TestFramework.RegisterTest('TestStringUnit Suite', TStringTestCase.Suite);
end
.
可以看到这个测试类同前面的
TFirstTestCase
类最大的不同之处在于
TStringTestCase
重载了
TTestCase
基类的两个重要方法
SetUp
和
TearDown
,这两个方法类似于
Contructor
和
Destructor
,每次执行测试过程
前,SetUp
会被调用,在执行完测试过程后,
TearDown
方法会被调用。我们可以在
SetUp
过程创建用于测
试的TStringClass
类的实例,并在
TearDown
方法中释放
TStringClass
的实例,以避免在测试中反复构造
和释放TStringClass
类所造成的不必要的系统开销。
另外可以看到在
TStringTestCase
的测试方法中,我使用了
CheckEquals
来检查测试是否通过,
TTestCase
类本身还提供了很多类似于Check
和
CheckEquals
的检查方法,具体使用可以参考
API
文档,这里就不赘述了。
重复测试
在实际开发过程中,我们经常会碰到一些资源泄漏的
Bug
(画刷,字体,句柄和内存没有释放)
,
它们在程序
最开始的几次运行的时候可能不会暴露问题,而是在几十次、上百次运行之后才会把相应的资源消耗光,那
么如何使用DUnit
来测试这类的问题呢?
如果是只需要运行
5
,
6
次就会暴露的问题,我们可以在运行测试程序时,多点几次运行按钮来重复测试,但是
如果需要几十遍甚至更多遍测试才能发现的问题,显然靠人手去点是不切实际的。DUnit
考虑到了这点,因此
TestExtensions.pas单元中
在提供了一个特殊的
TRepeatedTest
类来完成这项工作。下面是一个内存泄漏的例
子,在我的机器上,执行10
次以上就会无法通过测试,代码示意如下:
unit
TestLeakUnit;
interface
uses
TestFrameWork, TestExtensions, SysUtils;
type
TLeakTestCase = class(TTestCase)
private
protected
procedure
AllocateMem;
published
procedure
TestLeak;
end;
implementation
{ TLeakTestCase }
procedure
TLeakTestCase.AllocateMem;
var
Handle: Pointer;
begin
GetMem(Handle, 100000000);
end
;
procedure
TLeakTestCase.TestLeak;
begin
Self.CheckException(AllocateMem, EOutOfMemory, 'Memory leak');
end
;
initialization
TestFramework.RegisterTest('TestLeakUnit Suite',
TRepeatedTest.Create(TLeakTestCase.Suite, 100));
end
.
从代码可以看到,使用
TRepeatedTest
类很简单,只需要将需要重复运行的
TestCase
的
Suite
传递给
TRepeatedTest
类的构造函数,同时指定要重复执行的次数就可以了,这是一个装饰
(Decorator)
模式的典型应用。
TestFramework.RegisterTest('TestLeakUnit Suite',
TRepeatedTest.Create(TLeakTestCase.Suite, 100));
调整层次关系
从前面例子中我们可以看到测试案例的组织是有层次的,但是我们现在生成的默认的
TestSuite
都是只包含一个
TestCase
,那么有时很可能需要将内容相近的测试放在同一个
TestSuite
下面,
TestSuite
下面既包含多个
TestCase
,
也包含子TestSuite
,那么如何来做到呢?
下面修改
TestUnit
单元的
Initialization
部分的代码,将
TStringTestCase
注册在
TFirstTestCase
的
TestSuite
下面,
同时将TLeakTestCase
的
TestSuite
也注册在
TFirstTestCase
的
TestSuite
下面,代码示意如下:
initialization
// TestFramework.RegisterTest('TestUnit Suite',
// TFirstTestCase.Suite);
begin
ParentSuite := TTestSuite.Create('Parent Suite');
ChildSuite := TTestSuite.Create(' Child Suite');
ParentSuite.AddTests(TFirstTestCase);
ParentSuite.AddTests(TStringTestCase);
ChildSuite.AddTest(TRepeatedTest.Create(TLeakTestCase.Suite, 100));
ParentSuite.AddSuite(ChildSuite);
TestFramework.RegisterTest(ParentSuite);
end
;
另外注意要将其他几个测试单元的
Initialization
部分代码注释掉,运行后的示意图如下:
控制台模式的测试
现代软件工程方法
,
包括极限编程
,
都非常强调每日构建,持续集成,而如果每次构建系统都是由人手工完成的话,
成本比较高,效率比较低,同时非常容易出错,因此人们普遍采用自动批处理、Make
文件、或者类似于
Java Ant
的构建工具来创建运行版本。而创建一个可靠的程序运行版本的前提条件就是在创建前,程序应该能够通过所有
的测试。
我们前面讲的测试程序都是基于
GUI
的,需要手工去运行测试用例,人工判断程序是否通过了所有的测试项目,
这显然无法做到完全的自动化。为此DUnit
提供了另外一种运行方式,那就是在控制台模式下运行测试用例。
虽然在控制台模式下,测试案例程序不再有美观的界面和灵活的测试范围控制选项,但它可以无需人的参与而
自动运行全部的测试案例,如果有任何一个测试用例在测试过程中出现错误,
DUnit
将会返回一个非
0
的错误码
这使得控制台测试程序很适合放在一个make文件或者应用于
(数值为所有失败的错误数加上发生的异常数),
自动测试过程中。将控制台的测试程序加入到批处理后,后续的程序可以根据退出码判断前面的测试是否通过,
从而决定是否继续执行后续的版本创建的工作了,这样就可以做到构建过程的完全自动化了。
要想将我们前面写的测试程序改为在控制台下运行是非常简单的,只要在
ProjectTests.dpr
文件中加入
{$APPTYPE CONSOLE}
编译指令告诉
Delphi
生成控制台程序,同时添加一些判断代码就可以了,修改后的代码示意如下:
{$APPTYPE CONSOLE}
program
ProjectTests;
uses
TestFramework {$IFDEF LINUX},
QForms,
QGUITestRunner {$ELSE},
Forms,
GUITestRunner {$ENDIF},
TextTestRunner,
TestUnit in 'TestUnit.pas',
StringClass in 'StringClass.pas',
TestStringUnit in 'TestStringUnit.pas',
TestLeakUnit in 'TestLeakUnit.pas';
{$R *.RES}
begin
Application.Initialize;
if System.IsConsole then
TextTestRunner.RunRegisteredTests
else
GUITestRunner.RunRegisteredTests;
end
.
在控制台模式下,
DUnit
会使用
TextTestRunner
来运行注册的测试,在
GUI
方式下会使用
GUITextRunner
来运行测试,
如果我们想要切换回GUI
模式,只要将
{$APPTYPE CONSOLE}
指令注释掉就可以了。在控制台测试程序运行时,
DUnit
会在每个测试运行时在控制台上输出一个点,如果在测试中发生了错误,它会输出字母F表示测试失败,输出字母E
表示测试中发生了一个预期外的异常。
另外
DUnit
的控制台程序也可以在控制台下启动
GUI
方式的测试,
DUnit
的
Tests
目录下的
UnitTests.dpr
中提供了这样
一个例子,这个程序通过判断控制台程序运行的选项决定运行文本的测试还是GUI
的测试,代码示意如下:
begin
if
FindCmdLineSwitch('text-mode', ['-','/'], true) then
TextTestRunner.RunRegisteredTests(rxbHaltOnFailures)
else
begin
Application.Initialize;
Application.Title := 'DUnit Tests';
// RunRegisteredTests class methods are recommended
TGUITestRunner.RunRegisteredTests;
end;
end
.
对
GUI
界面控制的测试
前面我们讲到的例子都是对非可视化的类进行测试,而
Delphi
最重要的功能之一就是能够通过拖放的方式快速生成非常
专业的界面,在国内外Delphi
也被广泛的应用于前台界面程序的开发上。因此对于界面的控制操作,如
TabOrder
的布局、
界面的输入框是否正确地初始化了、界面是否能够响应某些快捷方式等等也就成为我们测试中很重要的一部分内容。
对于GUI
的测试,我们要建立的
TestCase
需要从
GUITesting.pas
单元中的
TGUITestCase
基类继承,而不应该是从
TTestCase
类继承了。假设现在有这样系统登录对话框,它有两个标签,两个输入框,和两个按钮,见下图示意:
对于登录界面的测试要求是:当显示时,界面会先将焦点定位于用户名前的输入框,同时界面组件的
TabOrder
顺序是先从上向下,然后从左到右,也就是说,用户名的TabOrder
应该为
0,
密码输入框的
TabOrder
为
1
,确定
按钮的TabOrder
为
2
,取消按钮的
TabOrder
为
3
。同时按下快捷键
Enter
后,将关闭对话框。
根据上面的测试要求实现的
TestCase
的代码示意如下:
unit
TestDialogUnit;
interface
uses
TestFrameWork, TestForm, Forms, Windows, Controls, GUITesting;
type
TDialogTestCase = class(TGUITestCase)
private
FDialog:TFormTest;
protected
procedure
SetUp; override;
procedure TearDown; override;
published
procedure
TestTabOrder;
procedure TestKey;
end;
implementation
{ TDialogTestCase }
procedure
TDialogTestCase.SetUp;
begin
inherited
;
FDialog:=TFormTest.Create(nil);
GUI:=FDialog;
FDialog.Show;
end
;
procedure
TDialogTestCase.TearDown;
begin
GUI:=nil;
FDialog.Free;
inherited;
end
;
procedure
TDialogTestCase.TestKey;
begin
FDialog.Edit1.SetFocus;
Self.EnterKey(VK_RETURN);
Check(FDialog.Visible=false, 'Form Closed?')
end
;
procedure
TDialogTestCase.TestTabOrder;
begin
CheckFocused('Edit1');
Tab;
CheckFocused('Edit2');
Tab;
CheckFocused('Button1');
Tab;
CheckFocused('Button2');
end
;
initialization
TestFramework.RegisterTest('TestDialogUnit Suite',
TDialogTestCase.Suite);
end
.
对代码进行一下说明,为了能对登录界面进行测试,我们首先在类的
SetUp
方法中建立起对话框的实例,并将实例赋值
给类的GUI
属性。运行测试时,
DUnit
会按照类方法定义的顺序先执行
TestTabOrder
方法来判断
TabOrder
是否正确设定
了,首先调用基类TGUITestCase
的
CheckFocused
方法检查
Edit1
是否在界面显示后最先获得了焦点,如果测试通过就
调用基类的Tab
方法来执行焦点遍历,来检查
TabOrder
的顺序是否正确。接下来是执行
TestKey
方法,这里我先把焦点
定在Edit1
上,然后调用基类的
EnterKey
输入回车键,如果窗体正确关闭的话,最后检查窗体是否可见。
辅助工具
为了简化编写测试案例的工作量,这里我将介绍一下如何使用Paul Spain编写的DUnit plug-in for Delphi向导来
简化工作量。向导可以在随书的光盘上找到,向导的安装程序会自动在Delphi IDE的主菜单上添加DUnit菜单。
DUnit菜单有两个创建测试案例的菜单:
- New Project... 命令将会创建一个新的DUnit的项目的模板代码。
- New TestCase... 命令将会创建一个新的DUnit的测试案例的模板代码。
新建项目
执行New Project...命令后,向导将会显示下面的对话框:
如果当前IDE中打开的项目名为Msg.dpr,向导会建议测试项目名称为Msg+Tests.dpr,同时我们可以修改Project Path
中建议的项目保存路径为我们指定的任意路径。设定好选项后,按下Enter键后,向导将会自动生成代码模版,
代码示意如下:
// Uncomment the following directive to create a console application
// or leave commented to create a GUI application...
// {$APPTYPE CONSOLE}
program MsgTests;
uses
TestFramework {$IFDEF LINUX},
QForms, QGUITestRunner {$ELSE},
Forms, GUITestRunner {$ENDIF},
TextTestRunner;
{$R *.RES}
begin
Application.Initialize;
{$IFDEF LINUX}
QGUITestRunner.RunRegisteredTests;
{$ELSE}
if System.IsConsole then
TextTestRunner.RunRegisteredTests
else
GUITestRunner.RunRegisteredTests;
{$ENDIF}
end.
光有测试项目还不够,我们还要添加测试案例(Test Case)。选中DUnit | New TestCase...菜单,会显示创建TestCase
的对话框,示意如下:
注意,该向导默认建议的UnitPath是项目的路径再加一个dunit(见图中的Unit Path输入框)。这里我们删除结尾的
”dunit\”字符串,同时禁止Add to TestCase “uses” clause选项。按下Enter键后会生成如下的Test Case模版代码:
unit MsgTestsTests;
interface
uses
TestFrameWork;
type
TMsgTestsTests = class(TTestCase)
private
protected
// procedure SetUp; override;
// procedure TearDown; override;
published
// Add test methods here...
end;
implementation
initialization
TestFramework.RegisterTest('MsgTestsTests Suite',
TMsgTestsTests.Suite);
end.
注意在生成代码前,会显示下面的对话框(这是这个向导的一个bug),我们直接选择Cancel就可以了。
除了DUnit Plugin之外,DUnit本身提供了一个XPGen的工具,XPGen通过使用递归下降的代码解析器分析Delphi单元文件
中的类定义,自动生成测试用例骨架代码。
XP的使用非常简单,先用Delphi打开..\Contrib\XPGen目录下的XPGen.dpr,编译运行后,将显示下面界面:
执行菜单命令File | Open,打开前面我们写的StringClass.pas单元,XPGen会自动生成下面的代码:
unit
Test_StringClass;
interface
uses
TestFramework,
SysUtils,
StringClass;
type
CRACK_TStringClass = class(TStringClass);
Check_TStringClass = class(TTestCase)
public
procedure
setUp; override;
procedure tearDown; override;
published
procedure
VerifyUpperCase;
procedure VerifyLowerCase;
procedure VerifyQuotedStr;
end;
function
Suite: ITestSuite;
implementation
function
Suite: ITestSuite;
begin
result := TTestSuite.Create('StringClass Tests');
result.addTest(testSuiteOf(Check_TStringClass));
end
;
procedure
Check_TStringClass.setUp;
begin
end
;
procedure
Check_TStringClass.tearDown;
begin
end
;
procedure
Check_TStringClass.VerifyUpperCase;
begin
fail('Test Not Implemented Yet');
end
;
procedure
Check_TStringClass.VerifyLowerCase;
begin
fail('Test Not Implemented Yet');
end
;
procedure
Check_TStringClass.VerifyQuotedStr;
begin
fail('Test Not Implemented Yet');
end
;
end
.
之后我们就可以将生成的代码复制到
Delphi
中,然后将其保存起来。如果有很多要测试的类的话,
XPGen
无疑将极大的
减轻我们的工作量。
总结
在本文中我们探讨了如何使用DUnit来实现持续测试,持续集成。DUnit虽然不一定是保证软件质量的万灵丹,但是它确
实是一个很好的工具。相信经过大量实践,我们一定能够从中受益的。