简易Smalltalk测试框架:
以及模式
Kent Beck,
First Class Software, Inc.
KentBeck@compuserve.com
翻译:ShiningRay @ NirvanaStudio
本软件和文档是由编码社区提供的服务。你可以随意分发。First Class Software, Inc.不提供任何形式的声明或隐含的担保。
(Transcribed to HTML by Ron Jeffries. The software is available for many Smalltalks, and for C++, on my FTP site.)
介绍
Smalltalk语言曾饱受煎熬,因为他一直缺少一种测试的习俗。这篇专栏将介绍一种简单的测试策略和框架来支持我们的Smalltalk。虽然这个测试策略和框架并不打算发展成为一个全套的解决方案,但是他相当于一个起点,任何有工业强度的工具和过程都都可以从他起进行建造。
本论文分为三个部分:
- 原理 – 描述了书写和运行由框架体现的测试原理。阅读这一章了解基本的背景。
- 手册 – 一个简单模式系统可以用来写你自己的测试。
- 框架 – 测试框架的一个简单版本。阅读这一章可以深入了解框架是如何操作的。
- 例子 – 一个使用测试框架来测试Set对象中方法的例子。
原理
我不喜欢基于用户界面的测试。在我的印象中,基于用户界面脚本的测试是十分脆弱的,难于使用。当我还在参加一个项目,我们使用了 用户界面测试,每天早上送来的测试报告中有二三十个测试失败,那是常有的。随便检查一下就会发现大多数的失败中实际上程序还是按照期望的运行的。界面上稍作一些修饰,就会导致最终的输出和期望的输出不一样。我们的测试元花费太多的时间保持测试代码和现有代码同步并且找出那些“伪失败”和“伪成功”,却没多 少时间写新的测试。
我的解决方法是直接在Smalltalk中写测试代码并检查他的结果。这个方法有一个缺点就是你的测试员必须会写一些简单的Smalltalk程序,但因此测试就变得更为可靠。
失败和错误
这个测试框架会区分失败和错误。一个失败指预期可能发生的问题。当你写测试时,你要检查预期的结果。如果你后来得到的是一个不同的结果,这是一个失败的测试。有一个错误那就更为悲惨,你可能根本没检查到有这个错误情况的可能。
测试单元
我推荐开发人员自己写自己的单元测试,每个类写一个。测试框架也支持写测试套件(TestSuite,或叫测试序列),也可以附加给一个类。我还推荐所有的类响应“testSuite”消息,返回一个包含单元测试的测试套件。另外,我还推荐开发人员花20~50%的时间开发测试代码。
集成测试
我推荐由一个单独的测试员写集成测试。哪里运行集成测试呢?最近的用户界面框架运动到更好的程序访问提供了同一个答案——驱动用户界面,但让测试来做。在VisualWorks(下面的实现也会用到这个方言),你可以打开一个应用程序模型ApplicationModel并不断往他的ValueHolders中填入值,造成各种混乱,也不会产什么麻烦。
运行测试
这是原理部分的最后一点。有种做法很有诱惑力——建立一系列测试数据,然后运行一系列测试,然后清除。在我的经历中,这个要比他代来的价值造成更多的问题。一个测试可能会结束和另一个的交互,而且一个测试中的失败可能造成后续的测试都不能够运行。测试框架让我们可以很容易的建立一系列通用测试数据,但是这些数据会在每次测试之前建立并在之后抛弃。这种做法的潜在的性能问题不会造成很大的影响因为测试可以在无人值守的情况下运行。
手册
这里是一个书写测试的简易模式系统。有以下模式:
-
模式
目的
装置
建立一个通用的测试装置。
测试案例
建立一个测试案例的激发器。
检查
检查测试案例的结果。
测试套件
聚合TestCase测试案例。
装置(Fixture)
你怎样开始写测试呢?
测试是一种不可能的任务。你要让他完全通过,这样你才能保证软件可以正常工作。从另一角度看,由于你的程序的可能状态太庞大,以至于你不可能测试每一种组合情况。
如果你一开始很茫然,都不知道你要测试什么,你就永远不能进入测试的状态。由一个行为可预测的配置开始则要好很多。当你对你开发的软件有更多的了解,你可以加入一个配置列表。
这样一个配置,称之为“装置”(Fixture)。下面是装置的例子:
-
装置
预计反馈
1.0 and 2.0
算数问题很容易给出预计的答案
到已知的机器的网络连接
对网络包进行相应
#() and #(1 2 3) 发送测试消息的结构
选择一个装置你判断哪些要测试哪些不要。一个完整的测试会有很多装置,每一个都可以以多种途径进行测试。
设计一个测试装置。
- 创建 TestCase 的子类
- 在装置中给每个已知的对象添加一个实例变量
- 重写 setUp 来初始化变量
在下面的例子中,测试装置是两个Set(集合),一个为空另一个包含元素。首先我们给TestCase建立子类并给对象添加实例变量,以便我们以后进行引用:
Class: SetTestCase superclass: TestCase instance variables: empty full然后我们重写setUp方法来建立装置中的对象:SetTestCase>>setUp empty := Set new. full := Set with: #abc with: 5
Test Case测试案例
你已经有了一个装置,下面要做什么呢?
你如何表现测试的一个单元?
你可以预测给装置发出消息之后的结果。你需要通过某种方式表现这样一种预知的情况。
最简单的方法是用交互的方法表现。你打开装置的一个检查器(Smalltalk中的Inspector)
并且给他发送消息。这种做法有两个缺点。第一,你要一直给同一个装置发送消息。如果有一个测试刚巧把对象弄乱了,那么后面所有的测试都会失败,即使代码也许是正确的。更重要的是,你不能方便地和其他人进行交流,交换你的交互测试。如果你把你的对象给了其他人,他们唯一可以测试这些对象的办法就是把你叫来并检查他们。
通过将每一个可预测的情况表现为一个对象,每一个都有一个自己装置,没有哪两个测试会互相干扰。同时,你可以很方便地把测试给别人运行。
用一个方法来代表装置的一个可预见的反应。
- 给TestCase的子类添加一个方法
- 用该方法激活装置
下面的例子代码演示了这两点。我们可以预见:将“5”加入一个空的集合会得到“5”在集合中的结果。我们给我们的TestCase的子类添加一个方法。用它来激活装置:
SetTestCase>>testAdd
empty add: 5.
...
一旦你激活了装置,你需要添加一个检查机制来确保你的预期正确发生。
检查
一个测试案例会激发一个测试装置。
你如何测试要得到预期的结果?
如果你在进行交互测试,你可以直接检查预期的结果。如果你要查看一个特定的返回之,就是用“print it”,并确保你获得正确的对象。如果你要查看一些额外的影响,请使用Inspector检查器。
由于测试是在他们自己的对象中,你需要一种直接用程序查找问题的方法。一种实现办法是使用标准错误处理机制(Object>>error:),以测试逻辑为error:信号
2 + 3 = 5 ifFalse: [self error: 'Wrong answer']
当你在测试的时候,你可能想要区别你要检查的错误,象二加三得六,和那些你没有预见的错误,如下标越界或者是未定义的消息。
你还没什么办法对付无法预见的错误(如果你已经对他们作了些什么,他们就不会说无法预见了,是不是?)当一个灾难性的错误降临时,测试框架会停止运行测试案例,记录错误,并直接运行下一个测试案例。由于每一个测试案例都有他自己的装置,前一个案例中的错误不会影响下一个。
测试框架简单的提供了一个“should:”方法来检测预期的值,这个方法带一个语句块作为参数。如果语句块得出的是真,那一切都好。否则,测试案例停止运行,测试失败被记录在案,然后运行下一个测试案例。
把检查的内容变成一个返回值为布尔型的语句块,并把语句块作为参数传递给“should:”。
在下面的例子中,在通过给一个空集合加“5”激活装置之后,我们希望检测并确保里面包含它。
SetTestCase>>testAdd
empty add: 5.
self should: [empty includes: 5]
有一个TestCase>>should: 的变体。TestCase>>shouldnt: 在语句块参数为真的情况下失败。这样你就不必使用“(…)not”了。
你一旦有了一个测试案例,你就可以运行它了。给你的TestCase的子类创建一个实例,并给出测试方法的选择器。并发送“runt”给结果对象:
(SetTestCase selector: #testAdd) run
如果他运行结束,那么测试通过了。如果摔了个跟头,看来有什么东西出错了。
TestSuite测试套件
你现在有若干测试案例。
你如何一下运行许多测试呢?
一旦你有两个测试案例在运行,你会希望他们能一个接一个运行而不是你去执行两次“do it”。你可以就把两块运行表达式放弃及然后执行来运行测试。然而,如果当你又需要运行“这一块案例和那一块案例”的时候呢,你就会烦了。
测试框架提供了一个对象来表示“一系列测试”——测试套件TestSuite。一个TestSuite运行一套测试案例并且把他们的运行结果同时汇报上来。根据多态的优点,TestSuite同样可以包含其他TestSuite,所以你可以把Joe的测试和Tammy的测试放在一个创建一个更高一级的套件。
把测试案例组合到测试套件中。
(TestSuite named: ‘Money’)
add: (MoneyTestCase selector: #testAdd);
add: (MoneyTestCase selector: #testSubtract);
run
给一个TestSuite发送了“run”的结果是一个TestResult对象。他记录了所有的测试案例引发的失败或错误,和套件运行的时间。
所有这些对象都是可以通过ObjectFiler或者BOSS进行储存。你可以很方便的保存一个套件,然后调入并运行,和以前的结果进行比较。
Framework框架
这一部分展示了测试框架的代码。如果你对框架的实现很好奇,或者你想要修改它,那么就在这一章了。当你和一个测试员交谈时,他们谈论的测试的最小单元是测试案例。TestCase是一个用户的对象,代表了一个单个测试案例。
Class: TestCase
superclass: Object
测试员谈论如何设置一个“测试装置”,这是一个带有可预测响应结构的对象,他既容易建立也容易推导。几个不同的测试案例可以针对同一个测试装置。
这个差别表现在框架中是通过给每个TestCase一个可插入的选择器。选择器调用的变量行为便是测试代码。同一个类的所有实例共享同一个装置。
Class: TestCase
superclass: Object
instance variables: selector
class variable: FailedCheckSignal
TestCase class>>selector: 是一个 Complete Creation Method
TestCase class>>selector: aSymbol
^self new setSelector: aSymbol
TestCase>>setSelector: 是 Creation Parameter Method
TestCase>>setSelector: aSymbol
selector := aSymbol
TestCase的子类需要相应改写钩子方法setUp和tearDown来建立和销毁测试装置。TestCase本身提供了两个什么也不做的基本方法。
TestCase>>setUp
"在此运行任何需要为运行测试所作的准备工作。"
TestCase>>tearDown
"在此释放为此本测试使用的任何资源。"
运行一个TestCase最简便的方法就是向他发送“run”消息。Run调用设置代码,运行选择器,然后运行拆卸代码。注意拆卸代码不管执行测试的时候有没有错误都会执行。调用setUp和tearDown可以封装成Execute Around Method,不过由于他们不是公共接口的一部分,他们必须在这里编写。
TestCase>>run
self setUp.
[self performTest] valueNowOrOnUnwindDo: [self tearDown]
PerformTest 仅仅执行一下选择器
TestCase>>performTest
self perform: selector
单个的TestCase一点意思也没有,一旦你让他运行起来了之后。然后,你就会想一次运行很多很多测试案例。测试员谈论运行测试“套件”。TestSuite是一个用户对象。它是测试案例的组合。
Class: TestSuite
superclass: Object
instance variables: name testCases
TestSuites 是有名称得对象。因此可以很方便判断他们的身份,这样他们可以被储存在存储器中,也可以从中读取。一下是完整的构造方法和构造参数方法。
TestSuite class>>named: aString
^self new setName: aString
TestSuite>>setName: aString
name := aString.
testCases := OrderedCollection new
testCases实例变量是在TestSuite>>setName: 中初始化的因为我不需要让他变成其他类型的集合。
测试套件有一个名称的访问方法,这样可以在用户界面上显示他们。
TestSuite>>name
^name
TestSuites拥有集合访问方法可以添加一个或多个 TestCases.
TestSuite>>addTestCase: aTestCase
testCases add: aTestCase
TestSuite>>addTestCases: aCollection
aCollection do: [:each | self addTestCase: each]
当你行一个TestSuite,你可能想要运行它所有的TestCase。但他不是这样简单的。如果你有一个代表应用程序的验收测试侧套件,在运行之后,你可能还想知道套件运行了多久,哪个案例有问题。这些是你可能想要储存以便将来引用的信息。
TestResult是一个TestSuite的结果对象。运行一个TestSuite返回一个TestResult,记录了上面描述的信息——起始时间和中止时间,套件名称和所有的失败和错误。
Class: TestResult superclass: Object instance variables: startTime stopTime testName failures errors当你运行一个TestSuite的时候,他会建立TestResult实例,并在TestCase运行之前之后打上时间标记。TestSuite>>run | result | result := self defaultTestResult. result start. self run: result. result stop. ^result
TestCase>>run 和 TestSuite>>run 在多态上并不相同。这是需要在以后的框架中解决的问题。一种做法是用一个可以按微秒度量的TestCaseResult来做性能回退测试(performance regression testing.)。
默认的TestResult由TestSuite构造,使用一个 Default Class.
TestSuite>>defaultTestResult
^self defaultTestResultClass test: self
TestSuite>>defaultTestResultClass
^TestResult
一个TestResult Complete Creation Method 需要一个 TestSuite.
TestResult class>>test: aTest
^self new setTest: aTest
TestResult>>setTest: aTest
testName := aTest name.
failures := OrderedCollection new.
errors := OrderedCollection new
TestResult可以通过发送start和stop消息来做时间标记。由于start和stop需要成对执行,他们必须隐藏在一个Execute Around Method方法中。这也是以后要做的。
TestResult>>start
startTime := Date dateAndTimeNow
TestResult>>stop
stopTime := Date dateAndTimeNow
当一个TestSuite运行时给出一个TestResult,它仅仅将其中每一个TestCase带着TestResult运行。
TestSuite>>run: aTestResult
testCases do: [:each | each run: aTestResult]
#run: 是TestSuite和TestCase中的组合选择器,这样你可以构造包含其他TestSuite的TestSuite,替代或者补充TestCase。
当一个TestCase运行时给出了一个TestResult,它应该要么安静无误得运行,向TestResult中添加一个错误,或者添加一个失败。捕获错误简单的使用了系统提供的errorSignal。捕获失败必须由TestCase本身提供。首先,我们需要一个 Class Initialization Method 来创建一个信号。
TestCase class>>initialize
FailedCheckSignal := self errorSignal newSignal
notifierString: 'Check failed - ';
nameClass: self message: #checkSignal
现在我们需要一个 Accessing Method.
TestCase>>failedCheckSignal
^FailedCheckSignal
现在,当TestCase用一个TestResult运行时,它要捕获错误和失败然后通知TestResult,同时它必须运行tearDown代码不管测试是否正确执行。者造成了这是框架中最丑的方法,因为有两个嵌套的错误处理器和方法中的valueNowOrOnUnwindDo:。这里少解释了一个模式在TestCase>>run 关于使用ensure:确保安全运行Execute Around Method 的第二个停止。
TestCase>>run: aTestResult
self setUp.
[self errorSignal
handle: [:ex | aTestResult error: ex errorString in: self]
do:
[self failedCheckSignal
handle: [:ex | aTestResult failure: ex errorString in: self]
do: [self performTest]]] valueNowOrOnUnwindDo: [self tearDown]
当一个TestResult被告知有一个错误或者失败发生了,他在他其中一个集合中记录这个事实。为了简单起见,记录只是两个数组,但是他可能应该是一个类对象包含一个时间标签和问题更详细的信息。
TestResult>>error: aString in: aTestCase
errors add: (Array with: aTestCase with: aString)
TestResult>>failure: aString in: aTestCase
failures add: (Array with: aTestCase with: aString)
一旦在测试方法中出现一个未捕获的错误(例如,无法辨认的消息),就调用错误的情况。失败的情况如何调用呢?TestCase提供了两个方法简化了失败检查。第一个是should: aBlock,如果aBlock执行后返回假便发出失败消息。第二个,shouldnt: aBlock,和前面刚好相反。
should: aBlock
aBlock value ifFalse: [self failedCheckSignal raise]
shouldnt: aBlock
aBlock value ifTrue: [self failedCheckSignal raise]
测试方法将会运行代码来激发测试装置,然后在should:和shouldnt:块中检查结果。
例子
Ok, 这就是他的工作原理,你怎么来用它呢?这里有一个简短的例子测试Set支持的一些消息。首先我们建立TestCase的子类,因为我们总想有好几对有趣的集合可玩。
Class: SetTestCase
superclass: TestCase
instance variables: empty full
现在我们需要初始化这些变量,所以我们重写setUp。
SetTestCase>>setUp
empty := Set new.
full := Set
with: #abc
with: 5
现在我们需要一个测试方法。让我们测试看看给一个集合添加元素究竟能否成功。
SetTestCase>>testAdd
empty add: 5.
self should: [empty includes: 5]
现在我们可以通过执行”(SetTestCase selector: #testAdd) run”来运行一个测试案例。
这里有一个使用shouldnt:的情况。他这么念的”after removing 5 from full, full should include #abc and it shouldn’t include 5.”
SetTestCase>>testRemove
full remove: 5.
self should: [full includes: #abc].
self shouldnt: [full includes: 5]
这里又有一个特殊情况,确保在你要使用索引访问的时候发出错误信号。
SetTestCase>>testIllegal
self should: [self errorSignal handle: [:ex | true] do: [empty at: 5. false]]
现在我们可以把它们一起放在一个测试套件里面。
| suite |
suite := TestSuite named: 'Set Tests'.
suite addTestCase: (SetTestCase selector: #testAdd).
suite addTestCase: (SetTestCase selector: #testRemove).
suite addTestCase: (SetTestCase selector: #testIllegal).
^suite
这里是这个套件的对象浏览器图TestResult是我们运行之后得到的。

以上所展示的测试方法仅仅覆盖了Set中的一小部分功能。给Set中所有的公共方法写测试确实是一个很让人郁闷的任务。尽管如此,就像 Hal Hildebrand 在使用了这个框架的早期一个版本之后告诉我的那样。“如果根本的对象不能正常工作,其他一切都免提。你必须写测试来确保所有的东西都能正常工作!”



0 Comments