sirbrillig/spies

PHP 测试中的简化间谍、存根和模拟

v1.10.2 2022-02-10 23:11 UTC

README

这是一个使 PHP 测试变得更加简单的库。您可以通过以下说明将其安装到 PHP 项目中。

它是什么?如果您曾经使用过 JavaScript 测试中的 sinon,您就知道 测试间谍 的概念,并且这个库在许多方面只是将这些概念应用于 PHP。它还包括 期望,以简化间谍断言,灵感来自 sinon-chai

如果您想直接查看详细信息,可以 在这里阅读 API

如果您不熟悉测试间谍,这里有一个简要的介绍:基本上,它们是表现像函数一样并记录它们如何被调用的对象。您可以在编写测试时将它们注入到对象中,并监视间谍以确定对象是否以您期望的方式表现。

$spy = new \Spies\Spy();
$spy( 'hello', 'world' );

$spy->was_called(); // Returns true
$spy->was_called_times( 1 ); // Returns true
$spy->was_called_times( 2 ); // Returns false
$spy->get_times_called(); // Returns 1
$spy->was_called_with( 'hello', 'world' ); // Returns true
$spy->was_called_with( 'goodbye', 'world' ); // Returns false

间谍也可以编程以以某种方式表现(在这种情况下,它们更正确地称为“存根”或“模拟”),强制您的代码沿着特定的路径执行,以测试特定的行为。

\Spies\stub_function( 'add_one' )->when_called->with( 5 )->will_return( 6 );
\Spies\stub_function( 'add_one' )->when_called->with( 1 )->will_return( 2 );

add_one( 5 ); // Returns 6
add_one( 1 ); // Returns 2

在 PHP 中,我们经常需要监视具有实例方法的整个对象,因此间谍提供了一种机制来实现这一点。

class Greeter {
    public function say_hello() {
        return 'hello';
    }

    public function say_goodbye() {
        return 'goodbye';
    }
}

function test_greeter() {
    $mock = \Spies\mock_object_of( 'Greeter' );
    $mock->add_method( 'say_hello' )->that_returns( 'greetings' );
    $greet = $mock->spy_on_method( 'greet' );
    $this->assertEquals( 'greetings', $mock->say_hello() );
    $this->assertEquals( null, $mock->say_goodbye() );
    $mock->greet();
    $this->assertTrue( $greet->was_called() );
}

最后一部分,期望为测试断言添加了一层语法,这使得测试断言更容易阅读,并提供更好的失败信息。

function test_spy_is_called_correctly() {
    $spy = \Spies\make_spy();
    $spy( 'hello', 'world', 7 );
    $spy( 'hello', 'world', 8 );
    $expectation = \Spies\expect_spy( $spy )->to_have_been_called->with( 'hello', 'world', \Spies\any() )->twice();
    $expectation->verify();
}

间谍被设计为优秀的 WP_MockMockery 的可选替代品,这两个都是非常强大的库,但有很多方面和怪癖,我认为它们不太直观。

欢迎提出建议、错误报告和功能请求!

安装

这是一个使 PHP 测试变得更加简单的库。您可以通过运行以下命令将其安装到 PHP 项目中:

composer require --dev sirbrillig/spies.

然后确保您在代码的某个位置包含了自动加载器

如果使用 PHPUnit,您可以将自动加载添加到您的 phpunit.xml 文件中

<phpunit bootstrap="vendor/autoload.php">
...

否则您可以手动包含自动加载器

require( './vendor/autoload.php' );

但是,请参阅 关于模拟和监视现有函数的说明。如果您需要这样做,您需要添加一个显式的引导文件。

详细信息

全局函数

如果您想为一个全局函数(如 WordPress 的 wp_insert_post)创建间谍,您可以将全局函数的名称传递给 \Spies\get_spy_for()

function test_calculation() {
	$add_one = \Spies\get_spy_for( 'add_together' );

	add_together( 2, 3 );

	$expectation = \Spies\expect_spy( $add_one )->to_have_been_called->with( 2, 3 ); // Passes
	$expectation->verify();
}

您也可以以相同的方式在同一个命名空间中定义的函数上监视

