9. 测试替身

Gerard Meszaros 在 Meszaros2007 中介绍了测试替身的概念:

Gerard Meszaros:有时候对被测系统(SUT)进行测试是很困难的,因为它依赖于其他无法在测试环境中使用的组件。这有可能是因为这些组件不可用,它们不会返回测试所需要的结果,或者执行它们会有不良副作用。在其他情况下,我们的测试策略要求对被测系统的内部行为有更多控制或更多可见性。如果在编写测试时无法使用(或选择不使用)实际的依赖组件(DOC),可以用测试替身来代替。测试替身不需要和真正的依赖组件有完全一样的的行为方式;他只需要提供和真正的组件同样的 API 即可,这样被测系统就会以为它是真正的组件!

PHPUnit 提供的 createMock($type)getMockBuilder($type) 方法可以在测试中用来自动生成对象,此对象可以充当任意指定原版类型(接口或类名)的测试替身。在任何预期或要求使用原版类的实例对象的上下文中都可以使用这个测试替身对象来代替。

createMock($type) 方法直接返回指定类型(接口或类)的测试替身对象实例。此测试替身的创建使用了最佳实践的默认值(不执行原始类的 construct()clone() 方法,且不对传递给测试替身的方法的参数进行克隆)。如果这些默认值非你所需,可以用 getMockBuilder($type) 方法并使用流畅式接口来定制测试替身的生成过程。

在默认情况下,原版类的所有方法都会被替换为只会返回 null 的伪实现(其中不会调用原版方法)。使用诸如 will($this->returnValue()) 之类的方法可以对这些伪实现在被调用时应当返回什么值做出配置。

局限性:final、private、与 static 方法

请注意,finalprivatestatic 方法无法对其进行上桩(stub)或模仿(mock)。PHPUnit 的测试替身功能将会忽略它们,并维持它们的原始行为。

Stubs (桩件)

将对象替换为(可选地)返回配置好的返回值的测试替身的实践方法称为上桩(stubbing)。可以用桩件(stub)来“替换掉被测系统所依赖的实际组件,这样测试就有了对被测系统的间接输入的控制点。这使得测试能强制安排被测系统的执行路径,否则被测系统可能无法执行”。

test-doubles.stubs.examples.StubTest.php展示了如何对方法的调用上桩以及如何设定返回值。首先用</span> <span class="pre">``PHPUnitFrameworkTestCase 类提供的 createMock() 方法来建立一个桩件对象,它表面看起来像是 ``SomeClass`类([Example 9.1](#test-doubles-stubs-examples-someclass-php))的实例。随后用 PHPUnit 提供的 [](#id4)流畅式接口 <http://martinfowler.com/bliki/FluentInterface.html>`_来指定桩件的行为。本质上,这意味着不需要建立多个临时对象然后再把它们捆到一起。取而代之的是范例中所示的链式方法调用。这使得代码更加易读并更加“流畅”。

Example 9.1 需要对其上桩的类

  1. <?php
  2. use PHPUnit\Framework\TestCase;
  3.  
  4. class SomeClass
  5. {
  6. public function doSomething()
  7. {
  8. // 随便做点什么。
  9. }
  10. }
  11. ?>

Example 9.2 对某个方法的调用上桩,返回固定值

  1. <?php
  2. use PHPUnit\Framework\TestCase;
  3.  
  4. class StubTest extends TestCase
  5. {
  6. public function testStub()
  7. {
  8. // 为 SomeClass 类创建桩件。
  9. $stub = $this->createMock(SomeClass::class);
  10.  
  11. // 配置桩件。
  12. $stub->method('doSomething')
  13. ->willReturn('foo');
  14.  
  15. // 现在调用 $stub->doSomething() 将返回 'foo'。
  16. $this->assertEquals('foo', $stub->doSomething());
  17. }
  18. }
  19. ?>

局限性:名字为“method”的方法

仅当原始类中不包含名字为“method”的方法时,以上范例才能正常运行。

如果原始类包含名为“method”的方法,就必须用 $stub->expects($this->any())->method('doSomething')->willReturn('foo');

“在幕后”,当使用了 createMock() 方法时, PHPUnit 自动生成了一个新的 PHP 类来实现想要的行为。

