Pharo By Example - 第十二章 Pharo测试框架

标签: pharo ; smalltalk ;


SUnit 是一个简洁但功能强大的框架,支持测试的创建和验证。从其名称可以猜到,SUnit 的设计专注于单元测试,但实际上它也可以用于集成测试和功能测试。SUnit 最初由 Kent Beck 开发,随后由 Joseph Pelrine 和许多贡献者扩展。SUnit 是所有其他 xUnit 框架的鼻祖。

本章尽量简短,以向您展示测试是简单的。有关SUnit和不同的测试方法的更深入描述,请阅读《在Pharo中进行测试》一书,可在 http://books.pharo.org 上找到。

在本章中,我们首先讨论为什么要测试,以及什么是好的测试。然后,我们通过一系列小示例展示如何使用 SUnit。最后,我们探讨 SUnit 的实现,以便你理解 Pharo 如何利用反射的力量来支持其工具。请注意,本章中记录并在 Pharo 中使用的版本是经过修改的 SUnit 3.3 版本。

12.1 引言

对测试和测试驱动开发的兴趣不仅限于 Pharo。自动化测试已成为敏捷软件开发运动的标志,任何关注提高软件质量的软件开发人员都应采用它。事实上,许多语言的开发者已经认识到单元测试的力量,现在每种编程语言都有 xUnit 的版本。

测试和测试套件的构建都不是什么新鲜事。到目前为止,每个人都知道测试是捕获错误的好方法。极限编程(eXtreme Programming)通过将测试作为核心实践并强调自动化测试,帮助使测试变得高效且有趣,而不是程序员讨厌的琐事。

SUnit 的价值在于它允许我们编写自检查的可执行测试:测试本身定义了正确的结果应该是什么。它还帮助我们将测试组织成组,描述测试必须运行的上下文,并自动运行一组测试。在不到两分钟的时间内,你可以使用 SUnit 编写测试,因此我们鼓励你使用 SUnit,而不是在 playground 中编写小代码片段,从而获得存储和自动执行测试的所有优势。

12.2 为什么测试很重要

不幸的是,许多开发者认为测试是浪费时间。毕竟,他们不会写 bug,只有其他程序员才会这样做。我们中的大多数人曾在某个时候说过:如果有更多时间,我会写测试。如果你从不写 bug,并且你的代码将来永远不会改变,那么测试确实是浪费时间。然而,这也可能意味着你的应用程序是微不足道的,或者它没有被你或其他人使用。将测试视为对未来的投资:拥有一套测试现在非常有用,但当你的应用程序或其运行环境在未来发生变化时,它将变得极其有用。

测试扮演着多重角色。首先,它们提供了所涵盖功能的文档。这种文档是动态的:看到测试通过告诉你文档是最新的。其次,测试帮助开发者确认他们对包的最近更改没有破坏系统中的任何其他部分,并在这种信心被证明是错误时找到破坏的部分。最后,在编程期间甚至之前编写测试迫使你思考你想要设计的功能以及它应该如何呈现给客户端代码,而不是如何实现它。

通过先编写测试,即在代码之前,你被迫说明你的功能将运行的上下文、它将如何与客户端代码交互以及预期的结果。你的代码将会得到改进。试试看。

我们无法测试任何实际应用程序的所有方面。覆盖完整的应用程序根本不可能,也不应该是测试的目标。即使有一个好的测试套件,一些 bug 仍然会潜入应用程序中,它们可能会潜伏等待机会破坏你的系统。如果你发现这种情况发生了,利用它!一旦你发现 bug,编写一个暴露它的测试,运行测试并观察它失败。现在你可以开始修复 bug:测试会告诉你何时完成。

12.3 什么是好的测试

编写好的测试是一项可以通过练习学习的技能。让我们来看看测试应该具有哪些属性才能获得最大的好处。

  • 测试应该是可重复的。您应该能够随心所欲地运行测试,并且始终得到相同的答案。

  • 测试应该在没有人为干预的情况下运行。您应该能够在无人值守的情况下运行它们。

  • 测试应该讲述一个故事。每个测试都应该涵盖一段代码的一个方面。测试应该充当您或其他人可以阅读以理解一项功能的场景。

  • 测试应该验证一个方面。当测试失败时,它应该表明有一个方面被破坏了。事实上,如果测试涵盖多个方面,首先它会更频繁地崩溃,其次它会迫使开发人员在修复时理解更多的选项集。

