Skip to content

Commit

Permalink
Merge pull request #8 from sirbrillig/improve-expectation-errors
Browse files Browse the repository at this point in the history
Refactor Expectations to use PHPUnit custom assertions
  • Loading branch information
sirbrillig authored Aug 9, 2016
2 parents ca26c79 + fc3cfe5 commit 5016f5e
Show file tree
Hide file tree
Showing 8 changed files with 206 additions and 181 deletions.
3 changes: 3 additions & 0 deletions API.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
- `was_called_with( $arg... )`: Return true if the Spy was called with specific arguments.
- `was_called_when( $callable )`: Return true if the passed function returns true at least once. For each spy call, the function will be called with the arguments from that call.
- `was_called_times( $count )`: Return true if the Spy was called exactly $count times.
- `was_called_times_with( $count, $arg... )`: Return true if the Spy was called exactly $count times with specific arguments.
- `was_called_before( $spy )`: Return true if the Spy was called before $spy.
- `get_times_called()`: Return the number of times the Spy was called.
- `get_call( $index )`: Return the call record for a single call.
Expand Down Expand Up @@ -112,6 +113,8 @@ These are methods available on instances of `\Spies\TestCase`.
- `assertSpyWasNotCalledWith( $spy, $args )`
- `assertSpyWasCalledTimes( $spy, $count )`
- `assertSpyWasNotCalledTimes( $spy, $count )`
- `assertSpyWasCalledTimesWith( $spy, $count, $args )`
- `assertSpyWasNotCalledTimesWith( $spy, $count, $args )`
- `assertSpyWasCalledBefore( $spy, $other_spy )`
- `assertSpyWasNotCalledBefore( $spy, $other_spy )`
- `assertSpyWasCalledWhen( $spy, $callable )`
Expand Down
192 changes: 55 additions & 137 deletions src/Spies/Expectation.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,7 @@ class Expectation {
public $to_be_called;
public $to_have_been_called;

// If true, `verify()` will throw Exceptions instead of using PHPUnit_Framework_Assert
public $throw_exceptions = false;
// If true, `verify()` will return an error description instead of using PHPUnit_Framework_Assert
// If true, `verify()` will return true or false instead making a PHPUnit assertion
public $silent_failures = false;

// Can be used to prevent double-verification.
Expand Down Expand Up @@ -55,22 +53,19 @@ public function __get( $key ) {
/**
* Verify all behaviors in this Expectation
*
* By default it will use PHPUnit_Framework_Assert to create assertions for
* By default it will use PHPUnit to create assertions for
* each behavior.
*
* If `throw_exceptions` is set to true, instead it will throw an exception
* for each failure.
*
* If `silent_failures` is set to true, instead it will return the description
* of the first failure it finds, or null if all behaviors passed.
* If `silent_failures` is set to true, it will return true or false instead
* making a PHPUnit assertion
*
* @return string|null The first failure description if there is a failure
*/
public function verify() {
$this->was_verified = true;
foreach( $this->delayed_expectations as $behavior ) {
$description = call_user_func( $behavior );
if ( $description ) {
if ( $description !== null ) {
return $description;
}
}
Expand All @@ -91,23 +86,16 @@ public function verify() {
public function when( $callable ) {
$this->expected_function = $callable;
$this->delay_expectation( function() use ( $callable ) {
$result = $this->spy->was_called_when( $callable );
$description = $this->build_failure_message( function( $bits ) {
$called_functions = $this->spy->get_called_functions();
$bits[] = 'matching the provided function but instead';
if ( count( $called_functions ) === 1 ) {
$bits[] = 'it was called with ' . $this->format_arguments_for_output( [ $called_functions[0] ] );
} else if ( count( $called_functions ) === 0 ) {
$bits[] = 'it was not called at all.';
} else if ( count( $called_functions ) > 1 ) {
$bits[] = 'it was called with each of these sets of arguments ' . $this->format_arguments_for_output( $called_functions );
}
return $bits;
} );
if ( $this->negation ) {
return $this->assertFalse( $result, $description );
if ( $this->silent_failures ) {
return ! ( new SpiesConstraintWasCalledWhen( $callable ) )->matches( $this->spy );
}
return \Spies\TestCase::assertSpyWasNotCalledWhen( $this->spy, $callable );
}
return $this->assertTrue( $result, $description );
if ( $this->silent_failures ) {
return ( new SpiesConstraintWasCalledWhen( $callable ) )->matches( $this->spy );
}
return \Spies\TestCase::assertSpyWasCalledWhen( $this->spy, $callable );
} );
return $this;
}
Expand All @@ -130,23 +118,16 @@ public function with() {
}
$this->expected_args = $args;
$this->delay_expectation( function() use ( $args ) {
$result = call_user_func_array( [ $this->spy, 'was_called_with' ], $args );
$description = $this->build_failure_message( function( $bits ) use ( $args ) {
$called_functions = $this->spy->get_called_functions();
$bits[] = 'with ' . json_encode( $args ) . ' but instead';
if ( count( $called_functions ) === 1 ) {
$bits[] = 'it was called with ' . $this->format_arguments_for_output( [ $called_functions[0] ] );
} else if ( count( $called_functions ) === 0 ) {
$bits[] = 'it was not called at all.';
} else if ( count( $called_functions ) > 1 ) {
$bits[] = 'it was called with each of these sets of arguments ' . $this->format_arguments_for_output( $called_functions );
}
return $bits;
} );
if ( $this->negation ) {
return $this->assertFalse( $result, $description );
if ( $this->silent_failures ) {
return ! ( new SpiesConstraintWasCalledWith( $this->expected_args ) )->matches( $this->spy );
}
return \Spies\TestCase::assertSpyWasNotCalledWith( $this->spy, $this->expected_args );
}
return $this->assertTrue( $result, $description );
if ( $this->silent_failures ) {
return ( new SpiesConstraintWasCalledWith( $this->expected_args ) )->matches( $this->spy );
}
return \Spies\TestCase::assertSpyWasCalledWith( $this->spy, $this->expected_args );
} );
return $this;
}
Expand All @@ -160,16 +141,16 @@ public function with() {
*/
public function to_be_called() {
$this->delay_expectation( function() {
$result = $this->spy->was_called();
$description = $this->build_failure_message( function( $bits ) {
$bits[] = 'but it was';
$bits[] = $this->negation ? 'called.' : 'not called at all.';
return $bits;
} );
if ( $this->negation ) {
return $this->assertFalse( $result, $description );
if ( $this->silent_failures ) {
return ! ( new SpiesConstraintWasCalled() )->matches( $this->spy );
}
return \Spies\TestCase::assertSpyWasNotCalled( $this->spy );
}
return $this->assertTrue( $result, $description );
if ( $this->silent_failures ) {
return ( new SpiesConstraintWasCalled() )->matches( $this->spy );
}
return \Spies\TestCase::assertSpyWasCalled( $this->spy );
} );
return $this;
}
Expand Down Expand Up @@ -223,40 +204,45 @@ public function twice() {
*/
public function times( $count ) {
$this->delay_expectation( function() use ( $count ) {
$called_functions = $this->spy->get_called_functions();
$actual = count( $called_functions );
if ( isset( $this->expected_args ) ) {
$actual = count( array_filter( $called_functions, function( $call ) {
return Helpers::do_args_match( $call->get_args(), $this->expected_args );
} ) );
}
$description = $this->build_failure_message( function( $bits ) use ( $count, $actual, $called_functions ) {
$bits[] = $count . ' times';
if ( isset( $this->expected_args ) ) {
$bits[] = 'with the arguments ' . json_encode( $this->expected_args );
if ( $this->negation ) {
if ( $this->silent_failures ) {
return ! ( new SpiesConstraintWasCalledTimesWith( $count, $this->expected_args ) )->matches( $this->spy );
}
return \Spies\TestCase::assertSpyWasNotCalledTimesWith( $this->spy, $count, $this->expected_args );
}
$bits[] = 'but it was called ' . $actual . ' times';
if ( $actual > 0 ) {
$bits[] = 'with each of these sets of arguments ' . $this->format_arguments_for_output( $called_functions );
if ( $this->silent_failures ) {
return ( new SpiesConstraintWasCalledTimesWith( $count, $this->expected_args ) )->matches( $this->spy );
}
return $bits;
} );
return \Spies\TestCase::assertSpyWasCalledTimesWith( $this->spy, $count, $this->expected_args );
}

if ( $this->negation ) {
return $this->assertNotEquals( $count, $actual, $description );
if ( $this->silent_failures ) {
return ! ( new SpiesConstraintWasCalledTimes( $count ) )->matches( $this->spy );
}
return \Spies\TestCase::assertSpyWasNotCalledTimes( $this->spy, $count );
}
if ( $this->silent_failures ) {
return ( new SpiesConstraintWasCalledTimes( $count ) )->matches( $this->spy );
}
return $this->assertEquals( $count, $actual, $description );
return \Spies\TestCase::assertSpyWasCalledTimes( $this->spy, $count );
} );
return $this;
}

public function before( $target_spy ) {
$this->delay_expectation( function() use ( $target_spy ) {
$actual = $this->spy->was_called_before( $target_spy );
$description = 'Expected "' . $this->spy->get_function_name() . '" to be called before "' . $target_spy->get_function_name() . '"';
if ( $this->negation ) {
return $this->assertFalse( $actual, $description );
if ( $this->silent_failures ) {
return ! ( new SpiesConstraintWasCalledBefore( $target_spy ) )->matches( $this->spy );
}
return \Spies\TestCase::assertSpyWasNotCalledBefore( $this->spy, $target_spy );
}
if ( $this->silent_failures ) {
return ( new SpiesConstraintWasCalledBefore( $target_spy ) )->matches( $this->spy );
}
return $this->assertTrue( $actual, $description );
return \Spies\TestCase::assertSpyWasCalledBefore( $this->spy, $target_spy );
} );
return $this;
}
Expand All @@ -273,72 +259,4 @@ public function before( $target_spy ) {
private function delay_expectation( $behavior ) {
$this->delayed_expectations[] = $behavior;
}

private function build_failure_message( $message_func ) {
$description = [];
$description[] = 'Expected "' . $this->spy->get_function_name() . '"';
$description[] = $this->negation ? 'not to be called' : 'to be called';
if ( is_callable( $message_func ) ) {
$description = $message_func( $description );
}
return implode( ' ', $description );
}

private function format_arguments_for_output( $called_functions ) {
return implode( ", ", array_map( function( $call ) {
return json_encode( $call->get_args() );
}, $called_functions ) );
}

private function assertTrue( $value, $description ) {
if ( ! $this->throw_exceptions && ! $this->silent_failures && class_exists( 'PHPUnit_Framework_Assert' ) ) {
\PHPUnit_Framework_Assert::assertTrue( $value, $description );
return;
}
if ( ! $value ) {
if ( $this->silent_failures ) {
return $description;
}
throw new UnmetExpectationException( $description );
}
}

private function assertFalse( $value, $description ) {
if ( ! $this->throw_exceptions && ! $this->silent_failures && class_exists( 'PHPUnit_Framework_Assert' ) ) {
\PHPUnit_Framework_Assert::assertFalse( $value, $description );
return;
}
if ( $value ) {
if ( $this->silent_failures ) {
return $description;
}
throw new UnmetExpectationException( $description );
}
}

private function assertEquals( $a, $b, $description ) {
if ( ! $this->throw_exceptions && ! $this->silent_failures && class_exists( 'PHPUnit_Framework_Assert' ) ) {
\PHPUnit_Framework_Assert::assertEquals( $a, $b, $description );
return;
}
if ( $a !== $b ) {
if ( $this->silent_failures ) {
return $description;
}
throw new UnmetExpectationException( $description );
}
}

private function assertNotEquals( $a, $b, $description ) {
if ( ! $this->throw_exceptions && ! $this->silent_failures && class_exists( 'PHPUnit_Framework_Assert' ) ) {
\PHPUnit_Framework_Assert::assertNotEquals( $a, $b, $description );
return;
}
if ( $a === $b ) {
if ( $this->silent_failures ) {
return $description;
}
throw new UnmetExpectationException( $description );
}
}
}
38 changes: 38 additions & 0 deletions src/Spies/SpiesConstraintWasCalledTimesWith.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php
namespace Spies;

class SpiesConstraintWasCalledTimesWith extends \PHPUnit_Framework_Constraint {
private $expected_args;
private $count;

public function __construct( $count, $args ) {
parent::__construct();
$this->expected_args = $args;
$this->count = $count;
}

public function matches( $other ) {
if ( ! $other instanceof \Spies\Spy ) {
return false;
}
return $other->was_called_times_with_array( $this->count, $this->expected_args );
}

protected function failureDescription( $other ) {
$generator = new FailureGenerator();
$generator->spy_was_not_called_with( $other, $this->expected_args );
return $generator->get_message();
}

protected function additionalFailureDescription( $other ) {
$generator = new FailureGenerator();
$generator->spy_was_not_called_with_additional( $other );
return $generator->get_message();
}

public function toString() {
return '';
}
}


30 changes: 30 additions & 0 deletions src/Spies/Spy.php
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,36 @@ public function was_called_with_array( $args ) {
return ( count( $matching_calls ) > 0 );
}

/**
* Return true if the spy was called with certain arguments a certain number of times
*
* Array version of was_called_with
*
* @param integer $times The number of times the function should have been called
* @param array $arg The arguments to look for in the call record
* @return boolean True if the spy was called with the arguments that number of times
*/
public function was_called_times_with_array( $count, $args ) {
$matching_calls = array_filter( $this->get_called_functions(), function( $call ) use ( $args ) {
return ( Helpers::do_args_match( $call->get_args(), $args ) );
} );
return ( count( $matching_calls ) === $count );
}

/**
* Return true if the spy was called with certain arguments a certain number of times
*
* @param integer $times The number of times the function should have been called
* @param mixed $arg... The arguments to look for in the call record
* @return boolean True if the spy was called with the arguments that number of times
*/
public function was_called_times_with() {
$all_args = func_get_args();
$count = $all_args[0];
$args = array_slice( $all_args, 1 );
return $this->was_called_times_with_array( $count, $args );
}

/**
* Return true if a spy call causes a function to return true
*
Expand Down
12 changes: 12 additions & 0 deletions src/Spies/TestCase.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ public static function wasCalledWhen( $callable ) {
return new \Spies\SpiesConstraintWasCalledWhen( $callable );
}

public static function wasCalledTimesWith( $count, $args ) {
return new \Spies\SpiesConstraintWasCalledTimesWith( $count, $args );
}

public static function assertSpyWasCalled( $condition, $message = '' ) {
self::assertThat( $condition, self::wasCalled(), $message );
}
Expand Down Expand Up @@ -67,4 +71,12 @@ public static function assertSpyWasNotCalledWhen( $condition, $callable, $messag
self::assertThat( $condition, self::logicalNot( self::wasCalledWhen( $callable ) ), $message );
}

public static function assertSpyWasCalledTimesWith( $condition, $count, $args, $message = '' ) {
self::assertThat( $condition, self::wasCalledTimesWith( $count, $args ), $message );
}

public static function assertSpyWasNotCalledTimesWith( $condition, $count, $args, $message = '' ) {
self::assertThat( $condition, self::logicalNot( self::wasCalledTimesWith( $count, $args ) ), $message );
}

}
Loading

0 comments on commit 5016f5e

Please sign in to comment.