test-doubles.stubs.examples.StubTest2.php这个例子展示了如何用仿件生成器的流畅式接口来配置测试替身的生成。这个测试替身的默认配置用的是和</span> <span class="pre">``createMock() 相同的最佳实践。

Example 9.3 使用可用于配置生成的测试替身类的仿件生成器 API

  1. <?php
  2. use PHPUnit\Framework\TestCase;
  3.  
  4. class StubTest extends TestCase
  5. {
  6. public function testStub()
  7. {
  8. // 为 SomeClass 类建立桩件。
  9. $stub = $this->getMockBuilder($originalClassName)
  10. ->disableOriginalConstructor()
  11. ->disableOriginalClone()
  12. ->disableArgumentCloning()
  13. ->disallowMockingUnknownTypes()
  14. ->getMock();
  15.  
  16. // 配置桩件。
  17. $stub->method('doSomething')
  18. ->willReturn('foo');
  19.  
  20. // 现在调用 $stub->doSomething() 将返回 'foo'。
  21. $this->assertEquals('foo', $stub->doSomething());
  22. }
  23. }
  24. ?>

在之前的例子中,用 willReturn($value) 返回简单值。这个简短的语法相当于 will($this->returnValue($value))。而在这个长点的语法中,可以使用变量,从而实现更复杂的上桩行为。

有时想要将(未改变的)方法调用时所使用的参数之一作为桩件的方法的调用结果来返回。 test-doubles.stubs.examples.StubTest3.php展示了如何用</span> <span class="pre">``returnArgument() 代替 returnValue() 来做到这点。

Example 9.4 对某个方法的调用上桩,返回参数之一

  1. <?php
  2. use PHPUnit\Framework\TestCase;
  3.  
  4. class StubTest extends TestCase
  5. {
  6. public function testReturnArgumentStub()
  7. {
  8. // 为 SomeClass 类创建桩件。
  9. $stub = $this->createMock(SomeClass::class);
  10.  
  11. // 配置桩件。
  12. $stub->method('doSomething')
  13. ->will($this->returnArgument(0));
  14.  
  15. // $stub->doSomething('foo') 返回 'foo'
  16. $this->assertEquals('foo', $stub->doSomething('foo'));
  17.  
  18. // $stub->doSomething('bar') 返回 'bar'
  19. $this->assertEquals('bar', $stub->doSomething('bar'));
  20. }
  21. }
  22. ?>

在用流畅式接口进行测试时,让某个已上桩的方法返回对桩件对象的引用有时会很有用。test-doubles.stubs.examples.StubTest4.php展示了如何用</span> <span class="pre">``returnSelf() 来做到这点。

Example 9.5 对方法的调用上桩,返回对桩件对象的引用

  1. <?php
  2. use PHPUnit\Framework\TestCase;
  3.  
  4. class StubTest extends TestCase
  5. {
  6. public function testReturnSelf()
  7. {
  8. // 为 SomeClass 类创建桩件。
  9. $stub = $this->createMock(SomeClass::class);
  10.  
  11. // 配置桩件。
  12. $stub->method('doSomething')
  13. ->will($this->returnSelf());
  14.  
  15. // $stub->doSomething() 返回 $stub
  16. $this->assertSame($stub, $stub->doSomething());
  17. }
  18. }
  19. ?>

有时候,上桩的方法需要根据预定义的参数清单来返回不同的值。可以用 returnValueMap() 方法将参数和相应的返回值关联起来建立映射。范例参见:numref:test-doubles.stubs.examples.StubTest5.php

Example 9.6 对方法的调用上桩,按照映射确定返回值

  1. <?php
  2. use PHPUnit\Framework\TestCase;
  3.  
  4. class StubTest extends TestCase
  5. {
  6. public function testReturnValueMapStub()
  7. {
  8. // 为 SomeClass 类创建桩件。
  9. $stub = $this->createMock(SomeClass::class);
  10.  
  11. // 创建从参数到返回值的映射。
  12. $map = [
  13. ['a', 'b', 'c', 'd'],
  14. ['e', 'f', 'g', 'h']
  15. ];
  16.  
  17. // 配置桩件。
  18. $stub->method('doSomething')
  19. ->will($this->returnValueMap($map));
  20.  
  21. // $stub->doSomething() 根据提供的参数返回不同的值。
  22. $this->assertEquals('d', $stub->doSomething('a', 'b', 'c'));
  23. $this->assertEquals('h', $stub->doSomething('e', 'f', 'g'));
  24. }
  25. }
  26. ?>

