Test Doubles

测试 Doubles

Gerard Meszaros在[Meszaros2007]中介绍了测试Doubles

有时,测试被测系统(SUT)非常困难,因为它依赖于其他不能在测试环境中使用的组件。这可能是因为它们不可用,它们不会返回测试所需的结果,或者因为执行它们会产生不良的副作用。在其他情况下,我们的测试策略要求我们对 SUT 的内部行为有更多的控制权或可见性。当我们正在编写一个测试时,我们不能(或者不选择)使用一个真正的依赖组件(DOC),我们可以用一个测试双替换它。Test Double 不必像真正的 DOC 一样行事; 它只需提供与真实 API 相同的 API ,以便 SUT 认为它是真正的API!
- 杰拉德梅萨罗斯

PHPUnit 提供的createMock($type)getMockBuilder($type)方法可以在测试中用于自动生成一个对象,该对象可以充当指定的原始类型(接口或类名称)的测试对象。该测试双重对象可以用于预期或要求原始类型的对象的每个上下文中。

createMock($type)方法立即返回指定类型(接口或类)的测试双重对象。使用最佳实践默认值(在执行该测试双重的创建__construct()__clone()原始类的方法不被执行),并传递给测试double的方法的参数不会被克隆。如果这些默认值不是您需要的,那么您可以使用该getMockBuilder($type)方法使用流畅的界面来自定义测试双代。

默认情况下,原始类的所有方法都将替换为仅返回的虚拟实现null(不调用原始方法)。will($this->returnValue())例如,使用该方法,您可以配置这些虚拟实现以在被调用时返回值。

Limitation: final, private, and static methods

请注意finalprivatestatic方法不能被扼杀或嘲弄。它们被 PHPUnit 的测试双重功能忽略,并保留它们的原始行为。

Stubs

使用(可选)返回配置的返回值的测试 double 替换对象的做法被称为存根。您可以使用存根来“替换 SUT 所依赖的实际组件,以便测试具有 SUT 间接输入的控制点,这允许测试强制 SUT 向下执行,否则它可能无法执行”。

例9.2展示了如何对方法调用进行存根并设置返回值。我们首先使用类createMock()提供的方法PHPUnit\Framework\TestCase来设置一个看起来像SomeClass(例9.1)对象的存根对象。然后,我们使用 PHPUnit 提供的Fluent接口来指定存根的行为。实质上,这意味着您不需要创建多个临时对象,然后将它们连接在一起。相反,您可以链接方法调用,如示例中所示。这导致更可读和“流畅”的代码。

例9.1:我们要存根的类

