Giter Club home page Giter Club logo

spies's Introduction

Spies

A library to make testing in PHP so much easier. You can install it in a PHP project by following the instructions below.

What is it? If you've ever used sinon in JavaScript testing, you know about the concept of Test Spies, and in many ways this library is just implementing those concepts in PHP. It also includes Expectations to simplify spy assertions, inspired by sinon-chai.

If you want to just skip to the details, you can read the API here.

If you are not familiar with Test Spies, here's a brief primer: Basically they are objects that behave like functions and keep a record of how they are called. You can inject them into objects when writing tests and monitor the spies to determine if the objects are behaving as you expect.

$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 can also be programmed to behave in certain ways (in which case they are more properly called "stubs" or "mocks"), forcing your code down certain paths in order to test specific behavior.

\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

In PHP, we often need to spy on whole objects with instance methods, so Spies provides a mechanism to do that as well:

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() );
}

The final piece, Expectations add a layer of syntax to test assertions that should be easier to read as well as providing better failure messages:

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();
}

Spies was designed as an optional replacement for the very excellent WP_Mock and Mockery, both of which are powerful but have many aspects and quirks that I don't find intuitive.

Suggestions, bug reports, and feature requests all welcome!

Installation

A library to make testing in PHP so much easier. You can install it in a PHP project by running:

composer require --dev sirbrillig/spies.

Then just make sure you include the autoloader somewhere in your code:

If using in PHPUnit, you can add autoload to your phpunit.xml file:

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

Otherwise you can include the autoloader manually:

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

However, please see the note on mocking and spying on existing functions. If you need to do this, you will need to add an explicit bootstrap file.

The Details

Global Functions

If you want to create a Spy for a global function (a function in the global namespace, like WordPress's wp_insert_post), you can pass the name of the global function to \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();
}

You can Spy on functions defined within a namespace in the same way:

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();
}

Stubs and Mocks

You can create stubs with the \Spies\stub_function() method. A stub is a fake function that can be called like a real function except that you control its behavior.

Stubs can also be used to mock a global function or a namespaced function, just like a Spy. In fact, a stub is also a Spy, which means you can query it for any information you like.

There are a few basic behaviors you can program into a stub:

  1. You can simply use one to replace a global function (it will return null).
  2. You can use one to return a specific value when called.
  3. You can use one to return a specific value when called with specific arguments.
  4. You can use one to return one of the arguments it was passed.
  5. You can use one to call a substitute function.

Here's just setting a return value:

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

get_color(); // Returns 'green'

Here's returning a value with certain arguments:

\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

Here's one returning one of its arguments:

\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

Here's one returning the result of a substitute function:

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

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

Objects

Sometimes you need to create a whole object with stubs as functions. In that case you can use \Spies\mock_object() which will return an object that can be passed around. The object by default has no methods, but you can use add_method() to add some.

add_method(), or its alias, spy_on_method(), when called without a second argument, returns a stub (which, remember, is also a Spy), so you can program its behavior or query it for expectations. You can also use the second argument to pass a function (or Spy) explicitly, in which case whatever you pass is what will be returned.

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
}

It can be tedious to call add_method() for every public method of an existing class that you are trying to mock. For that reason you can use \Spies\mock_object_of( $class_name ) which will create a MockObject and automatically add a Spy for each public method on the original class. These Spies will all return null by default, but you can replace any of them with your own Spy by using add_method() as above.

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() );
}

If you'd rather not call add_method() and you don't have an original class to copy, you can also just ignore all method calls on the object using and_ignore_missing():

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

Object Method Delegation

Sometimes it's helpful to be able to be able to spy on actual methods of an object, or to replace some methods on an object, but not others. This involves creating a delegate object, which can be done by passing a class instance to \Spies\mock_object().

The resulting MockObject will forward all method calls to the original class instance, except those overridden by using add_method(). It's possible to use spy_on_method() to spy on any method call of the object, just as you would do with a regular 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 );
}

Expectations

Spies can be useful all by themselves, but Spies also provides the Expectation class to make writing your test expectations easier.

Let's say we have a Spy and want to verify if it has been called in a PHPUnit test:

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

That works, but here's another way to write it:

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

They're both totally valid. Expectations just add some more syntactic sugar to your tests and speed the debugging process by improving failure messages. Particularly, they allow building up a set of expected behaviors and then validating all of them at once. Let's use a more complex example. Here it is with just a 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 ) );
}

And here with Expectations:

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();
}