这种属性的一个结果是,测试的数量应该与要测试的方面的数量成比例:更改系统的一个方面不应该破坏所有的测试,而应该只破坏有限的数量。这一点很重要,因为100次测试失败应该比10次测试失败传递出更强烈的信息。然而,实现这一理想并不总是可能的:尤其是,如果更改中断了对象的初始化或测试的设置,则很可能导致所有测试失败。

12.4 SUnit手把手

编写测试本身并不困难。现在,让我们编写我们的第一个测试,并向您展示使用SUnit的好处。我们使用一个测试Set类的示例。

我们将会:

  • 定义一组测试的类,并从SUnit的行为中获益。

  • 定义测试方法。

  • 使用断言验证预期结果。

  • 执行测试。

在我们进行的过程中编写代码并执行测试。

12.5 创建测试类

首先,您应该创建一个名为MyExampleSetTest的新的TestCase子类。添加两个实例变量,使您的新类如下所示:

清单12-1 一个Set测试类示例

TestCase subclass: #MyExampleSetTest
  instanceVariableNames: 'full empty'
  classVariableNames: ''
  package: 'MySetTest'
  

我们将使用MyExampleSetTest类对与Set类相关的所有测试进行分组。它定义了测试将要运行的上下文。在这里,上下文由两个实例变量fullempty描述,我们将使用它们来表示全集和空集。

类的名称并不重要,但是按照惯例,它应该以Test结尾。如果您定义一个名为Pattern的类并调用相应的测试类PatternTest,则这两个类将在浏览器中一起按字母顺序排列(假设它们位于同一个包中)。您的类必须是TestCase的子类,这一点至关重要。

12.6 第二步:初始化测试上下文

消息TestCase>>setUp定义了运行测试的上下文,这有点像初始化方法。在执行测试类中定义的每个测试方法之前调用setUp程序。

如下定义 setUp 方法,将 empty 变量初始化为一个空集合,并将 full 变量初始化为包含两个元素的集合。

MyExampleSetTest >> setUp
  empty := Set new.
  full := Set with: 5 with: 6
  

在测试术语中,上下文被称为测试的 fixture(夹具)。

12.7 第三步:编写一些测试方法

我们通过在MyExampleSetTest类里面定义一些方法来创建一些测试。每一个方法代表一个测试。方法的名称应该以字符串test开头,这样SUnit就可以将它们收集到测试套件中。测试方法不接受任何参数。

定义以下测试方法。第一个测试名为testIncludes,测试Setincludes:方法。测试表明,向包含5的集合发送消息includes: 5应返回true。显然,此测试依赖于setUp方法已经运行这一前提事实。

MyExampleSetTest >> testIncludes
  self assert: (full includes: 5).
  self assert: (full includes: 6)
  

第二个测试名为testOccurence,它验证在full集合中5的出现次数是否等于1,即使我们在集合中添加了另一个元素5

MyExampleSetTest >> testOccurrences
  self assert: (empty occurrencesOf: 0) equals: 0.
  self assert: (full occurrencesOf: 5) equals: 1.
  full add: 5.
  self assert: (full occurrencesOf: 5) equals: 1
  

最后,在删除元素5之后,我们测试它是否不再包含元素5。

MyExampleSetTest >> testRemove
  full remove: 5.
  self assert: (full includes: 6)
  self deny: (full includes: 5)
  

注意使用方法TestCase >> deny:来断言不应该为真的东西。aTest deny: anExpression等同于aTest assert: anExpression not,但可读性更好。

12.8 第四步:运行测试

运行测试的最简单方法是直接从浏览器从运行。只需单击类名的图标或单个测试方法,然后选择Run Testing(T)或按下图标。测试方法将被标记为绿色或红色,这取决于它们是否通过(如图12-2所示)。

您还可以选择要运行的测试套件集,并使用SUnit Test Runner获取更详细的结果日志,您可以通过选择 World > Test Runner 打开它。

打开 Test Runner ,选择MySetTest包,然后单击'Run Selected'按钮。

您还可以运行一个测试(并打印通常的通过/失败结果摘要),方法是对以下代码:MyExampleSetTestRun: #testRemove执行"Print it"。

有些人在他们的测试方法中包含一个可执行注释,允许在浏览器中运行带有Do It的测试方法,如下所示。

MyExampleSetTest >> testRemove
  full remove: 5.
  self assert: (full includes: 6).
  self deny: (full includes: 5)
  

MyExampleSetTest >> testRemove中引入错误并运行测试。例如,将6更改为7,如:

MyExampleSetTest >> testRemove
  full remove: 5.
  self assert: (full includes: 7).
  self deny: (full includes: 5)
  

未通过的测试(如果有的话)列在测试运行器的右侧窗格中。如果您想调试一下,要查看它失败的原因,只需单击名称即可。或者,您可以执行以下表达式之一:

(MyExampleSetTest selector: #testRemove) debug

MyExampleSetTest debug: #testRemove

12.9 第五步:解读结果

方法assert:TestAsserter类中定义。这是TestCase的超类,因此是所有其他TestCase子类的超类,负责所有类型的测试结果断言。assert:方法需要一个布尔参数,通常是经过测试的表达式的值。当参数为真时,测试通过;当参数为假时,测试失败。

测试实际上有三种可能的结果:通过、失败和引发错误。

  • 通过 我们希望的结果是测试中的所有断言都是真的,在这种情况下,测试通过了。在视觉上,测试工具使用绿色表示测试通过。

  • 失败 显而易见的方式是,其中一个断言可能是假的,从而导致测试失败。测试失败用黄色表示。

  • 错误 另一种可能性是在测试执行期间发生某种类型的错误,例如消息无法理解错误或索引越界错误。如果出现错误,那么测试方法中的断言可能根本没有执行,所以我们不能说测试失败了;然而,显然有些地方是错误的!错误通常以红色表示。

您可以尝试并修改您的测试以引发错误和失败。

12.10 使用assert:equals:

在出错的情况下消息assert:equals:提供了比assert:更好的报告。例如,以下两个测试是等价的。然而,第二个测试将会报告测试的期望值:这使得理解失败变得更容易。在本例中,我们假设aDateAndTime是测试类的实例变量。

testAsDate
  self assert: aDateAndTime asDate = ('February 29, 2004' asDate translateTo: 2 hours).
  
testAsDate
  self
    assert: aDateAndTime asDate
    equals: ('February 29, 2004', asDate translateTo: 2 hours).
    

12.11 跳过一个测试

有时个,在开发过程中,您可能希望跳过测试,而不是移除它或重命名它以阻止它运行。您只需在测试用例实例上调用TestAsserterskip消息即可。例如,下面的测试使用它来定义条件测试。

清单12-3

MyExampleSetTest >> testIllegal
  self should: [ empty at: 5 ] raise: Error.
  self should: [ empty at: 5 put: #zork ] raise: Error
  
OCCompiledMethodIntegrityTest >> testPragmas

  | newCompiledMethod originalCompiledMethod |
  (Smalltalk globals hasClassNamed: #Compiler) ifFalse: [ ^ self skip ].
  ...
  

这可以方便地确保您的自动测试执行报告成功。

12.12 断言异常

SUnit提供了另外两个重要的方法,TestAsserter >> should:raise:TestAsserter >> shouldnt:raise:用于引发测试异常。

例如,您可以使用self should: aBlock raise: anException来测试在执行某个块的过程中是否引发了特定的异常。下面的方法说明了should:raise:的用法。

尝试运行此测试。注意,方法的第一个参数是包含要执行的表达式的块。

12.13 以编程方式运行测试

通常,您将使用Test Runner或代码浏览器运行测试。

运行单一的测试

如果不想从World菜单启动测试运行程序UI,可以执行TestRunner open。您还可以运行单个测试,如下所示:

MyExampleSetTest run: #testRemove
>>> 1 run, 1 passed, 0 failed, 0 errors

在测试类中运行所有测试

TestCase的所有子类都可以响应消息suite,该suite消息将构建一个测试套件,其中包含类中名称以字符串test开头的所有方法。

要在套件中运行测试,请向其发送消息run。例如:

MyExampleSetTest suite run
>>> 4 run, 4 passed, 0 failed, 0 errors

12.14 小结

本章解释了为什么测试是对代码未来的一项重要投资。我们以循序渐进的方式解释了如何为Set类定义几个测试。

  • 为了最大限度地发挥它们的潜力,单元测试应该是快速的、可重复的、独立于任何直接的人类交互的,并且覆盖单个功能单元。

  • 名为MyClass的类的测试属于名为MyClassTest的类,它应该作为TestCase的子类引入。

  • setUp方法中初始化测试数据。

  • 每一个测试方法都应该以单词test开头。

  • 使用TestCase的方法assert:deny:和其他方法进行断言。

  • 运行测试!

一些软件开发方法,如极限编程(eXtreme Programming)和测试驱动开发(TDD),提倡在编写代码之前编写测试。这似乎违背了我们作为软件开发者的本能。我们只能说:去试试吧。我们发现,在编写代码之前编写测试有助于我们了解要编写什么代码,帮助我们了解何时完成,并帮助我们概念化类的功能并设计其接口。此外,测试优先的开发给了我们快速前进的勇气,因为我们不害怕会忘记一些重要的东西。