Gerard Meszaros 在 [Meszaros2007] 中介绍了测试替身的概念:
PHPUnit 提供的 getMock($className)
方法可以在测试中用来自动生成一个对象,它可以充当指定原版类的测试替身。在任何预期使用原始类的实例对象的上下文中都可以使用这个测试对象类来代替。
在默认情况下,原始类的所有方法都会被替换为只会返回 NULL
山寨实现(其中不会调用原版方法)。使用诸如 will($this->returnValue())
之类的方法可以对这些山寨实现在被调用时应当返回什么值做出配置。
请注意,final
、private
和 static
方法无法对其进行短连(stub)或模仿(mock)。PHPUnit 的测试替身功能将会忽略它们,并维持它们的原始行为。
请关注一下这个事实:参数管理方式已经修改过了。在之前的实现中,将会克隆对象的所有参数。这样就无法检查传递给方法的是否是同一个对象。例 9.15展示了新的实现方式在什么情况下会非常有用。例 9.16展示了如何切换回之前的行为方式。
将对象替换为(可选地)返回配置好的返回值的测试替身的实践方法被称为短连(stubbing)。可以用短连件(stub) 来“替换掉被测系统所依赖的实际组件,这样测试就有了对被测系统的间接输入的控制点。这使得测试能强制安排被测系统的执行路径,否则被测系统可能无法执行”。
例 9.2 展示了如何对方法的调用进行短连以及如何设定返回值。首先用 PHPUnit_Framework_TestCase
类提供的 getMock()
方法来建立一个短连件对象,它表面看起来像是 SomeClass
类(例 9.1)的实例。随后用 PHPUnit 提供的流畅式接口来指定短连件的行为。本质上,这意味着不需要建立多个临时对象然后再把它们捆到一起。取而代之的是范例中所示的链式方法调用。这使得代码更加易读并更加“流畅”。
例 9.2: 对某个方法的调用进行短连,返回固定值
<?php require_once 'SomeClass.php'; class StubTest extends PHPUnit_Framework_TestCase { public function testStub() { // 为 SomeClass 类创建短连件。 $stub = $this->getMock('SomeClass'); // 配置短连件。 $stub->expects($this->any()) ->method('doSomething') ->will($this->returnValue('foo')); // 现在调用 $stub->doSomething() 将返回 'foo'。 $this->assertEquals('foo', $stub->doSomething()); } } ?>
“在幕后”,当使用了 getMock()
方法时, PHPUnit 自动生成了一个新的 PHP 类来实现想要的行为。所生成的测试替身类可以通过 getMock()
的可选参数来进行配置。
默认情况下,给定类的所有方法都会替换为只会返回 NULL
的测试替身,除非用 will($this->returnValue())
之类的方法配置了测试替身的返回值。
如果提供了第二个(可选)参数,那么只有名称在数组中的方法会被替换为可配置的测试替身。其他方法的行为不会有所改变。如果以 NULL
作为(第二)参数,意味着不会有方法被替换。
第三(可选)参数持有的是传递给原版类的构造函数(默认情况下不会被替换为山寨实现)的参数数组。
第四(可选)参数用于指定生成的测试替身类的类名。
第五(可选)参数可用于禁用对原版类的构造方法的调用。
第六(可选)参数可用于禁用对原版类的克隆构造方法的调用。
第七(可选)参数可用于在测试替身类的生成期间禁用 __autoload()
。
另外,仿件生成器(Mock Builder) API 也可以用来对生成的测试替身类进行配置。例 9.3展示了一个例子。下面列出了可以在仿件生成器的流畅式接口中使用的方法:
setMethods(array $methods)
可以在仿件生成器对象上调用,来指定哪些方法将被替换为可配置的测试提升。其他方法的行为不会有所改变。如果调用 setMethods(NULL)
,那么没有方法会被替换。
setConstructorArgs(array $args)
可用于提供传递给原版类的构造函数(默认情况下不会被替换为山寨实现)的参数数组。
setMockClassName($name)
可用于指定生成的测试替身类的类名。
disableOriginalConstructor()
参数可用于禁用对原版类的构造方法的调用。
disableOriginalClone()
可用于禁用对原版类的克隆构造方法的调用。
disableAutoload()
可用于在测试替身类的生成期间禁用 __autoload()
。
例 9.3: 使用可用于配置生成的测试替身类的仿件生成器 API
<?php require_once 'SomeClass.php'; class StubTest extends PHPUnit_Framework_TestCase { public function testStub() { // 为 SomeClass 类创建短连件。 $stub = $this->getMockBuilder('SomeClass') ->disableOriginalConstructor() ->getMock(); // 配置短连件。 $stub->expects($this->any()) ->method('doSomething') ->will($this->returnValue('foo')); // 现在调用 $stub->doSomething() 将返回 'foo'。 $this->assertEquals('foo', $stub->doSomething()); } } ?>
有时想要将(未改变的)方法调用时所使用的参数之一作为短连的方法的调用结果来返回。例 9.4展示了如何用 returnArgument()
代替 returnValue()
来做到这点。
例 9.4: 对某个方法的调用进行短连,返回参数之一
<?php require_once 'SomeClass.php'; class StubTest extends PHPUnit_Framework_TestCase { public function testReturnArgumentStub() { // 为 SomeClass 类创建短连件。 $stub = $this->getMock('SomeClass'); // 配置短连件。 $stub->expects($this->any()) ->method('doSomething') ->will($this->returnArgument(0)); // $stub->doSomething('foo') 返回 'foo' $this->assertEquals('foo', $stub->doSomething('foo')); // $stub->doSomething('bar') 返回 'bar' $this->assertEquals('bar', $stub->doSomething('bar')); } } ?>
在对流畅式接口进行测试时,让某个短连的方法返回对短连件对象的引用有时会很有用。例 9.5 展示了如何用 returnSelf()
来做到这点。
例 9.5: 对方法的调用进行短连,返回对短连件对象的引用
<?php require_once 'SomeClass.php'; class StubTest extends PHPUnit_Framework_TestCase { public function testReturnSelf() { // 为 SomeClass 类创建短连件。 $stub = $this->getMock('SomeClass'); // 配置短连件。 $stub->expects($this->any()) ->method('doSomething') ->will($this->returnSelf()); // $stub->doSomething() 返回 $stub $this->assertSame($stub, $stub->doSomething()); } } ?>
有时候,短连的方法需要根据预定义的参数清单来返回不同的值。可以用 returnValueMap()
方法将参数和相应的返回值关联起来建立映射。范例参见例 9.6。
例 9.6: 对方法的调用进行短连,按照映射确定返回值
<?php require_once 'SomeClass.php'; class StubTest extends PHPUnit_Framework_TestCase { public function testReturnValueMapStub() { // 为 SomeClass 类创建短连件。 $stub = $this->getMock('SomeClass'); // 创建从参数到返回值的映射。 $map = array( array('a', 'b', 'c', 'd'), array('e', 'f', 'g', 'h') ); // 配置短连件。 $stub->expects($this->any()) ->method('doSomething') ->will($this->returnValueMap($map)); // $stub->doSomething() 根据提供的参数返回不同的值。 $this->assertEquals('d', $stub->doSomething('a', 'b', 'c')); $this->assertEquals('h', $stub->doSomething('e', 'f', 'g')); } } ?>
如果短连的方法需要返回计算得到的值而不是固定值(参见 returnValue()
)或某个(未改变的)参数(参见 returnArgument()
),可以用 returnCallback()
来让短连的方法返回回调函数或方法的结果。范例参见 例 9.7。
例 9.7: 对方法的调用进行短连,由回调生成返回值
<?php require_once 'SomeClass.php'; class StubTest extends PHPUnit_Framework_TestCase { public function testReturnCallbackStub() { // 为 SomeClass 类创建短连件。 $stub = $this->getMock('SomeClass'); // 配置短连件。 $stub->expects($this->any()) ->method('doSomething') ->will($this->returnCallback('str_rot13')); // $stub->doSomething($argument) 返回 str_rot13($argument) $this->assertEquals('fbzrguvat', $stub->doSomething('something')); } } ?>
相比于建立回调方法,有一个更简单的选择是直接给出期望返回值的列表。可以用 onConsecutiveCalls()
方法来做到这个。范例参见 例 9.8。
例 9.8: 对方法的调用进行短连,按照指定顺序返回列表中的值
<?php require_once 'SomeClass.php'; class StubTest extends PHPUnit_Framework_TestCase { public function testOnConsecutiveCallsStub() { // 为 SomeClass 类创建短连件。 $stub = $this->getMock('SomeClass'); // 配置短连件。 $stub->expects($this->any()) ->method('doSomething') ->will($this->onConsecutiveCalls(2, 3, 5, 7)); // $stub->doSomething() 每次返回值都不同 $this->assertEquals(2, $stub->doSomething()); $this->assertEquals(3, $stub->doSomething()); $this->assertEquals(5, $stub->doSomething()); } } ?>
除了返回一个值之外,短连的方法还能抛出一个异常。例 9.9展示了如何用 throwException()
做到这点。
例 9.9: 对方法的调用进行短连,抛出异常
<?php require_once 'SomeClass.php'; class StubTest extends PHPUnit_Framework_TestCase { public function testThrowExceptionStub() { // 为 SomeClass 类创建短连件。 $stub = $this->getMock('SomeClass'); // 配置短连件。 $stub->expects($this->any()) ->method('doSomething') ->will($this->throwException(new Exception)); // $stub->doSomething() 抛出异常 $stub->doSomething(); } } ?>
另外,也可以自行编写短连件,并在此过程中改善设计。在系统中被广泛使用的资源是通过单个外观(facade)来访问的,因此很容易就能用短连件替换掉资源。例如,将散落在代码各处的对数据库的直接调用替换为单个 Database
对象,这个对象实现了 IDatabase
接口。接下来,就可以创建实现了 IDatabase
的短连件并在测试中使用之。甚至可以创建一个选项来控制是用短连件还是用真实数据库来运行测试,这样测试就既能在开发过程中用作本地测试,又能在实际数据库环境中进行集成测试。
需要进行短连的功能往往集中在同一个对象中,这就改善了内聚度。 将功能通过单一且一致的界面呈现出来,就降低了这部分与系统其他部分之间的耦合度。
将对象替换为能验证预期行为(例如断言某个方法必会被调用)的测试替身的实践方法被称为模仿(mocking)。
可以用仿件对象(mock object)“作为观察点来核实被测试系统在测试中的间接输出。通常,仿件对象还需要包括短连件的功能,因为如果测试尚未失败则仿件对象需要向被测系统返回一些值,但是其重点还是在对间接输出的核实上。因此,仿件对象远不止是短连件加断言,它是以一种从根本上完全不同的方式来使用的。”
PHPUnit只会对在某个测试的作用域内生成的仿件对象进行自动校验,PHPUnit 不会对在诸如数据供给器内生成的仿件对象进行校验。
这有个例子:假设需要测试当前方法,在例子中是 update()
,确实在一个观察着另外一个对象的对象中上被调用了。例 9.10展示了被测系统(SUT)中 Subject
和 Observer
两个类的代码。
例 9.10: 被测系统(SUT)中 Subject 与 Observer 类的代码
<?php class Subject { protected $observers = array(); protected $name; public function __construct($name) { $this->name = $name; } public function getName() { return $this->name; } public function attach(Observer $observer) { $this->observers[] = $observer; } public function doSomething() { // 做点什么。 // ... // 通知观察者。 $this->notify('something'); } public function doSomethingBad() { foreach ($this->observers as $observer) { $observer->reportError(42, 'Something bad happened', $this); } } protected function notify($argument) { foreach ($this->observers as $observer) { $observer->update($argument); } } // 其他方法。 } class Observer { public function update($argument) { // 做点什么。 } public function reportError($errorCode, $errorMessage, Subject $subject) { // 做点什么。 } // 其他方法。 } ?>
例 9.11展示了如何用仿件对象来测试
Subject
和 Observer
对象之间的互动。
首先用 PHPUnit_Framework_TestCase
类提供的 getMock()
方法建立 Observer
的仿件对象。由于给出了一个数组做为 getMock()
方法的第二(可选)参数,Observer
类只有 update()
方法会被替换为仿实现。
例 9.11: 测试某个方法会以特定参数被调用一次
<?php class SubjectTest extends PHPUnit_Framework_TestCase { public function testObserversAreUpdated() { // 为 Observer 类建立仿件对象,只模仿 update() 方法。 $observer = $this->getMock('Observer', array('update')); // 建立预期状况:update() 方法将会被调用一次, // 并且将以字符串 'something' 为参数。 $observer->expects($this->once()) ->method('update') ->with($this->equalTo('something')); // 创建 Subject 对象,并将模仿的 Observer 对象连接其上。 subject = new Subject('My subject'); $subject->attach($observer); // 在 $subject 对象上调用 doSomething() 方法, // 预期将以字符串 'something' 为参数调用 // Observer 仿件对象的 update() 方法。 $subject->doSomething(); } } ?>
with()
方法可以携带任何数量的参数,对应于被模仿的方法的参数数量。可以对方法的参数指定更加高等的约束而不仅是简单的匹配。
例 9.12: 测试某个方法将会以特定数量的参数进行调用,并且对各个参数以多种方式进行约束
<?php class SubjectTest extends PHPUnit_Framework_TestCase { public function testErrorReported() { // 为 Observer 类建立仿件,对 reportError() 方法进行模仿 $observer = $this->getMock('Observer', array('reportError')); $observer->expects($this->once()) ->method('reportError') ->with($this->greaterThan(0), $this->stringContains('Something'), $this->anything()); $subject = new Subject('My subject'); $subject->attach($observer); // doSomethingBad() 方法应当会通过(observer的)reportError()方法 //向 observer 报告错误。 $subject->doSomethingBad(); } } ?>
withConsecutive()
方法可以接受任意多个数组作为参数,具体数量取决于欲测试的调用。每个数组都都是对被仿方法的相应参数的一组约束,就像 with()
中那样。
例 9.13: 测试某个方法将会以特定参数被调用二次
<?php class FooTest extends PHPUnit_Framework_TestCase { public function testFunctionCalledTwoTimesWithSpecificArguments() { $mock = $this->getMock('stdClass', array('set')); $mock->expects($this->exactly(2)) ->method('set') ->withConsecutive( array($this->equalTo('foo'), $this->greaterThan(0)), array($this->equalTo('bar'), $this->greaterThan(0)) ); $mock->set('foo', 21); $mock->set('bar', 48); } } ?>
callback()
约束用来进行更加复杂的参数校验。此约束的唯一参数是一个 PHP 回调项(callback)。此 PHP 回调项接受需要校验的参数作为其唯一参数,并应当在参数通过校验时返回 TRUE
,否则返回 FALSE
。
例 9.14: 更加复杂的参数校验
<?php class SubjectTest extends PHPUnit_Framework_TestCase { public function testErrorReported() { // Create a mock for the Observer class, mocking the // reportError() method $observer = $this->getMock('Observer', array('reportError')); $observer->expects($this->once()) ->method('reportError') ->with($this->greaterThan(0), $this->stringContains('Something'), $this->callback(function($subject){ return is_callable(array($subject, 'getName')) && $subject->getName() == 'My subject'; })); $subject = new Subject('My subject'); $subject->attach($observer); // doSomethingBad() 方法应当会通过(observer的)reportError()方法 //向 observer 报告错误。 $subject->doSomethingBad(); } } ?>
例 9.15: 测试某个方法将会被调用一次,并且以某个特定对象作为参数。
<?php class FooTest extends PHPUnit_Framework_TestCase { public function testIdenticalObjectPassed() { $expectedObject = new stdClass; $mock = $this->getMock('stdClass', array('foo')); $mock->expects($this->once()) ->method('foo') ->with($this->identicalTo($expectedObject)); $mock->foo($expectedObject); } } ?>
例 9.16: 创建仿件对象时启用参数克隆
<?php class FooTest extends PHPUnit_Framework_TestCase { public function testIdenticalObjectPassed() { $cloneArguments = true; $mock = $this->getMock( 'stdClass', array(), array(), '', FALSE, TRUE, TRUE, $cloneArguments ); // 也可以用仿件生成器 $mock = $this->getMockBuilder('stdClass') ->enableArgumentCloning() ->getMock(); // 现在仿件将对参数进行克隆,因此 identicalTo 约束将会失败。 } } ?>
表 A.1列出了可以应用于方法参数的各种约束,表 9.1列出了可以用于指定调用次数的各种匹配器。
表 9.1. 匹配器
匹配器 | 含义 |
---|---|
PHPUnit_Framework_MockObject_Matcher_AnyInvokedCount any() | 返回一个匹配器,当被评定的方法执行0次或更多次(即任意次数)时匹配成功。 |
PHPUnit_Framework_MockObject_Matcher_InvokedCount never() | 返回一个匹配器,当被评定的方法从未执行时匹配成功。 |
PHPUnit_Framework_MockObject_Matcher_InvokedAtLeastOnce atLeastOnce() | 返回一个匹配器,当被评定的方法执行至少一次时匹配成功。 |
PHPUnit_Framework_MockObject_Matcher_InvokedCount once() | 返回一个匹配器,当被评定的方法执行恰好一次时匹配成功。 |
PHPUnit_Framework_MockObject_Matcher_InvokedCount exactly(int $count) | 返回一个匹配器,当被评定的方法执行恰好 $count 次时匹配成功。 |
PHPUnit_Framework_MockObject_Matcher_InvokedAtIndex at(int $index) | 返回一个匹配器,当被评定的方法是第 $index 个执行的方法时匹配成功。 |
at()
匹配器的 $index
参数指的是对给定仿件对象的所有方法的调用的索引,从零开始。使用这个匹配器要谨慎,因为它可能导致测试由于与具体的实现细节过分紧密绑定而变得脆弱。
getMockForTrait()
方法返回一个使用了特定性状(trait)的仿件对象。给定性状的所有抽象方法将都被模仿。这样就能对性状的具体方法进行测试。
例 9.17: 对性状的具体方法进行测试
<?php trait AbstractTrait { public function concreteMethod() { return $this->abstractMethod(); } public abstract function abstractMethod(); } class TraitClassTest extends PHPUnit_Framework_TestCase { public function testConcreteMethod() { $mock = $this->getMockForTrait('AbstractTrait'); $mock->expects($this->any()) ->method('abstractMethod') ->will($this->returnValue(TRUE)); $this->assertTrue($mock->concreteMethod()); } } ?>
getMockForAbstractClass()
方法返回一个抽象类的仿件对象。给定抽象类的所有抽象方法将都被模仿。这样就能对抽象类的具体方法进行测试。
例 9.18: 对抽象类的具体方法进行测试
<?php abstract class AbstractClass { public function concreteMethod() { return $this->abstractMethod(); } public abstract function abstractMethod(); } class AbstractClassTest extends PHPUnit_Framework_TestCase { public function testConcreteMethod() { $stub = $this->getMockForAbstractClass('AbstractClass'); $stub->expects($this->any()) ->method('abstractMethod') ->will($this->returnValue(TRUE)); $this->assertTrue($stub->concreteMethod()); } } ?>
当应用程序需要和 web 服务进行交互时,会想要在不与 web 服务进行实际交互的情况下对其进行测试。为了简单地对 web 服务进行短连或模仿,可以像使用 getMock()
(见上文)那样使用 getMockFromWsdl()
。唯一的区别是 getMockFromWsdl()
所返回的短连件或者仿件是基于以 WSDL 描述的 web 服务,而 getMock()
返回的短连件或者仿件是基于 PHP 类或接口的。
例 9.19展示了如何用 getMockFromWsdl()
来对(例如)GoogleSearch.wsdl
中描述的 web 服务进行短连。
例 9.19: 对 web 服务进行短连
<?php class GoogleTest extends PHPUnit_Framework_TestCase { public function testSearch() { $googleSearch = $this->getMockFromWsdl( 'GoogleSearch.wsdl', 'GoogleSearch' ); $directoryCategory = new StdClass; $directoryCategory->fullViewableName = ''; $directoryCategory->specialEncoding = ''; $element = new StdClass; $element->summary = ''; $element->URL = 'http://www.phpunit.de/'; $element->snippet = '...'; $element->title = '<b>PHPUnit</b>'; $element->cachedSize = '11k'; $element->relatedInformationPresent = TRUE; $element->hostName = 'www.phpunit.de'; $element->directoryCategory = $directoryCategory; $element->directoryTitle = ''; $result = new StdClass; $result->documentFiltering = FALSE; $result->searchComments = ''; $result->estimatedTotalResultsCount = 3.9000; $result->estimateIsExact = FALSE; $result->resultElements = array($element); $result->searchQuery = 'PHPUnit'; $result->startIndex = 1; $result->endIndex = 1; $result->searchTips = ''; $result->directoryCategories = array(); $result->searchTime = 0.248822; $googleSearch->expects($this->any()) ->method('doGoogleSearch') ->will($this->returnValue($result)); /** * $googleSearch->doGoogleSearch() 将会返回短连的结果, * web 服务的 doGoogleSearch() 方法不会被调用。 */ $this->assertEquals( $result, $googleSearch->doGoogleSearch( '00000000000000000000000000000000', 'PHPUnit', 0, 1, FALSE, '', FALSE, '', '', '' ) ); } } ?>
vfsStream 是对虚拟文件系统的流包装器(stream wrapper),可以用于模仿真实文件系统,在单元测试中可能会有所助益。
如果使用 Composer 来管理项目的依赖关系,那么只需简单的在项目的 composer.json
文件中加一条对 mikey179/vfsStream
的依赖关系即可。以下是一个最小化的 composer.json
文件例子,只定义了一条对 PHPUnit 4.2 与 vfsStream 的开发时(development-time)依赖:
{ "require-dev": { "phpunit/phpunit": "4.2.*", "mikey179/vfsStream": "1.*" } }
例 9.20展示了一个与文件系统交互的类。
例 9.20: 一个与文件系统交互的类
<?php class Example { protected $id; protected $directory; public function __construct($id) { $this->id = $id; } public function setDirectory($directory) { $this->directory = $directory . DIRECTORY_SEPARATOR . $this->id; if (!file_exists($this->directory)) { mkdir($this->directory, 0700, TRUE); } } }?>
如果不使用诸如 vfsStream 这样的虚拟文件系统,就无法在隔离外部影响的情况下对 setDirectory()
方法进行测试(参见 例 9.21)。
例 9.21: 对一个与文件系统交互的类进行测试
<?php require_once 'Example.php'; class ExampleTest extends PHPUnit_Framework_TestCase { protected function setUp() { if (file_exists(dirname(__FILE__) . '/id')) { rmdir(dirname(__FILE__) . '/id'); } } public function testDirectoryIsCreated() { $example = new Example('id'); $this->assertFalse(file_exists(dirname(__FILE__) . '/id')); $example->setDirectory(dirname(__FILE__)); $this->assertTrue(file_exists(dirname(__FILE__) . '/id')); } protected function tearDown() { if (file_exists(dirname(__FILE__) . '/id')) { rmdir(dirname(__FILE__) . '/id'); } } } ?>
上面的方法有几个缺点:
和任何其他外部资源一样,文件系统可能会有一些间歇性的问题,这使得和它交互的测试变得不可靠。
在 setUp()
和 tearDown()
方法中,必须确保这个目录在测试前和测试后均不存在。
如果测试在 tearDown()
方法被调用之前就终止了,这个目录就会遗留在文件系统中。
例 9.22展示了如何在对与文件系统交互的类进行的测试中使用 vfsStream 来模仿文件系统。
例 9.22: 在对与文件系统交互的类进行的测试中模仿文件系统
<?php require_once 'vfsStream/vfsStream.php'; require_once 'Example.php'; class ExampleTest extends PHPUnit_Framework_TestCase { public function setUp() { vfsStreamWrapper::register(); vfsStreamWrapper::setRoot(new vfsStreamDirectory('exampleDir')); } public function testDirectoryIsCreated() { $example = new Example('id'); $this->assertFalse(vfsStreamWrapper::getRoot()->hasChild('id')); $example->setDirectory(vfsStream::url('exampleDir')); $this->assertTrue(vfsStreamWrapper::getRoot()->hasChild('id')); } } ?>
这有几个优点:
测试本身更加简洁。
vfsStream 让开发者能够完全控制被测代码所处的文件系统环境。
由于文件系统操作不再对真实文件系统进行操作,tearDown()
方法中的清理操作不再需要了。