如果上桩的方法需要返回计算得到的值而不是固定值(参见 returnValue())或某个(未改变的)参数(参见 returnArgument()),可以用 returnCallback() 来让上桩的方法返回回调函数或方法的结果。范例参见:numref:test-doubles.stubs.examples.StubTest6.php

Example 9.7 对方法的调用上桩,由回调生成返回值

  1. <?php
  2. use PHPUnit\Framework\TestCase;
  3.  
  4. class StubTest extends TestCase
  5. {
  6. public function testReturnCallbackStub()
  7. {
  8. // 为 SomeClass 类创建桩件。
  9. $stub = $this->createMock(SomeClass::class);
  10.  
  11. // 配置桩件。
  12. $stub->method('doSomething')
  13. ->will($this->returnCallback('str_rot13'));
  14.  
  15. // $stub->doSomething($argument) 返回 str_rot13($argument)
  16. $this->assertEquals('fbzrguvat', $stub->doSomething('something'));
  17. }
  18. }
  19. ?>

相比于建立回调方法,有一个更简单的选择是直接给出期望返回值的列表。可以用 onConsecutiveCalls() 方法来做到这个。范例参见 Example 9.8

Example 9.8 对方法的调用上桩,按照指定顺序返回列表中的值

  1. <?php
  2. use PHPUnit\Framework\TestCase;
  3.  
  4. class StubTest extends TestCase
  5. {
  6. public function testOnConsecutiveCallsStub()
  7. {
  8. // 为 SomeClass 类创建桩件。
  9. $stub = $this->createMock(SomeClass::class);
  10.  
  11. // 配置桩件。
  12. $stub->method('doSomething')
  13. ->will($this->onConsecutiveCalls(2, 3, 5, 7));
  14.  
  15. // $stub->doSomething() 每次返回值都不同
  16. $this->assertEquals(2, $stub->doSomething());
  17. $this->assertEquals(3, $stub->doSomething());
  18. $this->assertEquals(5, $stub->doSomething());
  19. }
  20. }
  21. ?>

除了返回一个值之外,上桩的方法还能抛出一个异常。test-doubles.stubs.examples.StubTest8.php展示了如何用</span> <span class="pre">``throwException() 做到这点。

Example 9.9 对方法的调用上桩,抛出异常

  1. <?php
  2. use PHPUnit\Framework\TestCase;
  3.  
  4. class StubTest extends TestCase
  5. {
  6. public function testThrowExceptionStub()
  7. {
  8. // 为 SomeClass 类创建桩件
  9. $stub = $this->createMock(SomeClass::class);
  10.  
  11. // 配置桩件。
  12. $stub->method('doSomething')
  13. ->will($this->throwException(new Exception));
  14.  
  15. // $stub->doSomething() 抛出异常
  16. $stub->doSomething();
  17. }
  18. }
  19. ?>

另外,也可以自行编写桩件,并在此过程中改善设计。在系统中被广泛使用的资源是通过单个外观(facade)来访问的,因此很容易就能用桩件替换掉资源。例如,将散落在代码各处的对数据库的直接调用替换为单个 Database 对象,这个对象实现了 IDatabase 接口。接下来,就可以创建实现了 IDatabase 的桩件并在测试中使用之。甚至可以创建一个选项来控制是用桩件还是用真实数据库来运行测试,这样测试就既能在开发过程中用作本地测试,又能在实际数据库环境中进行集成测试。

需要上桩的功能往往集中在同一个对象中,这就改善了内聚度。将功能通过单一且一致的接口呈现出来,就降低了这部分与系统其他部分之间的耦合度。

仿件对象(Mock Object)

将对象替换为能验证预期行为(例如断言某个方法必会被调用)的测试替身的实践方法称为模仿(mocking)

可以用 仿件对象(mock object)“作为观察点来核实被测试系统在测试中的间接输出。通常,仿件对象还需要包括桩件的功能,因为如果测试尚未失败则仿件对象需要向被测系统返回一些值,但是其重点还是在对间接输出的核实上。因此,仿件对象远不止是桩件加断言,它是以一种从根本上完全不同的方式来使用的”(Gerard Meszaros)。

