圣泉山人 后端开发工程师

PHP单元测试(三) 测试替身

2018-07-03

测试替身

在测试的时候,我们会经常遇到引用了外部依赖的对象,比如:发送邮件、Log记录、文件系统操作等等,这些外部依赖我们很难控制,这时,就可以通过测试替身来模拟这些外部依赖行为,从而让测试进行下去。

PHPUnit 提供了生成测试替身对象的方法:

  • createMock($type)
  • getMockBuilder($type)

前者返回的对象 = 后者使用默认值生成的对象

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

桩件

将对象替换为 返回配置好的返回值的测试替身的实践方法称为上桩(stubbing)。

桩件用法示例:

假设有这样一个邮件功能

class Email
{
    public function sendEmail()
    {
        echo 'send email success';
        return true;
    }
}

用桩件来进行模拟:

  • 返回简单指定值
      $stub = $this->createMock(Email::class);
      //配置桩件:指定返回值
      $stub->method('sendEmail')//指定替身方法,不能指定不存在的方法
          ->willReturn(true);
      $this->assertEquals(true, $stub->sendEmail());
    
  • 通过变量返回更复杂的数据(不用变量之间写也行)
      $stub = $this->createMock(Email::class);
      $res = [
          'flag' => 'success',
          'code' => 200
      ];
      $stub->method('sendEmail')->willReturn($res);
      $this->assertEquals($res, $stub->sendEmail());
    
  • 返回指定索引的参数值
      $stub = $this->createMock(Email::class);
      //指定下标的参数值作为返回值
      $stub->method('sendEmail')->will($this->returnArgument(0));
      $this->assertEquals('345@qq.com', $stub->sendEmail('345@qq.com'));
    
  • 返回桩件的自身引用
      $stub = $this->createMock(Email::class);
      $stub->method('sendEmail')->will(TestCase::returnSelf());
      $this->assertSame($stub, $stub->sendEmail());
    
  • 根据参数和值的映射进行返回
      stub = $this->createMock(Email::class);
    
      //最后一个值为返回值,前面都是参数
      $map = [
          ['zs@qq.com', true],
          ['ls@qq.com', false],
      ];
      $stub->method('sendEmail')
          ->will($this->returnValueMap($map));
    
      $this->assertTrue($stub->sendEmail('zs@qq.com'));
      $this->assertTrue($stub->sendEmail('ls@qq.com'));
    
  • 由回调生成返回值
      $stub = $this->createMock(Email::class);
      $stub->method('sendEmail')
          ->will($this->returnCallback('strtoupper'));
      //返回对参数使用回调函数后的结果
      $this->assertEquals('HELLO', $stub->sendEmail('hello'));
    
  • 按指定顺序迭代返回
      $stub = $this->createMock(Email::class);
      $stub->method('sendEmail')
          ->will($this->onConsecutiveCalls('hi', 'world', '!'));
      $this->assertEquals('hi', $stub->sendEmail());
      $this->assertEquals('world', $stub->sendEmail());
      $this->assertEquals('!', $stub->sendEmail());
    
  • 指定抛出异常
      $stub = $this->createMock(Email::class);
      $stub->method('sendEmail')
          ->will($this->throwException(new \InvalidArgumentException()));
      //将抛出异常
      $stub->sendEmail();
    

仿件

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

可以简单理解为:桩件 + 预期管理

相关 API

仿件生成器 getMockBuilder($type) 提供的方法:

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

