Skip to content

Commit 6f4e1af

Browse files
author
Zoltan Toth-Czifra
committed
Committing first version of the class
0 parents  commit 6f4e1af

File tree

3 files changed

+378
-0
lines changed

3 files changed

+378
-0
lines changed

PHPUnit/Extensions/MockFunction.php

Lines changed: 293 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,293 @@
1+
<?php
2+
3+
/**
4+
* Extension for PHPUnit that makes MockObject-style expectations possible for global functions (even PECL functions).
5+
*
6+
* @author zoltan.tothczifra
7+
*/
8+
class PHPUnit_Extensions_MockFunction
9+
{
10+
/**
11+
* Incremental ID of the current object instance to be able to find it.
12+
*
13+
* @see self::$instances
14+
* @var integer
15+
*/
16+
protected $id;
17+
18+
/**
19+
* Flag to tell if the function mocking is active or not (replacement is in place).
20+
*
21+
* @var boolean
22+
*/
23+
protected $active = false;
24+
25+
/**
26+
* Test case from where the mock function is created. Automagically found with call stack.
27+
*
28+
* @var PHPUnit_Framework_TestCase
29+
*/
30+
protected $test_case;
31+
32+
/**
33+
* Standard PHPUnit MockObject used to test invocations of the mocked function.
34+
*
35+
* @var object
36+
*/
37+
protected $mock_object;
38+
39+
/**
40+
* Object to check if the function is called from its scope (supposedly the test object to the test case).
41+
*
42+
* If the mocked function is called outside its scope, the original (unmocked)
43+
* function is executed - if there is.
44+
*
45+
* @var object
46+
*/
47+
protected $scope_object;
48+
49+
/**
50+
* The name of the original function that gets mocked.
51+
*
52+
* @var string
53+
*/
54+
protected $function_name;
55+
56+
/**
57+
* Random temporary name of a funstion there we "save" the original, unmocked function.
58+
*
59+
* If the function did not exist before mocking, it's empty.
60+
*
61+
* @var type
62+
*/
63+
protected $restore_name;
64+
65+
/**
66+
* Value of the incremental ID that next time will be assigned to an instance of this class.
67+
*
68+
* @var integer
69+
*/
70+
protected static $next_id = 1;
71+
72+
/**
73+
* List of active mock object instances (those that are not restored) with their ID as key.
74+
*
75+
* @var type
76+
*/
77+
protected static $instances = array();
78+
79+
/**
80+
* Class name of PHPUnit test cases, used to automatically find them in the call stack.
81+
*/
82+
const TESTCASE_CLASSNAME = 'PHPUnit_Framework_TestCase';
83+
84+
/**
85+
* Number of call stack items between the function call of the test object and self::invoked().
86+
*
87+
* 1. Inocation from test object
88+
* 2. Runkit function
89+
* 3. Call to self::invoked().
90+
*/
91+
const CALL_STACK_DISTANCE = 3;
92+
93+
/**
94+
* Constructor setting up object.
95+
*
96+
* @param string $function_name Name of the function to mock. Doesn't need to exist, might be newly created.
97+
* @param object $scope_object Object specifying the scope where the mocked function is used.
98+
*/
99+
public function __construct( $function_name, $scope_object )
100+
{
101+
if ( !function_exists( 'runkit_function_redefine' ) )
102+
{
103+
trigger_error( 'Runkit is not installed.' );
104+
}
105+
106+
$this->id = self::$next_id;
107+
$this->function_name = $function_name;
108+
$this->scope_object = $scope_object;
109+
$this->test_case = self::findTestCase();
110+
$this->mock_object = $this->test_case->getMock( 'Mock_' . $this->function_name . '_' . $this->id, array( 'invoked' ) );
111+
112+
++self::$next_id;
113+
self::$instances[$this->id] = $this;
114+
115+
$this->createFunction();
116+
}
117+
118+
/**
119+
* Called when all the referneces to the object are removed (even self::$instances).
120+
*
121+
* Makes sure the replaced functions are finally cleared in case runkit
122+
* "forgets" to remove them in the end of the request.
123+
* It is still higgly recommended to call restore() explciitly!
124+
*/
125+
public function __destruct()
126+
{
127+
$this->restore();
128+
}
129+
130+
/**
131+
* Clean-up function.
132+
*
133+
* Removes mocked function and restored the original was there is any.
134+
* Also removes the reference to the object from self::$instances.
135+
*/
136+
public function restore()
137+
{
138+
if ( $this->active )
139+
{
140+
runkit_function_remove( $this->function_name );
141+
if ( isset( $this->restore_name ) )
142+
{
143+
runkit_function_rename( $this->restore_name, $this->function_name );
144+
}
145+
$this->active = false;
146+
}
147+
148+
if ( isset( self::$instances[$this->id] ) )
149+
{
150+
unset( self::$instances[$this->id] );
151+
}
152+
}
153+
154+
/**
155+
* Callback method to be used in runkit function when it is invoked.
156+
*
157+
* It takes the parameters of the function call and passes them to the mock object.
158+
*
159+
* @param type $arguments 0-indexed array of arguments with which the mocked function was called.
160+
* @return mixed
161+
*/
162+
public function invoked( $arguments )
163+
{
164+
// Original function is called when the invocation is ousides he scope or
165+
// the invocation comes from this object.
166+
$caller_object = self::getCallStackObject( self::CALL_STACK_DISTANCE );
167+
if ( $caller_object === $this || ( isset( $this->scope_object ) && $this->scope_object !== $caller_object ) )
168+
{
169+
if ( isset( $this->restore_name ) )
170+
{
171+
return call_user_func_array( $this->restore_name, $arguments );
172+
}
173+
trigger_error( 'Undefined function: ' . $this->function_name );
174+
}
175+
return call_user_func_array( array( $this->mock_object, __FUNCTION__ ), $arguments );
176+
}
177+
178+
/**
179+
* Proxy to the 'expects' of the mock object.
180+
*
181+
* Also calld method() so after this the mock object can be used to set
182+
* parameter constraints and return values.
183+
*
184+
* @return object
185+
*/
186+
public function expects()
187+
{
188+
$arguments = func_get_args();
189+
return call_user_func_array( array( $this->mock_object, __FUNCTION__ ), $arguments )->method( 'invoked' );
190+
}
191+
192+
/**
193+
* Returns an instance of this class selected by its ID. Used in the runkit function.
194+
*
195+
* @param integer $id
196+
* @return object
197+
*/
198+
public static function findMock( $id )
199+
{
200+
if ( !isset( self::$instances[$id] ) )
201+
{
202+
throw new Exception( 'Mock object not found, might be destroyed already.' );
203+
}
204+
return self::$instances[$id];
205+
}
206+
207+
/**
208+
* Finds the rist object in the call cstack that is instance of a PHPUnit test case.
209+
*
210+
* @see self::TESTCASE_CLASSNAME
211+
* @return object
212+
*/
213+
public static function findTestCase()
214+
{
215+
$backtrace = debug_backtrace();
216+
$classname = self::TESTCASE_CLASSNAME;
217+
218+
do
219+
{
220+
$calling_test = array_shift( $backtrace );
221+
} while( isset( $calling_test ) && !( isset( $calling_test['object'] ) && $calling_test['object'] instanceof $classname ) );
222+
223+
if ( !isset( $calling_test ) )
224+
{
225+
trigger_error( 'No calling test found.' );
226+
}
227+
228+
return $calling_test['object'];
229+
}
230+
231+
/**
232+
* Creates runkit function to be used for mocking, taking care of callback to this object.
233+
*
234+
* Also temporary renames the original function if there is.
235+
*/
236+
protected function createFunction()
237+
{
238+
if ( function_exists( $this->function_name ) )
239+
{
240+
$this->restore_name = 'restore_' . $this->function_name . '_' . $this->id . '_' . uniqid();
241+
242+
runkit_function_copy( $this->function_name, $this->restore_name );
243+
runkit_function_redefine( $this->function_name, '', $this->getCallback() );
244+
}
245+
else
246+
{
247+
runkit_function_add( $this->function_name, '', $this->getCallback() );
248+
}
249+
250+
$this->active = true;
251+
}
252+
253+
/**
254+
* Gives back the source code body of the runkit function replacing the original.
255+
*
256+
* The function is quite simple - find the function mock instance (of this class)
257+
* that created it, then calls its invoked() method with the parameters of its invokation.
258+
*
259+
* @return string
260+
*/
261+
protected function getCallback()
262+
{
263+
$class_name = __CLASS__;
264+
return <<<CALLBACK
265+
\$mock = $class_name::findMock( {$this->id} );
266+
\$arguments = func_get_args();
267+
return \$mock->invoked( \$arguments );
268+
CALLBACK;
269+
}
270+
271+
/**
272+
* Returns an object from the call stack at Nth distance if there is, null otherwise.
273+
*
274+
* In theory we should instement the distance by one because when we call this
275+
* method, we don't count it itself to the callstack, but since the stack is
276+
* 0-indexed, we can avoid this step.
277+
*
278+
* @param type $distance The distance in the call stack from the current call and the desired one.
279+
* @return object
280+
*/
281+
protected static function getCallStackObject( $distance )
282+
{
283+
$backtrace = debug_backtrace();
284+
285+
if ( isset( $backtrace[$distance]['object'] ) )
286+
{
287+
return $backtrace[$distance]['object'];
288+
}
289+
290+
return null;
291+
}
292+
293+
}