function test_calculation() {
	$add_one = \Spies\get_spy_for( '\Calculator\add_together' );

	\Calculator\add_together( 2, 3 );

	$expectation = \Spies\expect_spy( $add_one )->to_have_been_called->with( 2, 3 ); // Passes
	$expectation->verify();
}

存根和模拟

您可以使用 \Spies\stub_function() 方法创建存根。存根是一个可以像真实函数一样调用的虚构函数,除了您可以控制其行为。

存根也可以像间谍一样模拟全局函数或命名空间函数。实际上,存根也是一个间谍,这意味着您可以查询您想要的任何信息。

您可以在存根中编程一些基本行为

  1. 您可以使用一个来替换全局函数(它将返回 null)。
  2. 您可以使用一个在调用时返回特定值。
  3. 您可以使用一个函数在传递特定参数时返回一个特定值。
  4. 您可以使用一个函数返回它所接收到的其中一个参数。
  5. 您可以使用一个函数调用替代函数。

这里只是设置一个返回值

\Spies\stub_function( 'get_color' )->and_return( 'green' );

get_color(); // Returns 'green'

这里使用某些参数返回一个值

\Spies\stub_function( 'add_one' )->when_called->with( 5 )->will_return( 6 );
\Spies\stub_function( 'add_one' )->when_called->with( 1 )->will_return( 2 );

add_one( 5 ); // Returns 6
add_one( 1 ); // Returns 2

这里返回一个参数

\Spies\stub_function( 'get_first' )->when_called->will_return( \Spies\passed_arg( 0 ) );

get_first( 5, 6, 7 ); // Returns 5
get_first( 1, 2, 3 ); // Returns 1

这里返回替代函数的结果

\Spies\stub_function( 'add_one' )->and_return( function( $a ) {
	return $a + 1;
} );

add_one( 5 ); // Returns 6
add_one( 1 ); // Returns 2

对象

有时您需要创建一个具有函数占位符的整个对象。在这种情况下,您可以使用 \Spies\mock_object(),这将返回一个可以被传递的对象。默认情况下,该对象没有方法,但您可以使用 add_method() 来添加一些。

add_method(),或其别名 spy_on_method(),在没有第二个参数的情况下被调用时,返回一个占位符(记住,占位符也是一个Spy),因此您可以编程其行为或查询其期望。您也可以使用第二个参数显式地传递一个函数(或Spy),在这种情况下,您传递的任何内容都将被返回。

function test_calculation() {
	$adder = \Spies\mock_object();
	$adder->add_method( 'add_one' )->when_called->with( 6 )->will_return( 7 );
	$add_one = $adder->spy_on_method( 'add_one' );

	$calculator = new Calculator( $adder );
	$calculator->add_one( 4 ); // Returns null
	$calculator->add_one( 6 ); // Returns 7

	\Spies\expect_spy( $add_one )->to_have_been_called(); // Passes
	\Spies\expect_spy( $add_one )->to_have_been_called->with( 2 ); // Fails
	\Spies\finish_spying(); // Verifies all Expectations
}

对于您尝试模拟的现有类的每个公共方法,调用 add_method() 可能会很麻烦。因此,您可以使用 \Spies\mock_object_of( $class_name ),这将创建一个 MockObject 并自动为原始类上的每个公共方法添加一个Spy。这些Spy默认返回null,但您可以使用与上面相同的 add_method() 替换其中的任何一个。

class Greeter {
	public function say_hello() {
		return 'hello';
	}

	public function say_goodbye() {
		return 'goodbye';
	}
}

function test_greeter() {
	$mock = \Spies\mock_object_of( 'Greeter' );
	$mock->add_method( 'say_hello' )->that_returns( 'greetings' );
	$this->assertEquals( 'greetings', $mock->say_hello() );
	$this->assertEquals( null, $mock->say_goodbye() );
}

如果您不想调用 add_method() 且没有原始类来复制,您也可以使用 and_ignore_missing() 忽略对象上的所有方法调用。

function test_greeter() {
	$mock = \Spies\mock_object()->and_ignore_missing();
	$this->assertEquals( null, $mock->say_goodbye() );
}