通过以下方法实现预期管理:

  • expects()
    • any() 方法执行0次或更多次(即任意次数)时匹配成功
    • never() 方法从未执行时匹配成功
    • atLeastOnce() 方法执行至少一次时匹配成功
    • once() 方法执行恰好一次时匹配成功
    • exactly(int $count) 方法执行恰好 $count 次时匹配成功
    • at(int $index) 方法是第 $index 个执行的方法时匹配成功 $index 参数指的是对给定仿件对象的所有方法的调用的索引,从零开始。使用这个匹配器要谨慎
  • with() 对参数的预期管理
    • equalTo(string $str) 预期参数将等于指定值
    • greaterThan($value) 预期参数值将大于指定数值
    • greaterThanOrEqual($value) 预期参数值将大于等于指定数值
    • stringContains(string $str) 预期参数将包含字指定符串
    • anything() 预期参数可以为任意数据
    • callback() 对参数进行回调验证,回调方法应该返回一个 bool ,其参数为待校验的参数
    • identicalTo() 校验参数是否和预期数据一致,用于对象比较
    • isTrue()
    • isFalse()
    • isNull()
    • isJson()
  • withConsecutive() 可以接受任意多个数组作为参数,每个数组都都是对被仿方法的相应参数的一组约束,其内容同 with() 中的预期

示例:对于基于SPL接口实现的观察者模式,可以如下测试 update 方法有没有被正确执行:

public function testRegister()
{
    //测试:当被观察者通知观察者时,观察者是否有执行 update 方法
    //生成一个仿件
    $mockObserver = $this->getMockBuilder(Observer::class)->setMethods(['update'])->getMock();

    $subjectObj = new Subject();

    //预期管理:update方法只会被执行一次,执行update方法时候的参数是:$subjectObj对象
    $mockObserver->expects($this->once())
        ->method('update')
        ->with($this->identicalTo($subjectObj));

    $subjectObj->attach($mockObserver);
    $subjectObj->notify();
}

示例2:

//生成一个仿件
$mock = $this->getMockBuilder(Observer::class)->setMethods(['doSomething'])->getMock();

//预期管理:doSomething 方法会被执行2次
//第一次:参数1:必须是字符串:“Hi”;参数2:值必须大于等于2;
//第二次:参数1:可以为任意数据;参数2:必须是布尔true
$mock->expects($this->exactly(3))->method('doSomething')->withConsecutive(
    [$this->equalTo('Hi'), $this->greaterThanOrEqual(2)],
    [$this->anything(),$this->isTrue()],
    [$this->stringContains('test'), $this->callback(function (int $arg) {
        return $arg % 2 == 0;
    })]
);

$mock->doSomething('Hi', 3);
$mock->doSomething('Any', true);
$mock->doSomething('phpunit-test', 5);//预期失败,不是偶数,回调会返回false
模仿 Trait 和 抽象类
  • getMockForTrait() 返回一个使用了特定 trait 的仿件对象

      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() 返回一个抽象类的仿件对象

      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());
          }
      }
    
对 Web 服务(Web Services)进行上桩或模仿
对文件系统进行模仿

vfsStream 是对虚拟文件系统 的 stream wrapper,可以用于模仿真实文件系统

可以使用:mikey179/vfsStream 依赖包。

Prophecy

Prophecy 是一个对象模仿框架,PHPUnit 对用 Prophecy 建立测试替身提供了内建支持

示例:

public function testObserversAreUpdated()
{
    $subject = new Subject('My subject');

    // 为 Observer 类建立预言(prophecy)。
    $observer = $this->prophesize(Observer::class);

    // 建立预期状况:update() 方法将会被调用一次,
    // 并且将以字符串 'something' 为参数。
    $observer->update('something')->shouldBeCalled();

    // 揭示预言,并将仿件对象链接到主体上。
    $subject->attach($observer->reveal());

    // 在 $subject 对象上调用 doSomething() 方法,
    // 预期将以字符串 'something' 为参数调用 
    // Observer 仿件对象的 update() 方法。
    $subject->doSomething();
}
public function testDemo()
{
    //建立预言
    $prop = $this->prophesize(Observer::class);
    //语言内容:doSomething 会被调用至少一次,且参数为:hi 2019
    $prop->doSomething('hi', 2019)->shouldBeCalled();

    //执行方法一次
    $prop->reveal()->doSomething('hi', 2019);
    //语言失败:
    $prop->reveal()->doSomething('hi', 2018);
}

Similar Posts

Comments