局限性:对预期的自动校验

PHPUnit只会对在某个测试的作用域内生成的仿件对象进行自动校验。诸如在数据供给器内生成或用@depends 标注注入测试的仿件对象,PHPUnit并不会自动对其进行校验。

这有个例子:假设需要测试的当前方法,在例子中是 update(),确实在一个观察着另外一个对象的对象中上被调用了。test-doubles.mock-objects.examples.SUT.php展示了被测系统(SUT)中</span> <span class="pre">``SubjectObserver 两个类的代码。

Example 9.10 被测系统(SUT)中 Subject 与 Observer 类的代码

  1. <?php
  2. use PHPUnit\Framework\TestCase;
  3.  
  4. class Subject
  5. {
  6. protected $observers = [];
  7. protected $name;
  8.  
  9. public function __construct($name)
  10. {
  11. $this->name = $name;
  12. }
  13.  
  14. public function getName()
  15. {
  16. return $this->name;
  17. }
  18.  
  19. public function attach(Observer $observer)
  20. {
  21. $this->observers[] = $observer;
  22. }
  23.  
  24. public function doSomething()
  25. {
  26. // 做点什么
  27. // ...
  28.  
  29. // 通知观察者发生了些什么
  30. $this->notify('something');
  31. }
  32.  
  33. public function doSomethingBad()
  34. {
  35. foreach ($this->observers as $observer) {
  36. $observer->reportError(42, 'Something bad happened', $this);
  37. }
  38. }
  39.  
  40. protected function notify($argument)
  41. {
  42. foreach ($this->observers as $observer) {
  43. $observer->update($argument);
  44. }
  45. }
  46.  
  47. // 其他方法。
  48. }
  49.  
  50. class Observer
  51. {
  52. public function update($argument)
  53. {
  54. // 做点什么。
  55. }
  56.  
  57. public function reportError($errorCode, $errorMessage, Subject $subject)
  58. {
  59. // 做点什么。
  60. }
  61.  
  62. // 其他方法。
  63. }
  64. ?>

test-doubles.mock-objects.examples.SubjectTest.php展示了如何用仿件对象来测试</span> <span class="pre">``SubjectObserver 对象之间的互动。

首先用 PHPUnit\Framework\TestCase 类提供的 getMockBuilder() 方法建立 Observer 的仿件对象。由于给出了一个数组做为 getMock() 方法的第二(可选)参数,Observer 类只有 update() 方法会被替换为仿实现。

由于关注的是检验某个方法是否被调用,以及调用时具体所使用的参数,因此引入 expects()with() 方法来指明此交互应该是什么样的。

Example 9.11 测试某个方法会以特定参数被调用一次

  1. <?php
  2. use PHPUnit\Framework\TestCase;
  3.  
  4. class SubjectTest extends TestCase
  5. {
  6. public function testObserversAreUpdated()
  7. {
  8. // 为 Observer 类建立仿件对象,只模仿 update() 方法。
  9. $observer = $this->getMockBuilder(Observer::class)
  10. ->setMethods(['update'])
  11. ->getMock();
  12.  
  13. // 建立预期状况:update() 方法将会被调用一次,
  14. // 并且将以字符串 'something' 为参数。
  15. $observer->expects($this->once())
  16. ->method('update')
  17. ->with($this->equalTo('something'));
  18.  
  19. // 创建 Subject 对象,并将模仿的 Observer 对象连接其上。
  20. $subject = new Subject('My subject');
  21. $subject->attach($observer);
  22.  
  23. // 在 $subject 对象上调用 doSomething() 方法,
  24. // 预期将以字符串 'something' 为参数调用
  25. // Observer 仿件对象的 update() 方法。
  26. $subject->doSomething();
  27. }
  28. }
  29. ?>

with() 方法可以携带任何数量的参数,对应于被模仿的方法的参数数量。可以对方法的参数指定更加高等的约束而不仅是简单的匹配。

