lucatume/function-mocker

使用 Patchwork 进行函数模拟

1.3.8 2018-04-18 15:25 UTC

README

A Patchwork 驱动的函数模拟器。

Build Status

显示代码

这可以写入一个 PHPUnit 测试套件中

<?php

// FunctionMocker needs the functions to be defined to replace them
function get_option($option)
{
    // no-op
}

function update_option($option, $value)
{
    // no-op
}

// The class under test
class Logger
{
    public function log($type, $message)
    {
        $option = get_option('log');
        $option[] = sprintf('[%s] %s - %s', date(DATE_ATOM, time()), $type, $message);
        update_option('log', sprintf('[%s] %s - %s', date(DATE_ATOM, time()), $type, $message));
    }
}

class InternaFunctionReplacementTest extends \PHPUnit\Framework\TestCase
{
    /**
     * It should log the correct message
     * @test
     */
    public function log_the_correct_message()
    {
        $mockTime = time();
        \tad\FunctionMocker\FunctionMocker::replace('time', $mockTime);
        \tad\FunctionMocker\FunctionMocker::replace('get_option', []);
        $update_option = \tad\FunctionMocker\FunctionMocker::replace('update_option');

        $logger = new Logger();

        $logger->log('error', 'There was an error');

        $expected = sprintf('[%s] error - There was an error', date(DATE_ATOM, $mockTime));
        $update_option->wasCalledWithOnce(['log', $expected]);
    }
}

安装

可以将压缩文件移动到相应的文件夹,或者使用 Composer

composer require lucatume/function-mocker:~1.0

用法

在理想的世界里,你永远不需要模拟静态方法和函数,应该使用 TDD 编写更好的面向对象代码,并将其用作设计工具。
但有时可能需要模拟这些函数和静态方法,这个库就是为了帮助您。

初始化

为了使 Function Mocker 充分发挥其封装能力(由 patchwork 授予的能力),需要在 CodeceptionPHPUnit 的正确引导文件中调用 FunctionMocker::init 方法,如下所示

<?php
// This is global bootstrap for autoloading
use tad\FunctionMocker\FunctionMocker;

require_once dirname( __FILE__ ) . '/../vendor/autoload.php';

FunctionMocker::init(['blacklist' => dirname(__DIR__)]);

init 方法将接受一个配置数组,支持以下参数

  • includewhitelist - 数组或字符串;要包含在补丁中的绝对路径列表。
  • excludeblacklist - 数组或字符串;要排除在补丁中的绝对路径列表。
  • cache-path - 字符串;Pathchwork 应缓存封装文件的文件夹的绝对路径。
  • redefinable-internals - 数组;可以替换的内部 PHP 函数列表;如果内部函数(由 PHP 定义的函数)未在此列表中,则它永远不会在测试中被替换。
\tad\FunctionMocker\FunctionMocker::init([
    'whitelist' => [dirname(__DIR__) . '/src',dirname(__DIR__) . '/vendor'],
    'blacklist' => [dirname(__DIR__) . '/included', dirname(__FILE__) . 'patchwork-cache', dirname(__DIR__)],
    'cache-path' => dirname(__DIR__) . 'patchwork-cache,
    'redefinable-internals' => ['time', 'filter_var']
]);

排除项目根目录,例如示例中的 dirname(__DIR__),通常是一个好主意。

注意:即使没有为 FunctionMocker::init 方法提供配置,库也将忽略 patchwork.json 文件。

初始化参数

函数模拟器将负责使用一些合理的默认值初始化 Patchwork,但可以自定义这些初始化参数

  • whitelist - 数组或字符串,默认为空;要包含在补丁中的绝对路径列表。
  • blacklist - 数组或字符串,默认为空;要排除在补丁中的绝对路径列表;Patchwork 库本身和 Function Mocker 库始终被排除。
  • cache-path - 字符串,默认为 cache 文件夹;Pathcwork 应缓存封装文件的文件夹的绝对路径。
  • redefinable-internals 数组,默认为空;可以替换的内部 PHP 函数列表;任何需要在测试中替换的 内部 函数(由 PHP 标准库定义)都应在此列出。

setUp 和 tearDown 方法

该库旨在用于PHPUnit测试用例的上下文中,并提供两个必须插入测试用例的setUp和tearDown方法中的static方法,以确保功能模拟器能够正常工作。

class MyTest extends \PHPUnit_Framework_TestCase {
    public function setUp(){
        // before any other set up method
        FunctionMocker::setUp();
        ...
    }

    public function tearDown(){
        ...

        // after any other tear down method
        FunctionMocker::tearDown();
    }
}

函数

替换函数

该库将允许在测试运行时使用FunctionMocker::replace方法替换已定义和未定义的函数,如下所示:

FunctionMocker::replace('myFunction', $returnValue);