<?php use PHPUnit\Framework\TestCase; class SomeClass { public function doSomething() { // Do something. } } ?>

例9.2:对一个方法调用进行连接以返回一个固定值

<?php use PHPUnit\Framework\TestCase; class StubTest extends TestCase { public function testStub() { // Create a stub for the SomeClass class. $stub = $this->createMock(SomeClass::class // Configure the stub. $stub->method('doSomething') ->willReturn('foo' // Calling $stub->doSomething() will now return // 'foo'. $this->assertEquals('foo', $stub->doSomething() } } ?>

限制: Methods named "method"

上述示例仅在原始类未声明名为“method”的方法时才有效。

如果原始类声明了一个名为 “method” 的方法,那么必须使用$stub->expects($this->any())->method('doSomething')->willReturn('foo'。

“Behind the scenes”,PHPUnit 自动生成一个新的 PHP 类,在使用createMock()方法时实现所需的行为。

例9.3说明了如何使用 Mock Builder 的流畅界面来配置测试组的创建。这个测试的配置使用了createMock()与之相同的最佳实践默认值。

例9.3:使用 Mock Builder API 可以用来配置生成的测试双重类

<?php use PHPUnit\Framework\TestCase; class StubTest extends TestCase { public function testStub() { // Create a stub for the SomeClass class. $stub = $this->getMockBuilder($originalClassName) ->disableOriginalConstructor() ->disableOriginalClone() ->disableArgumentCloning() ->disallowMockingUnknownTypes() ->getMock( // Configure the stub. $stub->method('doSomething') ->willReturn('foo' // Calling $stub->doSomething() will now return // 'foo'. $this->assertEquals('foo', $stub->doSomething() } } ?>

在迄今为止的例子中,我们一直使用返回简单的值willReturn($value)。这个简短的语法和will($this->returnValue($value))。我们可以在这个更长的语法上使用变体来实现更复杂的存根行为。

有时你想要返回一个方法调用的参数之一(不变)作为存根方法调用的结果。例9.4显示了如何使用returnArgument()而不是使用实现returnValue()

例9.4:对一个方法调用进行连接以返回其中一个参数

<?php use PHPUnit\Framework\TestCase; class StubTest extends TestCase { public function testReturnArgumentStub() { // Create a stub for the SomeClass class. $stub = $this->createMock(SomeClass::class // Configure the stub. $stub->method('doSomething') ->will($this->returnArgument(0) // $stub->doSomething('foo') returns 'foo' $this->assertEquals('foo', $stub->doSomething('foo') // $stub->doSomething('bar') returns 'bar' $this->assertEquals('bar', $stub->doSomething('bar') } } ?>

当测试一个流畅的接口时,让一个存根方法返回一个对存根对象的引用有时很有用。例9.5显示了如何使用returnSelf()来实现这一点。

例9.5:保留一个方法调用以返回对存根对象的引用

<?php use PHPUnit\Framework\TestCase; class StubTest extends TestCase { public function testReturnSelf() { // Create a stub for the SomeClass class. $stub = $this->createMock(SomeClass::class // Configure the stub. $stub->method('doSomething') ->will($this->returnSelf() // $stub->doSomething() returns $stub $this->assertSame($stub, $stub->doSomething() } } ?>

有时,根据预定义的参数列表,存根方法应该返回不同的值。您可以使用returnValueMap()创建一个将参数与相应的返回值关联的映射。参见例9.6。

例9.6:对一个方法调用进行连接以从地图返回值

<?php use PHPUnit\Framework\TestCase; class StubTest extends TestCase { public function testReturnValueMapStub() { // Create a stub for the SomeClass class. $stub = $this->createMock(SomeClass::class // Create a map of arguments to return values. $map = [ ['a', 'b', 'c', 'd'], ['e', 'f', 'g', 'h'] ]; // Configure the stub. $stub->method('doSomething') ->will($this->returnValueMap($map) // $stub->doSomething() returns different values depending on // the provided arguments. $this->assertEquals('d', $stub->doSomething('a', 'b', 'c') $this->assertEquals('h', $stub->doSomething('e', 'f', 'g') } } ?>

当存根方法调用应返回计算值而不是固定值(请参阅returnValue())或参数(不变)(请参阅returnArgument())时,可以使用returnCallback()该方法使存根方法返回回调函数或方法的结果。参见例9.7。

例9.7:保留一个方法调用以从回调中返回一个值

<?php use PHPUnit\Framework\TestCase; class StubTest extends TestCase { public function testReturnCallbackStub() { // Create a stub for the SomeClass class. $stub = $this->createMock(SomeClass::class // Configure the stub. $stub->method('doSomething') ->will($this->returnCallback('str_rot13') // $stub->doSomething($argument) returns str_rot13($argument) $this->assertEquals('fbzrguvat', $stub->doSomething('something') } } ?>

设置回调方法的更简单的替代方法可能是指定所需返回值的列表。你可以用这个onConsecutiveCalls()方法来做到这一点。示例请参见例9.8。

例9.8:对一个方法调用进行连接,以指定的顺序返回一个值列表

<?php use PHPUnit\Framework\TestCase; class StubTest extends TestCase { public function testOnConsecutiveCallsStub() { // Create a stub for the SomeClass class. $stub = $this->createMock(SomeClass::class // Configure the stub. $stub->method('doSomething') ->will($this->onConsecutiveCalls(2, 3, 5, 7) // $stub->doSomething() returns a different value each time $this->assertEquals(2, $stub->doSomething() $this->assertEquals(3, $stub->doSomething() $this->assertEquals(5, $stub->doSomething() } } ?>

取而代之的是,一个存根的方法也会引发一个异常。例9.9显示了如何使用throwException()来做到这一点。

例9.9:对一个方法调用进行连接以返回异常

<?php use PHPUnit\Framework\TestCase; class StubTest extends TestCase { public function testThrowExceptionStub() { // Create a stub for the SomeClass class. $stub = $this->createMock(SomeClass::class // Configure the stub. $stub->method('doSomething') ->will($this->throwException(new Exception) // $stub->doSomething() throws Exception $stub->doSomething( } } ?>

或者,您可以自己写存根,并改进设计。广泛使用的资源通过单一外观进行访问,因此您可以轻松地用存根替换资源。例如,您不必在整个代码中分散直接的数据库调用,而只需一个Database对象,一个IDatabase接口的实现者。然后,您可以创建一个存根实现IDatabase并将其用于测试。您甚至可以创建一个选项,以使用存根数据库或实际数据库运行测试,因此您可以在开发过程中使用测试进行本地测试,并可以使用测试与真实数据库进行集成测试。

需要剔除的功能往往集中在同一个对象中,从而提高内聚力。通过将功能与单个连贯的界面一起展示,您可以减少与系统其余部分的耦合。

Mock Objects

用一个验证期望值的测试 double 替换一个对象的做法,例如声明一个方法已被调用,被称为 mocking。

你可以使用一个 mock object 作为观察点,用来验证 SUT 的间接输出,通常,模拟对象还包含测试存根的功能,因为它必须将值返回给 SUT if 它还没有通过测试但重点在于间接输出的验证,因此,一个 mock object 不仅仅是一个测试存根加断言,而是以一种根本不同的方式使用“(Gerard Meszaros) 。

限制:自动验证期望值

只有在 mock 范围内生成的模拟对象将由 PHPUnit 自动验证。例如,在数据提供者中生成的模拟对象,或使用@depends注释注入到测试中的对象不会由 PHPUnit 自动验证。

这里是一个例子:假设我们想要测试update()在我们的例子中正确的方法是在一个观察另一个对象的对象上调用的。例9.10显示了作为被测系统(SUT)一部分的类SubjectObserver类的代码。

例9.10:作为被测系统(SUT)一部分的 Subject 和 Observer 类

<?php use PHPUnit\Framework\TestCase; class Subject { protected $observers = []; 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() { // Do something. // ... // Notify observers that we did something. $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 } } // Other methods. } class Observer { public function update($argument) { // Do something. } public function reportError($errorCode, $errorMessage, Subject $subject) { // Do something } // Other methods. } ?>

例9.11演示了如何使用模拟对象来测试对象SubjectObserver对象之间的交互。

我们首先使用getMockBuilder()类提供的方法为该PHPUnit\Framework\TestCase对象创建一个模拟对象Observer。因为我们给了一个数组作为方法的第二个(可选)参数,所以只有update(getMock())类的Observer方法被模拟实现所取代。

因为我们有兴趣验证一个方法是否被调用,以及调用哪个参数,我们引入了expects()with()方法来指定这个交互应该如何看待。

例9.11:测试一个方法被调用一次并且带有指定的参数

<?php use PHPUnit\Framework\TestCase; class SubjectTest extends TestCase { public function testObserversAreUpdated() { // Create a mock for the Observer class, // only mock the update() method. $observer = $this->getMockBuilder(Observer::class) ->setMethods(['update']) ->getMock( // Set up the expectation for the update() method // to be called only once and with the string 'something' // as its parameter. $observer->expects($this->once()) ->method('update') ->with($this->equalTo('something') // Create a Subject object and attach the mocked // Observer object to it. $subject = new Subject('My subject' $subject->attach($observer // Call the doSomething() method on the $subject object // which we expect to call the mocked Observer object's // update() method with the string 'something'. $subject->doSomething( } } ?>

with()方法可以采用任意数量的参数,与被模拟的方法的参数数量相对应。您可以在方法的参数上指定比简单匹配更高级的约束。

例9.12:测试一个方法是以不同的方式约束一些参数调用

<?php use PHPUnit\Framework\TestCase; class SubjectTest extends TestCase { public function testErrorReported() { // Create a mock for the Observer class, mocking the // reportError() method $observer = $this->getMockBuilder(Observer::class) ->setMethods(['reportError']) ->getMock( $observer->expects($this->once()) ->method('reportError') ->with( $this->greaterThan(0), $this->stringContains('Something'), $this->anything() $subject = new Subject('My subject' $subject->attach($observer // The doSomethingBad() method should report an error to the observer // via the reportError() method $subject->doSomethingBad( } } ?>

withConsecutive()方法可以使用任意数量的参数数组,具体取决于您想要测试的调用。每个数组都是与被模拟的方法的参数相对应的约束列表,如with()

例9.13:测试一个方法被特定的参数调用两次。

<?php use PHPUnit\Framework\TestCase; class FooTest extends TestCase { public function testFunctionCalledTwoTimesWithSpecificArguments() { $mock = $this->getMockBuilder(stdClass::class) ->setMethods(['set']) ->getMock( $mock->expects($this->exactly(2)) ->method('set') ->withConsecutive( [$this->equalTo('foo'), $this->greaterThan(0)], [$this->equalTo('bar'), $this->greaterThan(0)] $mock->set('foo', 21 $mock->set('bar', 48 } } ?>

callback()约束可用于更复杂的参数验证。该约束以 PHP 回调为唯一参数。PHP 回调将接收参数作为唯一参数进行验证,并且true如果参数通过验证false则返回。

例9.14:更复杂的参数验证

<?php use PHPUnit\Framework\TestCase; class SubjectTest extends TestCase { public function testErrorReported() { // Create a mock for the Observer class, mocking the // reportError() method $observer = $this->getMockBuilder(Observer::class) ->setMethods(['reportError']) ->getMock( $observer->expects($this->once()) ->method('reportError') ->with($this->greaterThan(0), $this->stringContains('Something'), $this->callback(function($subject){ return is_callable([$subject, 'getName']) && $subject->getName() == 'My subject'; }) $subject = new Subject('My subject' $subject->attach($observer // The doSomethingBad() method should report an error to the observer // via the reportError() method $subject->doSomethingBad( } } ?>

例9.15:测试一个方法被调用一次,并且与传入的对象相同

<?php use PHPUnit\Framework\TestCase; class FooTest extends TestCase { public function testIdenticalObjectPassed() { $expectedObject = new stdClass; $mock = $this->getMockBuilder(stdClass::class) ->setMethods(['foo']) ->getMock( $mock->expects($this->once()) ->method('foo') ->with($this->identicalTo($expectedObject) $mock->foo($expectedObject } } ?>

例9.16:创建一个启用了克隆参数的模拟对象

<?php use PHPUnit\Framework\TestCase; class FooTest extends TestCase { public function testIdenticalObjectPassed() { $cloneArguments = true; $mock = $this->getMockBuilder(stdClass::class) ->enableArgumentCloning() ->getMock( // now your mock clones parameters so the identicalTo constraint // will fail. } } ?>

表A.1显示了可应用于方法参数的约束条件,表9.1显示了可用于指定调用次数的匹配器。

表9.1 匹配器

MatcherMeaning
PHPUnit_Framework_MockObject_Matcher_AnyInvokedCount any()Returns a matcher that matches when the method it is evaluated for is executed zero or more times.
PHPUnit_Framework_MockObject_Matcher_InvokedCount never()Returns a matcher that matches when the method it is evaluated for is never executed.
PHPUnit_Framework_MockObject_Matcher_InvokedAtLeastOnce atLeastOnce()Returns a matcher that matches when the method it is evaluated for is executed at least once.
PHPUnit_Framework_MockObject_Matcher_InvokedCount once()Returns a matcher that matches when the method it is evaluated for is executed exactly once.
PHPUnit_Framework_MockObject_Matcher_InvokedCount exactly(int $count)Returns a matcher that matches when the method it is evaluated for is executed exactly $count times.
PHPUnit_Framework_MockObject_Matcher_InvokedAtIndex at(int $index)Returns a matcher that matches when the method it is evaluated for is invoked at the given $index.

匹配器的$index参数引用索引at()从零开始,在给定模拟对象的所有方法调用中。在使用这个匹配器时要小心谨慎,因为它会导致与特定实现细节密切相关的脆弱测试。

正如开头提到的,当createMock()方法用于生成测试双精度的缺省值与您的需求不匹配时,您可以使用该getMockBuilder($type)方法使用流畅接口来定制测试双精度生成。以下是 Mock Builder 提供的方法列表:

  • setMethods(array $methods)可以在模拟构建器对象上调用,以指定要用可配置测试对象替换的方法。其他方法的行为不会改变。如果您调用setMethods(null),则不会更换任何方法。

  • setConstructorArgs(array $args) 可以被调用来提供一个参数数组,该数组被传递给原始类的构造函数(默认情况下不会被替换为虚拟实现)。

  • setMockClassName($name) 可以用来为生成的测试 Double 指定一个类名。

  • disableOriginalConstructor() 可以用来禁用对原始类的构造函数的调用。

  • disableOriginalClone() 可用于禁用对原始类的克隆构造函数的调用。

  • __autoload()在生成测试 Double 期间禁用disableAutoload()

​Prophecy

  • Prophecy是一个“高度主观,但非常强大和灵活的 PHP 对象模拟框架。尽管它最初是为了满足 phpspec2 需求而创建的,但它足够灵活,可以在任何测试框架内以最小的努力使用。

PHPUnit 内置了使用 Prophecy 创建测试 Double 的支持。例9.17展示了如何使用 Prophecy 和启示的逻辑来表达例9.11所示的相同测试:

例9.17:测试一个方法被调用一次并带有指定的参数

<?php use PHPUnit\Framework\TestCase; class SubjectTest extends TestCase { public function testObserversAreUpdated() { $subject = new Subject('My subject' // Create a prophecy for the Observer class. $observer = $this->prophesize(Observer::class // Set up the expectation for the update() method // to be called only once and with the string 'something' // as its parameter. $observer->update('something')->shouldBeCalled( // Reveal the prophecy and attach the mock object // to the Subject. $subject->attach($observer->reveal() // Call the doSomething() method on the $subject object // which we expect to call the mocked Observer object's // update() method with the string 'something'. $subject->doSomething( } } ?>

有关如何使用此替代测试双框架创建,配置和使用 stubs

Mocking 特质和 Abstract 类

getMockForTrait()方法返回一个使用指定特征的模拟对象。给定特质的所有抽象方法都被嘲弄。这允许测试特征的具体方法。

例9.18:测试 trait 的具体方法

<?php use PHPUnit\Framework\TestCase; trait AbstractTrait { public function concreteMethod() { return $this->abstractMethod( } public abstract function abstractMethod( } class TraitClassTest extends TestCase { public function testConcreteMethod() { $mock = $this->getMockForTrait(AbstractTrait::class $mock->expects($this->any()) ->method('abstractMethod') ->will($this->returnValue(true) $this->assertTrue($mock->concreteMethod() } } ?>

getMockForAbstractClass()方法为抽象类返回一个模拟对象。给定抽象类的所有抽象方法都被模拟。这允许测试抽象类的具体方法。

例9.19:测试抽象类的具体方法

<?php use PHPUnit\Framework\TestCase; abstract class AbstractClass { public function concreteMethod() { return $this->abstractMethod( } public abstract function abstractMethod( } class AbstractClassTest extends TestCase { public function testConcreteMethod() { $stub = $this->getMockForAbstractClass(AbstractClass::class $stub->expects($this->any()) ->method('abstractMethod') ->will($this->returnValue(true) $this->assertTrue($stub->concreteMethod() } } ?>

Stubbing 和 Mocking Web 服务

当您的应用程序与 Web 服务进行交互时,您需要对其进行测试,而无需实际与 Web 服务进行交互。为了简化 Web 服务的 stub 和 mock,getMockFromWsdl()可以像使用getMock()(参见上文)那样使用。唯一的区别是getMockFromWsdl()基于 WSDL 中的 Web 服务描述getMock()返回存根或模拟,并返回基于 PHP 类或接口的存根或模拟。

例9.20显示了如何使用GgetMockFromWsdl()存根,例如,oogleSearch.wsdl中描述的Web服务。

Example 9.20: Stubbing a web service

<?php use PHPUnit\Framework\TestCase; class GoogleTest extends TestCase { public function testSearch() { $googleSearch = $this->getMockFromWsdl( 'GoogleSearch.wsdl', 'GoogleSearch' $directoryCategory = new stdClass; $directoryCategory->fullViewableName = ''; $directoryCategory->specialEncoding = ''; $element = new stdClass; $element->summary = ''; $element->URL = 'https://phpunit.de/'; $element->snippet = '...'; $element->title = '<b>PHPUnit</b>'; $element->cachedSize = '11k'; $element->relatedInformationPresent = true; $element->hostName = 'phpunit.de'; $element->directoryCategory = $directoryCategory; $element->directoryTitle = ''; $result = new stdClass; $result->documentFiltering = false; $result->searchComments = ''; $result->estimatedTotalResultsCount = 3.9000; $result->estimateIsExact = false; $result->resultElements = [$element]; $result->searchQuery = 'PHPUnit'; $result->startIndex = 1; $result->endIndex = 1; $result->searchTips = ''; $result->directoryCategories = []; $result->searchTime = 0.248822; $googleSearch->expects($this->any()) ->method('doGoogleSearch') ->will($this->returnValue($result) /** * $googleSearch->doGoogleSearch() will now return a stubbed result and * the web service's doGoogleSearch() method will not be invoked. */ $this->assertEquals( $result, $googleSearch->doGoogleSearch( '00000000000000000000000000000000', 'PHPUnit', 0, 1, false, '', false, '', '', '' ) } } ?>

Mocking 文件系统

vfsStream是一个虚拟文件系统流包装器,它可能有助于单元测试 mock 真正的文件系统。

如果使用 Composer 来管理mikey179/vfsStream项目的依赖关系,只需将依赖项添加到项目composer.json文件中即可。下面只定义了 PHPUnit 4.6 和 vfsStream 的开发时间依赖关系的文件的最小示例:composer.json

{ "require-dev": { "phpunit/phpunit": "~4.6", "mikey179/vfsStream": "~1" } }

例9.21显示了一个与文件系统交互的类。

例9.21:与文件系统交互的类

<?php use PHPUnit\Framework\TestCase; 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.22)。

例9.22:测试与文件系统交互的类

<?php use PHPUnit\Framework\TestCase; class ExampleTest extends 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.23显示了如何使用 vfsStream 在与文件系统交互的类的测试中模拟文件系统。

例9.23:在一个与文件系统交互的类的测试中模拟文件系统

<?php use PHPUnit\Framework\TestCase; class ExampleTest extends 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()方法中的清理操作。