Example 9.12 测试某个方法将会以特定数量的参数进行调用,并且对各个参数以多种方式进行约束

  1. <?php
  2. use PHPUnit\Framework\TestCase;
  3.  
  4. class SubjectTest extends TestCase
  5. {
  6. public function testErrorReported()
  7. {
  8. // 为 Observer 类建立仿件,对 reportError() 方法进行模仿
  9. $observer = $this->getMockBuilder(Observer::class)
  10. ->setMethods(['reportError'])
  11. ->getMock();
  12.  
  13. $observer->expects($this->once())
  14. ->method('reportError')
  15. ->with(
  16. $this->greaterThan(0),
  17. $this->stringContains('Something'),
  18. $this->anything()
  19. );
  20.  
  21. $subject = new Subject('My subject');
  22. $subject->attach($observer);
  23.  
  24. // doSomethingBad() 方法应当会通过(observer的)reportError()方法
  25. // 向 observer 报告错误。
  26. $subject->doSomethingBad();
  27. }
  28. }
  29. ?>

withConsecutive() 方法可以接受任意多个数组作为参数,具体数量取决于欲测试的调用。每个数组都都是对被仿方法的相应参数的一组约束,就像 with() 中那样。

Example 9.13 测试某个方法将会以特定参数被调用二次

  1. <?php
  2. use PHPUnit\Framework\TestCase;
  3.  
  4. class FooTest extends TestCase
  5. {
  6. public function testFunctionCalledTwoTimesWithSpecificArguments()
  7. {
  8. $mock = $this->getMockBuilder(stdClass::class)
  9. ->setMethods(['set'])
  10. ->getMock();
  11.  
  12. $mock->expects($this->exactly(2))
  13. ->method('set')
  14. ->withConsecutive(
  15. [$this->equalTo('foo'), $this->greaterThan(0)],
  16. [$this->equalTo('bar'), $this->greaterThan(0)]
  17. );
  18.  
  19. $mock->set('foo', 21);
  20. $mock->set('bar', 48);
  21. }
  22. }
  23. ?>

callback() 约束用来进行更加复杂的参数校验。此约束的唯一参数是一个 PHP 回调项(callback)。此 PHP 回调项接受需要校验的参数作为其唯一参数,并应当在参数通过校验时返回 true,否则返回 false

Example 9.14 更加复杂的参数校验

  1. <?php
  2. use PHPUnit\Framework\TestCase;
  3.  
  4. class SubjectTest extends TestCase
  5. {
  6. public function testErrorReported()
  7. {
  8. // 为 Observer 类建立仿件,模仿 reportError() 方法
  9. $observer = $this->getMockBuilder(Observer::class)
  10. ->setMethods(['reportError'])
  11. ->getMock();
  12.  
  13. $observer->expects($this->once())
  14. ->method('reportError')
  15. ->with($this->greaterThan(0),
  16. $this->stringContains('Something'),
  17. $this->callback(function($subject){
  18. return is_callable([$subject, 'getName']) &&
  19. $subject->getName() == 'My subject';
  20. }));
  21.  
  22. $subject = new Subject('My subject');
  23. $subject->attach($observer);
  24.  
  25. // doSomethingBad() 方法应当会通过(observer的)reportError()方法
  26. // 向 observer 报告错误。
  27. $subject->doSomethingBad();
  28. }
  29. }
  30. ?>

Example 9.15 测试某个方法将会被调用一次,并且以某个特定对象作为参数。

  1. <?php
  2. use PHPUnit\Framework\TestCase;
  3.  
  4. class FooTest extends TestCase
  5. {
  6. public function testIdenticalObjectPassed()
  7. {
  8. $expectedObject = new stdClass;
  9.  
  10. $mock = $this->getMockBuilder(stdClass::class)
  11. ->setMethods(['foo'])
  12. ->getMock();
  13.  
  14. $mock->expects($this->once())
  15. ->method('foo')
  16. ->with($this->identicalTo($expectedObject));
  17.  
  18. $mock->foo($expectedObject);
  19. }
  20. }
  21. ?>

Example 9.16 创建仿件对象时启用参数克隆

  1. <?php
  2. use PHPUnit\Framework\TestCase;
  3.  
  4. class FooTest extends TestCase
  5. {
  6. public function testIdenticalObjectPassed()
  7. {
  8. $cloneArguments = true;
  9.  
  10. $mock = $this->getMockBuilder(stdClass::class)
  11. ->enableArgumentCloning()
  12. ->getMock();
  13.  
  14. // 现在仿件将对参数进行克隆,因此 identicalTo 约束将会失败。
  15. }
  16. }
  17. ?>