并且允许将返回值设置为实际值或函数回调

public function testReplacedFunctionReturnsValue(){
    FunctionMocker::replace('myFunction', 23);

    $this->assertEquals(23, myFunction());
}

public fuction testReplacedFunctionReturnsCallback(){
    FunctionMocker::replace('myFunction', function($arg){
            return $arg + 1;
        });

    $this->assertEquals(24, myFunction());
}

如果您需要替换一个函数并使其返回一系列值,每次调用一个值,则可以使用FunctionMocker::replaceInOrder方法

public function testReplacedFunctionReturnsValue(){
    FunctionMocker::replaceInOrder('myFunction', [23, 89, 2389]);

    $this->assertEquals(23, myFunction());
    $this->assertEquals(89, myFunction());
    $this->assertEquals(2389, myFunction());
}

监视函数

如果FunctionMocker::replace方法返回的值存储在一个变量中,那么可以对该函数进行调用检查

public function testReplacedFunctionReturnsValue(){
    $myFunction = FunctionMocker::replace('myFunction', 23);

    $this->assertEquals(23, myFunction());

    $myFunction->wasCalledOnce();
    $myFunction->wasCalledWithOnce(23);
}

可用于函数监视的方法在下面的“方法”部分中列出。

批量替换函数

当需要为组件工作提供一系列函数的占位符时,可以这样做

public function testBatchFunctionReplacement(){
    $functions = ['functionOne', 'functionTwo', 'functionThree', ...];

    FunctionMocker::replace($functions, function($arg){
        return $arg;
        });

    foreach ($functions as $f){
        $this->assertEquals('foo', $f('foo'));
    }
}

在替换一系列函数时,返回值将是一个可以按函数名称引用的间谍对象数组的array

public function testBatchFunctionReplacement(){
    $functions = ['functionOne', 'functionTwo', 'functionThree', ...];

    $replacedFunctions = FunctionMocker::replace($functions, function($arg){
        return $arg;
        });
    
    functionOne();

    $functionOne = $replacedFunctions['functionOne'];
    $functionOne->wasCalledOnce();

}

静态方法

替换静态方法

与函数类似,该库将允许使用FunctionMocker::replace方法替换已定义的静态方法

public function testReplacedStaticMethodReturnsValue(){
    FunctionMocker::replace('Post::getContent', 'Lorem ipsum');

    $this->assertEquals('Lorem ipsum', Post::getContent());
}

同样,可以设置回调函数的返回值

public function testReplacedStaticMethodReturnsCallback(){
    FunctionMocker::replace('Post::formatTitle', function($string){
        return "foo $string baz";
        });

    $this->assertEquals('foo lorem baz', Post::formatTitle('lorem'));
}

请注意,只能替换public static方法。

监视静态方法

存储FunctionMocker::replace函数的返回值允许使用下面的“方法”部分中列出的方法监视静态方法

public function testReplacedStaticMethodReturnsValue(){
    $getContent = FunctionMocker::replace('Post::getContent', 'Lorem ipsum');

    $this->assertEquals('Lorem ipsum', Post::getContent());

    $getContent->wasCalledOnce();
    $getContent->wasNotCalledWith('some');
    ...
}

批量替换静态方法

静态方法也可以批量替换,将相同的返回值或回调分配给任何替换的方法

public function testBatchReplaceStaticMethods(){
    $methods = ['Foo::one', 'Foo::two', 'Foo::three'];

    FunctionMocker::replace($methods, 'foo');

    $this->assertEquals('foo', Foo::one());
    $this->assertEquals('foo', Foo::two());
    $this->assertEquals('foo', Foo::three());
}

在批量替换静态方法时,FunctionMocker::replace将返回一个按方法名称索引的间谍对象数组,可以用作任何其他静态方法间谍对象;

public function testBatchReplaceStaticMethods(){
    $methods = ['Foo::one', 'Foo::two', 'Foo::three'];

    $replacedMethods = FunctionMocker::replace($methods, 'foo');
    
    Foo::one();

    $one = $replacedMethods['one'];
    $one->wasCalledOnce();
}

实例方法

替换实例方法

当尝试替换实例方法时,FunctionMocker::replace方法将返回一个扩展的PHPUnit模拟对象,实现了所有原始方法和其中一些(见下文)

// file SomeClass.php

class SomeClass{

    protected $dep;

    public function __construct(Dep $dep){
        $this->dep = $dep;
    }

    public function someMethod(){
        return $this->dep->go();
    }
}

// file SomeClassTest.php   

use tad\FunctionMocker\FunctionMocker;

class SomeClassTest extends PHPUnit_Framework_TestCase {

    /**
     * @test
     */
    public function it_will_call_go(){
        $dep = FunctionMocker::replace('Dep::go', 23);

        $sut = new SomeClass($dep);

        $this->assertEquals(23, $sut->someMethod());
    }
}

