woocommerce/tests/Tools/CodeHacking/Hacks/FunctionsMockerHack.php

181 lines
5.9 KiB
PHP

<?php
/**
* FunctionsMockerHack class file.
*
* @package WooCommerce\Testing
*/
namespace Automattic\WooCommerce\Testing\Tools\CodeHacking\Hacks;
use ReflectionMethod;
use ReflectionClass;
/**
* Hack to mock standalone functions.
*
* How to use:
*
* 1. Invoke 'FunctionsMockerHack::initialize' once, passing an array with the names of the functions
* that can be mocked.
*
* 2. Invoke 'CodeHacker::add_hack(FunctionsMockerHack::get_hack_instance())' once.
*
* 3. Use 'add_function_mocks' in tests as needed to register callbacks to be executed instead of the functions, e.g.:
*
* FunctionsMockerHack::add_function_mocks([
* 'get_option' => function($name, $default) {
* return 'foo' === $name ? 'bar' : get_option($name, $default);
* }
* ]);
*
* 1 and 2 must be done during the unit testing bootstrap process.
*
* Note that unless the tests directory is included in the hacking via 'CodeHacker::initialize'
* (and they shouldn't!), test code files aren't hacked, therefore the original functions are always
* executed inside tests (and thus the above example won't stack-overflow).
*/
final class FunctionsMockerHack extends CodeHack {
/**
* Tokens that precede a non-standalone-function identifier.
*
* @var array
*/
private static $non_global_function_tokens = array(
T_PAAMAYIM_NEKUDOTAYIM,
T_DOUBLE_COLON,
T_OBJECT_OPERATOR,
T_FUNCTION,
T_CLASS,
T_EXTENDS,
);
/**
* @var FunctionsMockerHack Holds the only existing instance of the class.
*/
private static $instance;
/**
* Initializes the class.
*
* @param array $mockable_functions An array containing the names of the functions that will become mockable.
*
* @throws \Exception $mockable_functions is not an array or is empty.
*/
public static function initialize( $mockable_functions ) {
if ( ! is_array( $mockable_functions ) || empty( $mockable_functions ) ) {
throw new \Exception( 'FunctionsMockeHack::initialize: $mockable_functions must be a non-empty array of function names.' );
}
self::$instance = new FunctionsMockerHack( $mockable_functions );
}
/**
* FunctionsMockerHack constructor.
*
* @param array $mockable_functions An array containing the names of the functions that will become mockable.
*/
private function __construct( $mockable_functions ) {
$this->mockable_functions = $mockable_functions;
}
/**
* Hacks code by replacing elegible function invocations with an invocation to this class' static method with the same name.
*
* @param string $code The code to hack.
* @param string $path The path of the file containing the code to hack.
* @return string The hacked code.
*/
public function hack( $code, $path ) {
$tokens = $this->tokenize( $code );
$code = '';
$previous_token_is_non_global_function_qualifier = false;
foreach ( $tokens as $token ) {
$token_type = $this->token_type_of( $token );
if ( T_WHITESPACE === $token_type ) {
$code .= $this->token_to_string( $token );
} elseif ( T_STRING === $token_type && ! $previous_token_is_non_global_function_qualifier && in_array( $token[1], $this->mockable_functions, true ) ) {
$code .= __CLASS__ . "::{$token[1]}";
$previous_token_is_non_global_function_qualifier = false;
} else {
$code .= $this->token_to_string( $token );
$previous_token_is_non_global_function_qualifier = in_array( $token_type, self::$non_global_function_tokens, true );
}
}
return $code;
}
/**
* @var array Functions that can be mocked, associative array of function name => callback.
*/
private $function_mocks = array();
/**
* Register function mocks.
*
* @param array $mocks Mocks as an associative array of function name => mock function with the same arguments as the original function.
*
* @throws \Exception Invalid input.
*/
public function register_function_mocks( $mocks ) {
if ( ! is_array( $mocks ) ) {
throw new \Exception( 'FunctionsMockerHack::add_function_mocks: $mocks must be an associative array of function name => callable.' );
}
foreach ( $mocks as $function_name => $mock ) {
if ( ! in_array( $function_name, $this->mockable_functions, true ) ) {
throw new \Exception( "FunctionsMockerHack::add_function_mocks: Can't mock '$function_name' since it isn't in the list of mockable functions supplied to 'initialize'." );
}
if ( ! is_callable( $mock ) ) {
throw new \Exception( "FunctionsMockerHack::add_function_mocks: The mock supplied for '$function_name' isn't callable." );
}
}
$this->function_mocks = array_merge( $this->function_mocks, $mocks );
}
/**
* Register function mocks.
*
* @param array $mocks Mocks as an associative array of function name => mock function with the same arguments as the original function.
*
* @throws \Exception Invalid input.
*/
public static function add_function_mocks( $mocks ) {
self::$instance->register_function_mocks( $mocks );
}
/**
* Unregister all the registered function mocks.
*/
public function reset() {
$this->function_mocks = array();
}
/**
* Handler for undefined static methods on this class, it invokes the mock for the function if registered or the original function if not.
*
* @param string $name Name of the function.
* @param array $arguments Arguments for the function.
*
* @return mixed The return value from the invoked callback or function.
*/
public static function __callStatic( $name, $arguments ) {
if ( array_key_exists( $name, self::$instance->function_mocks ) ) {
return call_user_func_array( self::$instance->function_mocks[ $name ], $arguments );
} else {
return call_user_func_array( $name, $arguments );
}
}
/**
* Get the only existing instance of this class. 'get_instance' is not used to avoid conflicts since that's a widely used method name.
*
* @return FunctionsMockerHack The only existing instance of this class.
*/
public static function get_hack_instance() {
return self::$instance;
}
}