对象方法代理

有时能够监视对象的实际方法,或者替换对象上的某些方法,但不是所有方法,是有帮助的。这涉及到创建一个代理对象,可以通过将类实例传递给 \Spies\mock_object() 来完成。

结果 MockObject 将将所有方法调用转发到原始类实例,除了使用 add_method() 覆盖的方法。您可以使用 spy_on_method() 监视对象的任何方法调用,就像您会使用常规MockObject一样。

class Greeter {
	public function say_hello() {
		return 'hello';
	}

	public function say_goodbye() {
		return 'goodbye';
	}
}

function test_greeter() {
	$mock = \Spies\mock_object( new Greeter() );
	$say_goodbye = $mock->spy_on_method( 'say_goodbye' );
	$mock->add_method( 'say_hello' )->that_returns( 'greetings' );
	$this->assertEquals( 'greetings', $mock->say_hello() );
	$this->assertEquals( 'goodbye', $mock->say_goodbye() );
	$this->assertSpyWasCalled( $say_goodbye );
}

期望

Spy本身很有用,但Spy还提供了 Expectation 类,以便更容易编写测试期望。

假设我们有一个Spy,并想在PHPUnit测试中验证它是否被调用

function test_spy_is_called() {
	$spy = \Spies\make_spy();
	$spy();
	$this->assertTrue( $spy->was_called() );
}

这可行,但这里还有一种写法

function test_spy_is_called() {
	$spy = \Spies\make_spy();
	$spy();
	$expectation = \Spies\expect_spy( $spy )->to_have_been_called();
	$expectation->verify();
}

它们都是完全有效的。期望只是为您的测试添加了一些语法糖,并通过改进错误消息来加速调试过程。特别是,它们允许构建一组期望的行为,然后一次性验证所有这些行为。让我们用一个更复杂的例子来说明。这是只有一个Spy的情况

function test_spy_is_called_correctly() {
	$spy = \Spies\make_spy();
	$spy( 'hello', 'world', 7 );
	$spy( 'hello', 'world', 8 );
	$this->assertTrue( $spy->was_called_with( 'hello', 'world', \Spies\any() ) );
	$this->assertTrue( $spy->was_called_times( 2 ) );
}

这是使用期望的情况

function test_spy_is_called_correctly() {
	$spy = \Spies\make_spy();
	$spy( 'hello', 'world', 7 );
	$spy( 'hello', 'world', 8 );
	$expectation = \Spies\expect_spy( $spy )->to_have_been_called->with( 'hello', 'world', \Spies\any() )->twice();
	$expectation->verify();
}

最后一部分,$expectation->verify() 实际上测试所有期望的行为。您还可以调用函数 \Spies\finish_spying() 做同样的事情,并且可以将其放在 tearDown 方法中。

更好的失败信息

期望最有用的地方可能是它们提供了更好的失败信息。与 $this->assertTrue( $spy->was_called_with( 'hello' ) )\Spies\expect_spy( $spy )->to_have_been_called->with( 'hello' ) 都断言相同的事情,但前者只会告诉你“false不是true”,而期望将失败并带有类似以下的消息

Expected "anonymous function" to be called with ['hello'] but instead it was called with ['goodbye']

finish_spying

为了在测试期间完成期望,并保持全局作用域中的函数不会相互干扰,在每次测试后调用 \Spies\finish_spying()非常重要的

finish_spying() 做了三件事

  1. 对每个期望调用 verify()expect_spy() 只准备期望。它只有在调用 verify() 时才会被测试。
  2. 清除所有当前的 Spy 和模拟函数。
  3. 清除所有当前的期望。

因为期望只有在调用 verify()finish_spying() 时才会被评估,所以你可以在测试代码之前或之后使用期望。以下两种方式是相同的

function tearDown() {
	\Spies\finish_spying();
}

function test_calculation() {
	$add_one = \Spies\get_spy_for( 'add_together' );

	add_together( 2, 3 );

	\Spies\expect_spy( $add_one )->to_have_been_called->with( 2, 3 ); // Passes
}
function tearDown() {
	\Spies\finish_spying();
}