FunctionMocker::replace方法将使用any方法设置PHPUnit模拟对象,上面的调用相当于

$dep->expects($this->any())->method('go')->willReturn(23);

如果需要在测试中替换多个实例方法,则存在这种实例方法替换的替代方法

use tad\FunctionMocker\FunctionMocker;

class SomeClassTest extends \PHPUnit_Framework_TestCase {
    
    public function dependencyTest(){

        $func = function($one, $two){
                return $one + $two;
            };

        $mock = FunctionMocker::replace('Dependency')
            ->method('methodOne') // replace with null returning methods
            ->method('methodTwo', 23) // replace the method and return a value
            ->method('methodThree', $func)
            ->get();

        $this->assertNull($mock->methodOne());
        $this->assertEquals(23, $mock->methodTwo());
        $this->assertEquals(4, $mock->methodThree(1,3));

    }
}

如果不指定任何要替换的方法,将返回一个只替换了__construct方法的模拟对象。

模拟链式方法

从版本0.2.13开始,可以模拟旨在链式调用的实例方法。给定以下依赖类

class Query {

    ...

    public funtion where($column, $condition, $constraint){
        ...

        return $this;
    }

    public function getResults(){
        return $this->results;
    }

    ...

}

和一个可能的客户端类

class QueryUser{

    ...

   public function getOne($id){
    $this->query
        ->where('ID', '=', $id)
        ->where('type', '=', $this->type)
        ->getFirst();
   }

   ...

}

在测试用例中使用->作为返回值来模拟返回自身的where方法

public function test_will_call_where_with_proper_args(){
    // tell FunctionMocker to return the mock object itself when
    // the `where` method is called
    FunctionMocker::replace('Query::where', '->');
    $query = FunctionMocker::replace('Query::getFirst', $mockResult);
    $sut = new QueryUser();
    $sut->setQuery($query);
    
    // execute
    $sut->getOne(23);

    // verify
    ...
}

模拟抽象类、接口和特性

FunctionMocker依赖于PHPUnit实例模拟引擎,保留了模拟接口、抽象类和特性的能力;用于此的目的的语法与模拟实例方法的语法相同

interface SalutingInterface {
    public function sayHi();
}

上面的接口可以像这样在测试中进行替换

public function test_say_hi(){
    $mock = FunctionMocker::replace('SalutingInterface::sayHi', 'Hello World!');
    
    // passes
    $this->assertEquals('Hello World!', $mock->sayHi());
}

有关更详细的方法,请参阅PHPUnit文档。

监视实例方法

调用实例方法时,FunctionMocker::replace方法返回的对象允许使用“方法”部分中指定的方法来检查对替换方法的调用

// file SomeClass.php

class SomeClass{

    public function methodOne(){
        ...
    }

    public function methodTwo(){
        ...
    }
}

// file SomeClassTest.php   

use tad\FunctionMocker\FunctionMocker;

class SomeClassTest extends PHPUnit_Framework_TestCase {

    /**
     * @test
     */
    public function returns_the_same_replacement_object(){
        // replace both class instance methods to return 23
        $replacement = FunctionMocker::replace('SomeClass::methodOne', 23);
        // $replacement === $replacement2
        $replacement2 = FunctionMocker::replace('SomeClass::methodTwo', 23);

        $replacement->methodOne();
        $replacement->methodTwo();

        $replacement->wasCalledOnce('methodOne');
        $replacement->wasCalledOnce('methodTwo');
    }
}

另一个更流畅的API允许以上断言以更类似于prophecy使用的这种方式进行重写

// file SomeClassTest.php   

use tad\FunctionMocker\FunctionMocker;

class SomeClassTest extends PHPUnit_Framework_TestCase {

    /**
     * @test
     */
    public function returns_the_same_replacement_object(){
        // replace both class instance methods to return 23
        $mock = FunctionMocker::replace('SomeClass)
            ->methodOne()
            ->methodTwo();
        $replacement = $mock->get(); // think of $mock->reveal()

        $replacement->methodOne();
        $replacement->methodTwo();

        $mock->verify()->methodOne()->wasCalledOnce();
        $mock->verify()->methodTwo()->wasCalledOnce();
    }
}

批量替换实例方法

可以使用与批量函数和静态方法替换相同的语法进行批量实例替换。
给定上面的SomeClass

public function testBatchInstanceMethodReplacement(){
    $methods = ['SomeClass::methodOne', 'SomeClass::methodTwo'];
    // replace both class instance methods to return 23
    $replacements = FunctionMocker::replace($methods, 23);

    $replacement[0]->methodOne();
    $replacement[1]->methodTwo();

    $replacement[0]->wasCalledOnce('methodOne');
    $replacement[1]->wasCalledOnce('methodTwo');
}

方法

除了作为PHPUnit模拟对象接口一部分定义的方法(参见此处),这些方法仅在替换实例方法时可用,函数模拟器会扩展替换的函数和方法以下方法