That last part, $expectation->verify() is what actually tests all the expected behaviors. You can also call the function \Spies\finish_spying() which will do the same thing, and can be put in a tearDown method.

Better failures

Perhaps the most useful thing about Expectations is that they provide better failure messages. Whereas $this->assertTrue( $spy->was_called_with( 'hello' ) ) and \Spies\expect_spy( $spy )->to_have_been_called->with( 'hello' ) both assert the same thing, the former will only tell you "false is not true", and the Expectation will fail with something like this message:

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

finish_spying

To complete an expectation during a test, and to keep functions in the global scope from interfering with one another, it's very important to call \Spies\finish_spying() after each test.

finish_spying() does three things:

  1. Calls verify() on each Expectation. expect_spy() only prepares the expectation. It is not tested until verify() is called.
  2. Clears all current Spies and mocked functions.
  3. Clears all current Expectations.

Because Expectations are only evaluated when we call verify() or finish_spying(), you can use expectations before or after the code that is being tested. There's syntactic sugar to make it sound right either way. The following two are the same:

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 );
}

Argument lists

If you use with() to test an Expectation, sometimes you don't care about the value of an argument. In this case you can use \Spies\Expectation::any() in place of that argument:

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 );
}

If you need to match just part of a string, you can use \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();

You can also use \Spies\match_array() to match elements of an array while ignoring other parts:

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 Custom Assertions

If you prefer to use PHPUnit custom assertions rather than Expectations, those are also available (although you must base your test class on \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() ] );
    }
}

Custom assertions will provide detailed information about why your test failed, which is much better than "false is not 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" )

See the API document for the full list of custom assertions available.

Assertion Helpers

For any assertion, even those not involving Spies or Stubs, it can be helpful to compare partial arrays in the same manner as match_array(). You can use the helper function do_arrays_match() to do this:

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

Spying and Mocking existing functions

PHP does not allow mocking existing functions. However, there is a library called Patchwork which allows this. If that library is loaded, it will be used by Spies. The library must be loaded before Spies. One way to do this is to use a test bootstrap file.

If using in PHPUnit, you can require the bootstrap from your phpunit.xml file:

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

Here is an example bootstrap file that also loads the autoloader:

<?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;
}

If Patchwork is loaded, you will be able to use mock_function() and get_spy_for() on existing functions:

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() );

Contributing

Please submit an issue or PR!

CircleCI

spies's People

Contributors

mehamasum avatar sirbrillig avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar

spies's Issues

Potential infinite loop on re-stubbing undefined global function without any return value

If we stub a previously undefined global function, a new global function is generated.
And then if we re-stub it again without any return value, it gets redefined and an entry is created in GlobalSpies::$redefined_functions (because of changes in this pr). Previously, the re-stub wouldn't work and there would be no entry in GlobalSpies::$redefined_functions.

But a new problem arises when the re-stub is invoked: \Spies\GlobalSpies::handle_call_for is called and in turn \Spies\GlobalSpies::call_original_global_function gets called (since stubbed without a return value).
Since there is an entry for the invoked function in GlobalSpies::$redefined_functions, the previous definition is restored (which happens to be the generated function) and call_user_func_array is called on that. This invokes \Spies\GlobalSpies::handle_call_for again and this keeps happening in a cycle.

Previously (before this pr), the re-stub wouldn't work and thus the redefinition would never trigger. Only the first generated function or the original global function (had it existed) would be invoked over and over again. In our case, the first generated function calls \Spies\GlobalSpies::handle_call_for like above, but it returns early from \Spies\GlobalSpies::call_original_global_function due to lack of redefinition and thus loop is not created.

Example code:

<?php

class Test_Foo extends PHPUnit\Framework\TestCase {
	function tearDown() {
		\Spies\finish_spying();
	}

	public function test_foo_1() {
		$bar_spy = \Spies\get_spy_for( 'bar' ); // stubbing previously undefined global function
		$ret = bar();
		$this->assertTrue(
			$bar_spy->was_called_times( 1 )
		);
		$this->assertEquals( $this::$ret, $ret );
	}

	public function test_foo_2() {
		$bar_spy = \Spies\get_spy_for( 'bar' );  // re-stubbing
		$ret = bar();
		$this->assertTrue(
			$bar_spy->was_called_times( 1 )
		);
		$this->assertEquals( $this::$ret, $ret );
	}
}

Invocation of the second bar above will create an infinite loop.

Note: this case doesn't generate if the global function exists beforehand or if the re-stub has a return value. In those cases, \Spies\GlobalSpies::call_original_global_function is not called and there is no loop.