function test_calculation() {
	$add_one = \Spies\get_spy_for( 'add_together' );

	\Spies\expect_spy( $add_one )->to_be_called->with( 2, 3 ); // Passes

	add_together( 2, 3 );
}

参数列表

如果你使用 with() 测试期望,有时你不在乎参数的值。在这种情况下,你可以用 \Spies\Expectation::any() 代替该参数

function tearDown() {
	\Spies\finish_spying();
}

function test_calculation() {
	$add_one = \Spies\get_spy_for( 'add_together' );

	\Spies\expect_spy( $add_one )->to_be_called->with( \Spies\Expectation::any(), \Spies\Expectation::any() ); // Passes

	add_together( 2, 3 );
}

如果你需要匹配字符串的一部分,可以使用 \Spies\match_pattern()

$spy = \Spies\get_spy_for( 'run_experiment' );
run_experiment( 'slartibartfast' );
\Spies\expect_spy( $spy )->to_have_been_called->with( \Spies\match_pattern( '/bart/' ) );
\Spies\finish_spying();

你还可以使用 \Spies\match_array() 来匹配数组中的元素,同时忽略其他部分

function tearDown() {
	\Spies\finish_spying();
}

function test_name() {
	$say_hello = \Spies\get_spy_for( 'say_hello' );

	\Spies\expect_spy( $say_hello )->to_be_called->with( \Spies\match_array( [ 'name' => 'Raistlin' ] ) ) ); // Passes

	say_hello( [ 'name' => 'Raistlin', 'job' => 'wizard', 'robes' => 'black' ] );
}

PHPUnit 自定义断言

如果你更喜欢使用 PHPUnit 自定义断言而不是期望,这些断言也是可用的(虽然你必须基于 \Spies\TestCase 创建测试类)

class MyTest extends \Spies\TestCase {
    function test_spy_is_called_correctly() {
        $spy = \Spies\make_spy();
        $spy( 'hello', 'world', 7 );
        $spy( 'hello', 'world', 8 );
        $this->assertSpyWasCalledWith( $spy, [ 'hello', 'world', \Spies\any() ] );
    }
}

自定义断言将提供有关测试失败原因的详细信息,这比“false 不是 true”要好得多。

Failed asserting that a spy is called with arguments: ( "a", "b", "c" ).
a spy was actually called with:
 1. arguments: ( "b", "b", "c" ),
 2. arguments: ( "m", "b", "c" )

有关可用的自定义断言的完整列表,请参阅API 文档

断言助手

对于任何断言,即使不涉及 Spy 或 Stub,比较与 match_array() 相同的数组部分也是有帮助的。你可以使用辅助函数 do_arrays_match() 来完成此操作

$array = [ 'baz' => 'boo', 'foo' => 'bar' ];
$this->assertTrue( \Spies\do_arrays_match( $array, \Spies\match_array( [ 'foo' => 'bar' ] ) ) );

监视和模拟现有函数

PHP 不允许模拟现有函数。然而,有一个名为 Patchwork 的库允许这样做。如果加载了该库,它将由 Spies 使用。该库必须在 Spies 之前加载。一种方法是使用测试引导文件。

如果你在 PHPUnit 中使用,你可以从 phpunit.xml 文件中包含引导文件

<phpunit bootstrap="tests/bootstrap.php">
...

以下是一个示例引导文件,它还加载了自动加载器

<?php
$autoload  = 'vendor/autoload.php';
$patchwork = 'vendor/antecedent/patchwork/Patchwork.php';

# require patchwork first
if ( file_exists( $patchwork ) ) {
	require_once $patchwork;
}

if ( file_exists( $autoload ) ) {
	require_once $autoload;
}

如果加载了 Patchwork,你将能够对现有函数使用 mock_function()get_spy_for()

function sayHello() {
  return 'hello';
}
//...
\Spies\mock_function( 'sayHello' )->and_return( 'bye' );
$this->assertEquals( 'bye', sayHello() );
function sayHello() {
  return 'hello';
}
//...
$spy = \Spies\get_spy_for( 'sayHello' );
sayHello();
$this->assertTrue( $spy->was_called() );

贡献

请提交问题或 PR!

CircleCI