  • wasCalledTimes(int $times [, string $methodName]) - 如果函数或静态方法被调用$times次,则将断言PHPUnit断言;$times参数可以使用以下时间语法使用。
  • wasCalledOnce([string $methodName]) - 如果函数或静态方法被调用一次,则将断言PHPUnit断言。
  • wasNotCalled([string $methodName]) - 如果函数或静态方法没有被调用,则将断言PHPUnit断言。
  • wasCalledWithTimes(array $args, int $times[, string $methodName]) - 如果函数或静态方法被带有$args参数调用$times次,则将断言PHPUnit断言;$times参数可以使用以下时间语法使用;$args参数可以是原始值和PHPUnit约束(如[23, Test::isInstanceOf('SomeClass')])的任意组合。
  • wasCalledWithOnce(array $args[, string $methodName]) - 如果函数或静态方法被带有$args参数调用一次,则将断言PHPUnit断言;$args参数可以是原始值和PHPUnit约束(如[23, Test::isInstanceOf('SomeClass')])的任意组合。
  • wasNotCalledWith(array $args[, string $methodName]) - 如果函数或静态方法没有被带有$args参数调用,则将断言PHPUnit断言;$args参数可以是原始值和PHPUnit约束(如[23, Test::isInstanceOf('SomeClass')])的任意组合。

需要方法名来验证替换的实例方法的调用!

时间

指定函数或方法应该被调用的次数时,可以使用灵活的语法;在最基本的形式中,可以用数字表示

// the function should have have been called exactly 2 times
$function->wasCalledTimes(2);

但使用字符串可以使检查更简单,可以使用PHP中使用的比较器语法

// the function should have been called at least 2 times
$function->wasCalledTimes('>=2');

可用的比较器有 >n<n>=n<=n==n(与插入数字相同)、!n

糖方法

函数模拟器包含一些糖方法,以使我的测试生活更加轻松。任何这些方法的结果都可以使用替代代码实现,但我已实现这些方法以加快速度。

测试方法

函数模拟器包装一个PHPUnit_Framework_TestCase,允许以静态方式调用通常在$this上调用的测试方法。一个测试方法可以写成这样

use tad\FunctionMocker\FunctionMocker as Test;

class SomeTest extends \PHPUnit_Framework_TestCase {

    public function test_true() {
        $this->assertTrue(true);
    }

    public function test_wrapped_true_work_the_same() {
        Test::assertTrue(true);
    }

}

作为一个简单的包装,要使用的测试用例可以通过测试用例中的setUp方法中的setTestCase静态方法设置

public function setUp() {
    FunctionMocker::setTestCase($this);
}

并且任何特定于测试用例的方法都将作为tad\FunctionMocker\FunctionMocker类的静态方法可用。
除了包装测试用例定义的方法之外,任何由PHPUnit_Framework_TestCase类定义的方法都可用于自动完成,以便于阅读IDE,如PhpStormSublime Text

替换全局变量

允许在测试后用模拟对象替换全局变量并恢复它。最好用于替换/设置全局对象实例以模拟;例如

FunctionMocker::replaceGlobal('wpdb', 'wpdb::get_row', $rowData);

// this will access $wpdb->get_row()
$post = get_latest_post();

// verify
$this->assertEquals(...);

等同于编写

// prepare
$mockWpdb = FunctionMocker::replace('wpdb::get_row', $rowData);
$prevWpdb = isset($GLOBALS['wpdb']) ? $GLOBALS['wpdb'] : null;
$GLOBALS['wpdb'] = $mockWpdb;

// this will access $wpdb->get_row()
$post = get_latest_post();

// verify
$this->assertEquals(...);

// restore state
$GLOBALS['wpdb'] = $prevWpdb;

设置全局变量

允许替换/设置全局值并在测试后恢复其状态。

FunctionMocker::setGlobal('switchingToTheme', 'foo');
$do_action = FunctionMocker::replace('do_action');

// exercitate
call_switch_theme_actions();

$do_action->wasCalledWithOnce(['before_switch_to_theme_foo', ])

等同于编写

$prev = isset($GLOBALS['switchingToTheme']) ? $GLOBALS['switchingToTheme'] : null;
$GLOBALS['switchingToTheme'] = 'foo';
$do_action = FunctionMocker::replace('do_action');

// exercitate
call_switch_theme_actions();

// verify 
$do_action->wasCalledWithOnce(['before_switch_to_theme_foo', ])

// restore state
$GLOBALS['switchingToTheme'] = $prev;