:ref:`appendixes.assertions.assertThat.tables.constraints列出了可以应用于方法参数的各种约束,:numref:[](#id8)test-doubles.mock-objects.tables.matchers`列出了可以用于指定调用次数的各种匹配器。

匹配器 含义
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 个执行的方法时匹配成功。

Note

at() 匹配器的 $index 参数指的是对给定仿件对象的所有方法的调用的索引,从零开始。使用这个匹配器要谨慎,因为它可能导致测试由于与具体的实现细节过分紧密绑定而变得脆弱。

如一开始提到的,如果 createMock() 方法在生成测试替身时所使用的默认值不符合你的要求,则可以通过 getMockBuilder($type) 方法来用流畅式接口定制测试替身的生成过程。以下是仿件生成器所提供的方法列表:

  • setMethods(array $methods) 可以在仿件生成器对象上调用,来指定哪些方法将被替换为可配置的测试替身。其他方法的行为不会有所改变。如果调用 setMethods(null),那么没有方法会被替换。
  • setConstructorArgs(array $args) 可用于向原版类的构造函数(默认情况下不会被替换为伪实现)提供参数数组。
  • setMockClassName($name) 可用于指定生成的测试替身类的类名。
  • disableOriginalConstructor() 参数可用于禁用对原版类的构造方法的调用。
  • disableOriginalClone() 可用于禁用对原版类的克隆方法的调用。
  • disableAutoload()可用于在测试替身类的生成期间禁用</span> <span class="pre">__autoload()

Prophecy

Prophecy 是个“极为自我却又非常强大且灵活的 PHP 对象模仿框架。虽然一开始是为了满足 phpspec2 的需要而建立的,但它足够灵活,可以用最小代价用于任何测试框架内。”

PHPUnit 对用 Prophecy 建立测试替身提供了内建支持。:numref:`test-doubles.prophecy.examples.SubjectTest.php展示了:numref:[](#id13)test-doubles.mock-objects.examples.SubjectTest.php`中展示的测试应该如何用 Prophecy 的的预言式理念方式来达到同样的效果:

Example 9.17 测试某个方法会以特定参数被调用一次

  1. <?php
  2. use PHPUnit\Framework\TestCase;
  3.  
  4. class SubjectTest extends TestCase
  5. {
  6. public function testObserversAreUpdated()
  7. {
  8. $subject = new Subject('My subject');
  9.  
  10. // 为 Observer 类建立预言(prophecy)。
  11. $observer = $this->prophesize(Observer::class);
  12.  
  13. // 建立预期状况:update() 方法将会被调用一次,
  14. // 并且将以字符串 'something' 为参数。
  15. $observer->update('something')->shouldBeCalled();
  16.  
  17. // 揭示预言,并将仿件对象链接到主体上。
  18. $subject->attach($observer->reveal());
  19.  
  20. // 在 $subject 对象上调用 doSomething() 方法,
  21. // 预期将以字符串 'something' 为参数调用
  22. // Observer 仿件对象的 update() 方法。
  23. $subject->doSomething();
  24. }
  25. }
  26. ?>

更多关于如何用这个测试替身框架来创建、配置及使用桩件、谍件、仿件的细节,请参考 Prophecy 的 文档

对特质(Trait)与抽象类进行模仿

getMockForTrait() 方法返回一个使用了特定特质(trait)的仿件对象。给定特质的所有抽象方法将都被模仿。这样就能对特质的具体方法进行测试。

Example 9.18 对特质的具体方法进行测试

  1. <?php
  2. use PHPUnit\Framework\TestCase;
  3.  
  4. trait AbstractTrait
  5. {
  6. public function concreteMethod()
  7. {
  8. return $this->abstractMethod();
  9. }
  10.  
  11. public abstract function abstractMethod();
  12. }
  13.  
  14. class TraitClassTest extends TestCase
  15. {
  16. public function testConcreteMethod()
  17. {
  18. $mock = $this->getMockForTrait(AbstractTrait::class);
  19.  
  20. $mock->expects($this->any())
  21. ->method('abstractMethod')
  22. ->will($this->returnValue(true));
  23.  
  24. $this->assertTrue($mock->concreteMethod());
  25. }
  26. }
  27. ?>