README

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
PHPUnit extension that uses runkit to mock PHP functions (both user-defined and system) and use mockobject-style invocation matchers, parameter constraints and all that magic.
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
<?php
2+
3+
require_once dirname(__FILE__) . '/../../PHPUnit/Extensions/MockFunction.php';
4+
5+
/**
6+
* Test class for PHPUnit_Extensions_MockFunction.
7+
* Generated by PHPUnit on 2011-05-18 at 00:22:24.
8+
*/
9+
class PHPUnit_Extensions_MockFunctionTest extends PHPUnit_Framework_TestCase {
10+
11+
/**
12+
* @var PHPUnit_Extensions_MockFunction
13+
*/
14+
protected $object;
15+
16+
/**
17+
* Sets up the fixture, for example, opens a network connection.
18+
* This method is called before a test is executed.
19+
*/
20+
protected function setUp() {
21+
$this->object = new PHPUnit_Extensions_MockFunction;
22+
}
23+
24+
/**
25+
* Tears down the fixture, for example, closes a network connection.
26+
* This method is called after a test is executed.
27+
*/
28+
protected function tearDown() {
29+
30+
}
31+
32+
/**
33+
* @todo Implement testRestore().
34+
*/
35+
public function testRestore() {
36+
// Remove the following lines when you implement this test.
37+
$this->markTestIncomplete(
38+
'This test has not been implemented yet.'
39+
);
40+
}
41+
42+
/**
43+
* @todo Implement testInvoked().
44+
*/
45+
public function testInvoked() {
46+
// Remove the following lines when you implement this test.
47+
$this->markTestIncomplete(
48+
'This test has not been implemented yet.'
49+
);
50+
}
51+
52+
/**
53+
* @todo Implement testExpects().
54+
*/
55+
public function testExpects() {
56+
// Remove the following lines when you implement this test.
57+
$this->markTestIncomplete(
58+
'This test has not been implemented yet.'
59+
);
60+
}
61+
62+
/**
63+
* @todo Implement testFindMock().
64+
*/
65+
public function testFindMock() {
66+
// Remove the following lines when you implement this test.
67+
$this->markTestIncomplete(
68+
'This test has not been implemented yet.'
69+
);
70+
}
71+
72+
/**
73+
* @todo Implement testFindTestCase().
74+
*/
75+
public function testFindTestCase() {
76+
// Remove the following lines when you implement this test.
77+
$this->markTestIncomplete(
78+
'This test has not been implemented yet.'
79+
);
80+
}
81+
82+
}
83+
84+
?>

0 commit comments

Comments
 (0)