DUnit
Delphi 的终极测试工具
by Will Watts
edited by Juanco Añez
Copyright © 1999 Will Watts. All rights reserved.
Later versions are © 2000-2001 The DUnit Group. All rights reserved.
This text may be distributed freely as long as it's reproduced in its entirety.
There's an English version of this document here
翻译:蔡焕麟
内容
档案内容
起步
你的第一个测试项目
SetUp 与 TearDown
测试套件
逐步建立测试套件
采用 DUnit 进行单元测试
DUnit 是一个类别框架,目的是要支持 XP 的软件测试方法。它支持 Delphi 4 以后的版本。
其概念为,当你在开发或修改程序代码时,你就要同时开发出相称的测试程序,而不是把它们延后到测试阶段。若能随时更新测试程序并且经常反复地执行它们,你就能够更轻易地产生可靠的程序代码,而且在进行修改与重整(refactorings)时更有把握不会破坏原有的程序代码,于是,应用程序等于有了自我测试的能力。
DUnit 提供了一些类别以便组织与执行这些测试。DUnit 提供两种执行测试的方式:
DUnit 的灵感源自 JUnit 框架,该框架是由 Kent Beck 与 Erich Gamma 为 Java 程序语言所设计的,但是 DUnit 已经逐渐发展成威力更强的 Delphi 专属工具。最早是由 Juanco Añez 设计成 Delphi 的版本,目前则是由 SourceForge 的 DUnit Group 所维护。
档案内容
随着 DUnit 套件所发布的档案应该存放在一个属于自己的目录下,以便保留完整的目录结构:
目录名称 | 说明 | |||
DUnit |
| |||
| framework | 事先编译好的框架模块 | ||
| src | 函式库原始码 | ||
| doc | 辅助说明档,网页与 MPL 授权许可 | ||
|
| images | 网页的图形档案 | |
Time2Help 产生的 API 文件 | ||||
| Contrib | 其他人贡献的模块 | ||
|
| 一个可以自动产生测试案例(test cases)的工具 | ||
| tests | 给这个框架本身所使用的测试案例 | ||
| bin | 事先编译好,可以单独执行的 GUI 测试程序 | ||
| examples |
| ||
|
| cmdline | 示范如何在命令行环境下使用 DUnit | |
|
| collection | 一个类似 Java 的集合(collections)实作以及它的 DUnit 测试案例 | |
|
| registration | 使用测试案例注册系统(registration system)(译注:示范几种注册测试案例的方法) | |
|
| 组织测试程序代码的方式 | ||
|
|
| diffunit | 把测试案例放在独立的单元里面 |
|
|
| sameunit | 把测试案例和被测试的程序代码放在同一个单元里面 |
一步步教你建立一个存取 Registry 的工具及其测试案例 | ||||
|
| 示范如何将 GUITestRunner 嵌入至其他窗口内 | ||
|
| (...) |
| |
|
| TListTest | 给 Delphi 的 Classes.TList 对象使用的测试案例 |
目录 src 包含下列档案
文件名 | 说明 |
框架本身 | |
可用来扩充测试案例的 Decorator 类别 | |
用来测试用户接口(窗口与对话盒)的类别 | |
在控制面板模式下执行测试的函式 | |
此框架的图形用户界面 | |
GUITestRunner Form |
framework 目录中包含以上各单元编译过的版本,以及用来连结 .BPL 的 .DCP 档案(对应的 .BPL 档案存在 bin 目录里)(译注1)。
起步
在开始使用 DUnit 之前,Delphi 的单元搜寻路径里必须包含 DUnit 的原始码或编译后的档案路径 。你可以在 Delphi IDE 中点选 Tools | Environment Options | Library,然后把 DUnit 路径加到原有的路径列表里:
另一种做法,是将 DUnit 路径加到默认的项目选项或者特定的项目选项里,在 IDE 中点选 Project | Options:
你的第一个测试项目
建立一个新的应用程序,然后关闭 Delphi 为你自动产生的 Unit1.pas 并且不要储存。储存这个新的项目(在你想要测试的应用程序的相同目录下的 'real life' 目录)并且命名为 Project1Test.dpr。
点选 File | New | Unit 以建立一个新的(没有 form 的)单元,由于我们会把测试案例写在这个档案里面,所以储存的时候就取 Project1TestCases 之类的文件名,接着在 interface 的 uses 子句里加入 TestFramework。
宣告一个 TTestCaseFirst 类别,该类别继承自 TTestCase,然后实作一个如下所示的 TestFirst 方法(显然地,这个小范例只是为了让你顺利起步),注意最后的 initialization 区段,TTestCaseFirst 类别就是在这里完成注册的。
unit Project1TestCases;
interface
uses
TestFrameWork;
type
TTestCaseFirst = class(TTestCase)
published
procedure TestFirst;
end;
implementation
procedure TTestCaseFirst.TestFirst;
begin
Check(1 + 1 = 2, 'Catastrophic arithmetic failure!');
end;
initialization
TestFramework.RegisterTest(TTestCaseFirst.Suite);
end.
测试的结果是置于所呼叫的 Check 方法里面,这里我很无聊地想要确认 1 + 1 是否等于 2。TestFramework.RegisterTest 程序会把传入的测试案例对象注册到此框架的注册系统里。
在执行这个项目以前,点选主选单的 Project | View Source 以开启项目的原始码,把 TestFrameWork 以及 GUITestRunner 加到 uses 子句里,然后移除预设的 Application 程序代码,并以下面的程序代码取代:
program Project1Test;
uses
Forms,
TestFrameWork,
GUITestRunner,
Project1TestCases in 'Project1TestCases.pas';
{$R *.RES}
begin
Application.Initialize;
GUITestRunner.RunRegisteredTests;
end.
现在试着执行程序,如果一切正常,你应该会看到 DUnit 的 GUITestRunner 窗口,里面有一个树状组件显示可用的测试(目前只有 TestFirst),点一下 Run 按钮即可执行测试。画面上的复选框可以让你以阶层的方式选择欲测试的项目,还有额外的按钮以便切换测试项目或整个分支的选取状态。
若要加入更多的测试,只需简单地在 TTestCaseFirst 里加入新的测试方法,TTestCase.Suite 类别方法会透过 RTTI(RunTime Type Information,执行时期型态信息)自动地寻找并且呼叫它们,这些测试方法必须符合两个条件:
注意 DUnit 会为它所找到的每个方法各自建立一个类别的实体(instance),所以测试方法之间不可共享实体的数据。
现在要再加入两个测试方法:TestSecond 与 TestThird,其宣告如下:
TTestCaseFirst = class(TTestCase)
published
procedure TestFirst;
procedure TestSecond;
procedure TestThird;
end;
...
procedure TTestCaseFirst.TestSecond;
begin
Check(1 + 1 = 3, 'Deliberate failure');
end;
procedure TTestCaseFirst.TestThird;
var
i: Integer;
begin
i := 0;
Check(1 div i = i, 'Deliberate exception');
end;
如果你重新执行这个程序,你就会看到 TestSecond 测试失败了(旁边有一个小的紫红色方框),而 TestThird 会丢出一个异常(旁边的方框是红色的),通过测试的方框会是绿色的,而没有执行的测试则是灰色的。失败的测试清单会被列在下方的面板上,当你去点选它们就可以在底部的面板上看到它们的详细资料。
如果你在 IDE 里面执行程序,你会发现每当程序发生错误时就会暂停,当你用 DUnit 进行测试时,这样的行为可能不是你想要的,你可以照下面的步骤将 IDE 的这项功能关掉:点选 Tools | Debugger Options,然后把 Language Exceptions 页夹的 Stop on Delphi Exceptions 项目取消。
Setup 与 TearDown
我们通常会在执行一组测试之前进行一般的准备工作,并在事后进行清理。比如说,在测试一个类别的时候,你也许会想要建立该类别的实体,然后对它施行一些检查,最后再将它释放,如果测试项目很多的话,你将免不了在每一个测试方法里面撰写重复的程序代码。DUnit 对此提出的解决方案是,在每一个测试方法被执行之前和之后分别去呼叫 TTestCase 的虚拟方法 Setup 与 TearDown,以终极测试的行话来说,由这两个方法来提供测试前的必要处理就称为一个 fixture(译注 2)。
以下范例扩充了 TTestCaseFirst 并增加几个测试 Delphi 集合类别 TStringList 的方法:
interface
uses
TestFrameWork,
Classes; // needed for TStringList
type
TTestCaseFirst = class(TTestCase)
private
Fsl: TStringList;
protected
procedure SetUp; override;
procedure TearDown; override;
published
procedure TestFirst;
procedure TestSecond;
procedure TestThird;
procedure TestPopulateStringList;
procedure TestSortStringList;
end;
...
procedure TTestCaseFirst.SetUp;
begin
Fsl := TStringList.Create;
end;
procedure TTestCaseFirst.TearDown;
begin
Fsl.Free;
end;
procedure TTestCaseFirst.TestPopulateStringList;
var
i: Integer;
begin
Check(Fsl.Count = 0);
for i := 1 to 50 do // Iterate
Fsl.Add('i');
Check(Fsl.Count = 50);
end;
procedure TTestCaseFirst.TestSortStringList;
begin
Check(Fsl.Sorted = False);
Check(Fsl.Count = 0);
Fsl.Add('You');
Fsl.Add('Love');
Fsl.Add('I');
Fsl.Sorted := True;
Check(Fsl[2] = 'You');
Check(Fsl[1] = 'Love');
Check(Fsl[0] = 'I');
end;
测试套件(Test suites)
当你在测试一个真正有用的(non-trivial)应用程序时,你会想要建立一个以上的 TTestCase 衍生类别,欲将这些类别加到上层节点,你只需在 initialization 子句里面注册它们就行了,写法跟上面的范例一样。有时候,你可能想要更清楚地定义测试案例之间的结构关系,为此 DUnit 提供了建立测试套件的功能,它可以让你在测试案例中包含其他的测试案例或测试套件(使用 Composite 样式)。
如同在 TTestCaseFirst 测试案例中所显示的,当算术运算的测试方法执行时,SetUp 和 TearDown 方法虽然有被呼叫但完全没做任何事。其中有两个处理字符串串行的方法,最好能将它们分离成独立的测试套件,做法是先把 TTestCaseFirst 拆成两个类别,分别是 TTestArithmetic 与 TTestStringList:
type
TTestArithmetic = class(TTestCase)
published
procedure TestFirst;
procedure TestSecond;
procedure TestThird;
end;
TTestStringlist = class(TTestCase)
private
Fsl: TStringList;
protected
procedure SetUp; override;
procedure TearDown; override;
published
procedure TestPopulateStringList;
procedure TestSortStringList;
end;
(当然啦,你也得更新这些方法的实作才行)
然后把 inistailization 的程序代码改成这样:
RegisterTest('Simple suite', TTestArithmetic.Suite);
RegisterTest('Simple suite', TTestStringList.Suite);
逐步建立测试套件
TestFramework 单元的 TTestSuite 类别实作了测试套件,所以你可以用更明显的方式建立测试阶层:
下面的 UnitTests 函式会建立一个测试套件,并且在其中加入两个测试类别:
function UnitTests: ITestSuite;
var
ATestSuite: TTestSuite;
begin
ATestSuite := TTestSuite.Create('Some trivial tests');
ATestSuite.AddTests(TTestArithmetic.Suite);
ATestSuite.AddTests(TTestStringlist.Suite);
Result := ATestSuite;
end;
还有另一种写法,跟上面的作用也是完全相同的:
function UnitTests: ITestSuite;
begin
Result := TTestSuite.Create('Some trivial tests',
[
TTestArithmetic.Suite,
TTestStringlist.Suite
]);
end;
上面的范例是在呼叫 TTestSuite 的建构元时,把要加入的测试一并透过数组传递过去。
使用上述任一种方式建立的测试套件,其注册方式跟你之前注册个别测试案例的方式是相同的:
initialization
RegisterTest('Simple Test', UnitTests);
end.
当测试程序执行时,你就会在 GUITestRunner 窗口上看到新的树状阶层。
其他功能
在控制面板模式下执行测试
有时候,我们会想要在控制面板模式下执行测试套件,比如说当你想要用一个 Makefile 执行整批的测试,这时候控制面板模式就很有用。如要在控制面板模式下执行测试,之前在 DPR 档案里面的 uses 子句中的 GUITestRunner 就要改成 TextTestRunner,并且加入条件编译 {$APPTYPE CONSOLE} 或者在 IDE 里点选 Project | Options | Linker | Generate console application 选项。
以下范例 Project1TestConsole.dpr 的项目原始码:
{$APPTYPE CONSOLE}
program Project1TestConsole;
uses
TestFrameWork,
TextTestRunner,
Project1TestCases in 'Project1TestCases.pas';
{$R *.RES}
begin
TextTestRunner.RunRegisteredTests;
end.
程序执行的输出结果会像这样:
--
DUnit: Testing.
..F.E..
Time: 0.20
FAILURES!!!
Test Results:
Run: 5
Failures: 1
Errors: 1
There was 1 error:
1) TestThird: EDivByZero: Division by zero
There was 1 failure:
1) TestSecond
注意第三行的 '..F.E..' 字符串,其中每一个句点(.)代表一项执行无误的测试,'F' 表示测试失败(failed),而 'E' 表示发生异常(exception)。
如果你希望当测试失败时,让 TextTestRunner 停止执行并且传回一个非零的结束码,你可以传入一个rxbHaltOnFailures 参数值,像这样:
TextTestRunner.RunRegisteredTests(rxbHaltOnFailures);
当你使用 Makefile 来执行测试套件的时候,这些回传的结束码会很有用处。
扩充功能
The TextExtensions 单元中的类别是用来扩充 DUnit 框架的功能,大部分的类别使用了「四人帮」(GoF, Gang of Four)的 "Design Patterns" 书中所定义的 decorator 样式。
TRepeatedTest
TRepeatedTest 类别允选你重复装饰的测试许多次,例如,重复执行 TestFirst 测试案例中的 TTestArithmetic 10 次,你的程序可以这么写:
uses
TestFrameWork,
TestExtensions, // needed for TRepeatedTest
Classes; // needed for TStringList
...
function UnitTests: ITest;
var
ATestArithmetic : TTestArithmetic;
begin
ATestArithmetic := TTestArithmetic.Create('TestFirst');
Result := TRepeatedTest.Create(ATestArithmetic, 10);
end;
请注意 TTestArithmetic 的建构元:
ATestArithmetic := TTestArithmetic.Create('TestFirst');
这里我把要重复执行的测试方法的名称传递给建构元,当然这个名称一定不能写错,否则随后执行时只能得到令人失望的结果。
如果你想要重复测试 TTestArithmetic 的全部方法,你可以把它们放在一个套件里:
function UnitTests: ITest;
begin
Result := TRepeatedTest.Create(ATestArithmetic.Suite, 10);
end;
TTestSetup
TTestSetup 类别可以让你为一个测试案例类别进行唯一一次的初始化设定(Setup 与 TearDown 方法是每次执行测试方法时就会被呼叫)。例如,如果你正在撰写一组测试以验证某些存取数据库的程序代码,你可能会从 TTestSetup 衍生一个类别,并且利用它来开启和关闭数据库。
参考数据
位于 SourceForge 的 DUnit 首页(https://sourceforge.net/projects/dunit/),有最新的原始码,邮递论坛,问答集...等。
Delphi 的终极测试工具 ( http://www.suigeneris.org/juanca/writings/1999-11-29.html),Juancarlo Añez 在这篇文章里介绍了他设计的 DUnit 类别,此文最初公布于 Borland 开发人员社群网站。
JUnit Test Infected: Programmers Love Writing Tests (http://www.junit.org/junit/doc/testinfected/testing.htm),这是一篇介绍 JUnit 的好文章, DUnit 就是以此框架为基础而发展出来的。
Simple Smalltalk Testing: With Patterns(http://www.xprogramming.com/testfram.htm),Kent Beck 最早的文件,比较适合熟悉 Smalltalk 的人阅读。
~o~
译注
1. 部分档案目录在新版本里面已经不存在了,例如:framework,故应以官方释出的最新版的目录结构为准。
2. Fixture 是 XP 术语。在一个 Test Case 里面,负责初始化及清理的动作,对应到实作上就是 SetUp 与 TearDown 这两个虚拟方法。每次执行测试时,会事先呼叫所有的 fixture 的 Setup 方法,并且在测试结束前呼叫 TearDown,我想也就因为这些是固定要执行的动作,所以取其名为 fixture,若要译成中文,也许可以说成「固定装置」或「固定机制」。 fixture 的典型用法是在 Setup 方法里面配置资源,并且在 TearDown 里面释放资源,如果你正在测试存取数据库的程序,并且希望测试的过程不会改变数据库的内容,也可以在 TearDown 里面将所有的交易撤回,或者撰写交易补偿的程序代码。