Stubbed global function can not be re-stubbed after `finish_spying`

If a global function is stubbed once and then the original function is restored through finish_spying, the same global function can not be stubbed again.

For example,

<?php

require_once '/vendor/antecedent/patchwork/Patchwork.php';

function bar(): int {
	return 0;
}


class Test_Foo extends PHPUnit\Framework\TestCase {
	function tearDown() {
		\Spies\finish_spying();
	}


	public function test_foo_one() {
		\Spies\stub_function( 'bar' )->that_returns( 1 );
		$this->assertEquals( 1, bar() );
	}

	public function test_foo_two() {
		\Spies\stub_function( 'bar' )->that_returns( 2 );
		$this->assertEquals( 2, bar() );
	}

}

In test_foo_one, I have stubbed bar to return 1.
In test_foo_two, I have stubbed bar to return 2.
If both tests are run one after another, the first test passes and the second doesn't.

There was 1 failure:

1) Test_Foo::test_foo_two
Failed asserting that 0 matches expected 2.

The second one's bar is not stubbed and returns the original return value.

So a stubbed global function can not be re-stubbed properly.

Note: if the tests are run individually, both tests pass.

Error when using Patchwork with PHP internal functions

Steps to reproduce

For example I've func.php (of course A is bad function, and we want to redefine trigger_error with Patchwork):

<?php

function A() {
	trigger_error( 'boom' );
	return false;
}

and here's a simple test case for that:

<?php

use PHPUnit\Framework\TestCase;

class FuncTest extends TestCase {

	public function test_A() {
		\Spies\mock_function( 'trigger_error' )->and_return( null );
		$this->assertFalse( A() );
	}
}

and here's the error I got:

There was 1 error:

1) FuncTest::test_A
boom

./vendor/antecedent/patchwork/src/CallRerouting.php:358
./vendor/antecedent/patchwork/src/CallRerouting.php:436
./vendor/antecedent/patchwork/src/CallRerouting.php:291
./vendor/antecedent/patchwork/src/CallRerouting.php:468
./vendor/antecedent/patchwork/src/CallRerouting.php:233
./vendor/antecedent/patchwork/src/CallRerouting.php:255
./vendor/antecedent/patchwork/src/Stack.php:27
./vendor/antecedent/patchwork/src/CallRerouting.php:266
./vendor/sirbrillig/spies/src/Spies/GlobalSpies.php:109
./vendor/sirbrillig/spies/src/Spies/Spy.php:396
./vendor/sirbrillig/spies/src/Spies/Spy.php:124
./vendor/sirbrillig/spies/src/Spies/GlobalSpies.php:60
./vendor/sirbrillig/spies/src/Spies/GlobalSpies.php:120
./vendor/antecedent/patchwork/src/CallRerouting.php:233
./vendor/antecedent/patchwork/src/CallRerouting.php:255
./vendor/antecedent/patchwork/src/Stack.php:27
./vendor/antecedent/patchwork/src/CallRerouting.php:266
./func.php:4
./func-test.php:10

Notes

  • Using \Patchwork\redefine( 'trigger_error', \Patchwork\always( null ) ) works fine.
  • Redefine our own defined functions (e.g. A) with \Spies\mock_function( 'A' ) works fine.

Only load PHPUnit dependencies if using PHPUnit

It might be best to move the PHPUnit assertions to a separate library or at least conditionally load it only if we are inside PHPUnit. That would allow Spies to be more easily re-used in other test runners such as corretto or atoum.

See also #21, which is related.

  • Separate PHPUnit from Expectations
  • Conditionally load PHPUnit assertions only if PHPUnit is available
  • Move phpunit (and patchwork) from dependency to dev dependency

Substitute special matchers in reports

When using a special matcher like any() or match_array(), and the Expectation or assertion fails, we get a report like the following which includes the JSON representations of those objects:

Test:

expect_spy($spy)->to_have_been_called->with(any(), 'test-message', match_array(['one']));
finish_spying();

Response:

Failed asserting that myFunc is called with arguments: ( {"value":"ANYTHING"}, "test-message", {"expected_array":["one"]} ).

It would be nicer to not see the internals of the matcher objects. Perhaps something like this instead:

Failed asserting that myFunc is called with arguments: ( any, "test-message", match_array(["one"]) ).

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    ๐Ÿ–– Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. ๐Ÿ“Š๐Ÿ“ˆ๐ŸŽ‰

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google โค๏ธ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.