getMockForAbstractClass() 方法返回一个抽象类的仿件对象。给定抽象类的所有抽象方法将都被模仿。这样就能对抽象类的具体方法进行测试。

Example 9.19 对抽象类的具体方法进行测试

  1. <?php
  2. use PHPUnit\Framework\TestCase;
  3.  
  4. abstract class AbstractClass
  5. {
  6. public function concreteMethod()
  7. {
  8. return $this->abstractMethod();
  9. }
  10.  
  11. public abstract function abstractMethod();
  12. }
  13.  
  14. class AbstractClassTest extends TestCase
  15. {
  16. public function testConcreteMethod()
  17. {
  18. $stub = $this->getMockForAbstractClass(AbstractClass::class);
  19.  
  20. $stub->expects($this->any())
  21. ->method('abstractMethod')
  22. ->will($this->returnValue(true));
  23.  
  24. $this->assertTrue($stub->concreteMethod());
  25. }
  26. }
  27. ?>

对 Web 服务(Web Services)进行上桩或模仿

当应用程序需要和 web 服务进行交互时,会想要在不与 web 服务进行实际交互的情况下对其进行测试。为了简单地对 web 服务进行上桩或模仿,可以像使用 getMock() (见上文)那样使用 getMockFromWsdl()。唯一的区别是 getMockFromWsdl() 所返回的桩件或者仿件是基于以 WSDL 描述的 web 服务,而 getMock() 返回的桩件或者仿件是基于 PHP 类或接口的。

test-doubles.stubbing-and-mocking-web-services.examples.GoogleTest.php展示了如何用</span> <span class="pre">``getMockFromWsdl() 来对(例如):file:GoogleSearch.wsdl 中描述的 web 服务上桩。

Example 9.20 对 web 服务上桩

  1. <?php
  2. use PHPUnit\Framework\TestCase;
  3.  
  4. class GoogleTest extends TestCase
  5. {
  6. public function testSearch()
  7. {
  8. $googleSearch = $this->getMockFromWsdl(
  9. 'GoogleSearch.wsdl', 'GoogleSearch'
  10. );
  11.  
  12. $directoryCategory = new stdClass;
  13. $directoryCategory->fullViewableName = '';
  14. $directoryCategory->specialEncoding = '';
  15.  
  16. $element = new stdClass;
  17. $element->summary = '';
  18. $element->URL = 'https://phpunit.de/';
  19. $element->snippet = '...';
  20. $element->title = '<b>PHPUnit</b>';
  21. $element->cachedSize = '11k';
  22. $element->relatedInformationPresent = true;
  23. $element->hostName = 'phpunit.de';
  24. $element->directoryCategory = $directoryCategory;
  25. $element->directoryTitle = '';
  26.  
  27. $result = new stdClass;
  28. $result->documentFiltering = false;
  29. $result->searchComments = '';
  30. $result->estimatedTotalResultsCount = 3.9000;
  31. $result->estimateIsExact = false;
  32. $result->resultElements = [$element];
  33. $result->searchQuery = 'PHPUnit';
  34. $result->startIndex = 1;
  35. $result->endIndex = 1;
  36. $result->searchTips = '';
  37. $result->directoryCategories = [];
  38. $result->searchTime = 0.248822;
  39.  
  40. $googleSearch->expects($this->any())
  41. ->method('doGoogleSearch')
  42. ->will($this->returnValue($result));
  43.  
  44. /**
  45. * $googleSearch->doGoogleSearch() 将会返回上桩的结果,
  46. * web 服务的 doGoogleSearch() 方法不会被调用。
  47. */
  48. $this->assertEquals(
  49. $result,
  50. $googleSearch->doGoogleSearch(
  51. '00000000000000000000000000000000',
  52. 'PHPUnit',
  53. 0,
  54. 1,
  55. false,
  56. '',
  57. false,
  58. '',
  59. '',
  60. ''
  61. )
  62. );
  63. }
  64. }
  65. ?>

对文件系统进行模仿

vfsStream 是对虚拟文件系统 &lt;[http://en.wikipedia.org/wiki/Virtual_file_system](http://en.wikipedia.org/wiki/Virtual_file_system)&gt;_ 的 流包覆器(stream wrapper),可以用于模仿真实文件系统,在单元测试中可能会有所助益。

如果使用 Composer 来管理项目的依赖关系,那么只需简单的在项目的 composer.json 文件中加一条对 mikey179/vfsStream 的依赖关系即可。以下是一个最小化的 ``composer.json``文件例子,只定义了一条对 PHPUnit 4.6 与 vfsStream 的开发时(development-time)依赖:

  1. {
  2. "require-dev": {
  3. "phpunit/phpunit": "~4.6",
  4. "mikey179/vfsStream": "~1"
  5. }
  6. }

:numref:`test-doubles.mocking-the-filesystem.examples.Example.php`展示了一个与文件系统交互的类。

Example 9.21 一个与文件系统交互的类

  1. <?php
  2. use PHPUnit\Framework\TestCase;
  3.  
  4. class Example
  5. {
  6. protected $id;
  7. protected $directory;
  8.  
  9. public function __construct($id)
  10. {
  11. $this->id = $id;
  12. }
  13.  
  14. public function setDirectory($directory)
  15. {
  16. $this->directory = $directory . DIRECTORY_SEPARATOR . $this->id;
  17.  
  18. if (!file_exists($this->directory)) {
  19. mkdir($this->directory, 0700, true);
  20. }
  21. }
  22. }?>

如果不使用诸如 vfsStream 这样的虚拟文件系统,就无法在隔离外部影响的情况下对 setDirectory() 方法进行测试(参见 Example 9.22)。

Example 9.22 对一个与文件系统交互的类进行测试

  1. <?php
  2. use PHPUnit\Framework\TestCase;
  3.  
  4. class ExampleTest extends TestCase
  5. {
  6. protected function setUp()
  7. {
  8. if (file_exists(dirname(__FILE__) . '/id')) {
  9. rmdir(dirname(__FILE__) . '/id');
  10. }
  11. }
  12.  
  13. public function testDirectoryIsCreated()
  14. {
  15. $example = new Example('id');
  16. $this->assertFalse(file_exists(dirname(__FILE__) . '/id'));
  17.  
  18. $example->setDirectory(dirname(__FILE__));
  19. $this->assertTrue(file_exists(dirname(__FILE__) . '/id'));
  20. }
  21.  
  22. protected function tearDown()
  23. {
  24. if (file_exists(dirname(__FILE__) . '/id')) {
  25. rmdir(dirname(__FILE__) . '/id');
  26. }
  27. }
  28. }
  29. ?>

上面的方法有几个缺点:

  • 和任何其他外部资源一样,文件系统可能会间歇性的出现一些问题,这使得和它交互的测试变得不可靠。
  • setUp()tearDown() 方法中,必须确保这个目录在测试前和测试后均不存在。
  • 如果测试在 tearDown() 方法被调用之前就终止了,这个目录就会遗留在文件系统中。
    :numref:`test-doubles.mocking-the-filesystem.examples.ExampleTest2.php`展示了如何在对与文件系统交互的类进行的测试中使用 vfsStream 来模仿文件系统。

Example 9.23 在对与文件系统交互的类进行的测试中模仿文件系统

  1. <?php
  2. use PHPUnit\Framework\TestCase;
  3.  
  4. class ExampleTest extends TestCase
  5. {
  6. public function setUp()
  7. {
  8. vfsStreamWrapper::register();
  9. vfsStreamWrapper::setRoot(new vfsStreamDirectory('exampleDir'));
  10. }
  11.  
  12. public function testDirectoryIsCreated()
  13. {
  14. $example = new Example('id');
  15. $this->assertFalse(vfsStreamWrapper::getRoot()->hasChild('id'));
  16.  
  17. $example->setDirectory(vfsStream::url('exampleDir'));
  18. $this->assertTrue(vfsStreamWrapper::getRoot()->hasChild('id'));
  19. }
  20. }
  21. ?>

这有几个优点:

  • 测试本身更加简洁。
  • vfsStream 让开发者能够完全控制被测代码所处的文件系统环境。
  • 由于文件系统操作不再对真实文件系统进行操作,tearDown() 方法中的清理操作不再需要了。

原文: https://phpunit.readthedocs.io/zh_CN/latest/test-doubles.html