Code hacker resdesign for single-load of code files.

The code hacker as originally designed, as a mechanism that allowed
to enable hacks at the individual test level, is flawed because it
assumes that code files are loaded before each test, but actually
the PHP engine loads code files only once.

Therefore this commit redesigns it so that the two existing main hacks,
the functions mocker and the static methods hacker, are applied
to all the relevant functions and classes at bootstrap time, and
mocks for each individual function/method can be registered at the
beginning of each test. See README for the full details.
This commit is contained in:
Nestor Soriano 2020-06-02 10:08:24 +02:00
parent 2a68bb018d
commit bfda3f9938
12 changed files with 500 additions and 621 deletions

View File

@ -10,7 +10,6 @@
namespace Automattic\WooCommerce\Testing\Tools\CodeHacking; namespace Automattic\WooCommerce\Testing\Tools\CodeHacking;
use \ReflectionObject; use \ReflectionObject;
use \ReflectionFunction;
use \ReflectionException; use \ReflectionException;
/** /**
@ -55,7 +54,7 @@ final class CodeHacker {
* *
* @var array * @var array
*/ */
private static $path_white_list = array(); private static $paths_with_files_to_hack = array();
/** /**
* Registered hacks. * Registered hacks.
@ -64,13 +63,6 @@ final class CodeHacker {
*/ */
private static $hacks = array(); private static $hacks = array();
/**
* Registered persistent hacks.
*
* @var array
*/
private static $persistent_hacks = array();
/** /**
* Is the code hacker enabled?. * Is the code hacker enabled?.
* *
@ -99,13 +91,6 @@ final class CodeHacker {
} }
} }
/**
* Unregister all the non-persistent registered hacks.
*/
public static function clear_hacks() {
self::$hacks = self::$persistent_hacks;
}
/** /**
* Check if the code hacker is enabled. * Check if the code hacker is enabled.
* *
@ -116,48 +101,29 @@ final class CodeHacker {
} }
/** /**
* Check if persistent hacks have been registered. * Execute the 'reset()' method in all the registered hacks.
*
* @return bool True if persistent hacks have been registered.
*/ */
public static function has_persistent_hacks() { public static function reset_hacks() {
return count( self::$persistent_hacks ) > 0; foreach ( self::$hacks as $hack ) {
call_user_func( array( $hack, 'reset' ) );
}
} }
/** /**
* Register a new hack. * Register a new hack.
* *
* @param mixed $hack A function with signature "hack($code, $path)" or an object containing a method with that signature. * @param mixed $hack A function with signature "hack($code, $path)" or an object containing a method with that signature.
* @param bool $persistent If true, the hack will be registered as persistent (so that clear_hacks will not clear it).
* @throws \Exception Invalid input. * @throws \Exception Invalid input.
*/ */
public static function add_hack( $hack, $persistent = false ) { public static function add_hack( $hack ) {
if ( ! is_callable( $hack ) && ! is_object( $hack ) ) { if ( ! self::is_valid_hack_object( $hack ) ) {
throw new \Exception( "CodeHacker::addhack: Hacks must be either functions, or objects having a 'process(\$text, \$path)' method." ); $class = get_class( $hack );
throw new \Exception( "CodeHacker::addhack for instance of $class: Hacks must be objects having a 'process(\$text, \$path)' method and a 'reset()' method." );
} }
if ( ! self::is_valid_hack_callback( $hack ) && ! self::is_valid_hack_object( $hack ) ) {
throw new \Exception( "CodeHacker::addhack: Hacks must be either a function with a 'hack(\$code,\$path)' signature, or an object containing a public method 'hack' with that signature. " );
}
if ( $persistent ) {
self::$persistent_hacks[] = $hack;
}
self::$hacks[] = $hack; self::$hacks[] = $hack;
} }
/**
* Check if the supplied argument is a valid hack callback (has two mandatory arguments).
*
* @param mixed $callback Argument to check.
*
* @return bool true if the argument is a valid hack callback, false otherwise.
* @throws ReflectionException Error when instantiating ReflectionFunction.
*/
private static function is_valid_hack_callback( $callback ) {
return is_callable( $callback ) && HACK_CALLBACK_ARGUMENT_COUNT === ( new ReflectionFunction( $callback ) )->getNumberOfRequiredParameters();
}
/** /**
* Check if the supplied argument is a valid hack object (has a public "hack" method with two mandatory arguments). * Check if the supplied argument is a valid hack object (has a public "hack" method with two mandatory arguments).
* *
@ -172,20 +138,34 @@ final class CodeHacker {
$ro = new ReflectionObject( ( $callback ) ); $ro = new ReflectionObject( ( $callback ) );
try { try {
$rm = $ro->getMethod( 'hack' ); $rm = $ro->getMethod( 'hack' );
return $rm->isPublic() && ! $rm->isStatic() && 2 === $rm->getNumberOfRequiredParameters(); $has_valid_hack_method = $rm->isPublic() && ! $rm->isStatic() && 2 === $rm->getNumberOfRequiredParameters();
$rm = $ro->getMethod( 'reset' );
$has_valid_reset_method = $rm->isPublic() && ! $rm->isStatic() && 0 === $rm->getNumberOfRequiredParameters();
return $has_valid_hack_method && $has_valid_reset_method;
} catch ( ReflectionException $exception ) { } catch ( ReflectionException $exception ) {
return false; return false;
} }
} }
/** /**
* Set the white list of files to hack. If note set, all the PHP files will be hacked. * Initialize the code hacker.
* *
* @param array $path_white_list Paths of the files to hack, can be relative paths. * @param array $paths Paths of the directories containing the files to hack.
* @throws \Exception Invalid input.
*/ */
public static function set_white_list( array $path_white_list ) { public static function initialize( array $paths ) {
self::$path_white_list = $path_white_list; if ( ! is_array( $paths ) || empty( $paths ) ) {
throw new \Exception( 'CodeHacker::initialize - $paths must be a non-empty array with the directories containing the files to be hacked.' );
}
self::$paths_with_files_to_hack = array_map(
function( $path ) {
return realpath( $path );
},
$paths
);
} }
/** /**
@ -352,7 +332,7 @@ final class CodeHacker {
*/ */
public function stream_open( $path, $mode, $options, &$opened_path ) { public function stream_open( $path, $mode, $options, &$opened_path ) {
$use_path = (bool) ( $options & STREAM_USE_PATH ); $use_path = (bool) ( $options & STREAM_USE_PATH );
if ( 'rb' === $mode && self::path_in_white_list( $path ) && 'php' === pathinfo( $path, PATHINFO_EXTENSION ) ) { if ( 'rb' === $mode && self::path_in_list_of_paths_to_hack( $path ) && 'php' === pathinfo( $path, PATHINFO_EXTENSION ) ) {
$content = $this->native( 'file_get_contents', $path, $use_path, $this->context ); $content = $this->native( 'file_get_contents', $path, $use_path, $this->context );
if ( false === $content ) { if ( false === $content ) {
return false; return false;
@ -511,13 +491,15 @@ final class CodeHacker {
* @param string $path File path to check. * @param string $path File path to check.
* *
* @return bool TRUE if there's an entry in the white list that ends with $path, FALSE otherwise. * @return bool TRUE if there's an entry in the white list that ends with $path, FALSE otherwise.
*
* @throws \Exception The class is not initialized.
*/ */
private static function path_in_white_list( $path ) { private static function path_in_list_of_paths_to_hack( $path ) {
if ( empty( self::$path_white_list ) ) { if ( empty( self::$paths_with_files_to_hack ) ) {
return true; throw new \Exception( "CodeHacker is not initialized, it must initialized by invoking 'initialize'" );
} }
foreach ( self::$path_white_list as $white_list_item ) { foreach ( self::$paths_with_files_to_hack as $white_list_item ) {
if ( substr( $path, -strlen( $white_list_item ) ) === $white_list_item ) { if ( substr( $path, 0, strlen( $white_list_item ) ) === $white_list_item ) {
return true; return true;
} }
} }

View File

@ -8,73 +8,15 @@
namespace Automattic\WooCommerce\Testing\Tools\CodeHacking; namespace Automattic\WooCommerce\Testing\Tools\CodeHacking;
use PHPUnit\Runner\BeforeTestHook; use PHPUnit\Runner\BeforeTestHook;
use PHPUnit\Runner\AfterTestHook;
use PHPUnit\Util\Test;
use ReflectionClass;
use ReflectionMethod;
use Exception;
/** /**
* Helper to use the CodeHacker class in PHPUnit. * Helper to use the CodeHacker class in PHPUnit. To use, add this to phpunit.xml:
* *
* How to use:
*
* 1. Add this to phpunit.xml:
*
* <extensions> * <extensions>
* <extension class="CodeHackerTestHook" /> * <extension class="CodeHackerTestHook" />
* </extensions> * </extensions>
*
* 2. Add the following to the test classes:
*
* use Automattic\WooCommerce\Testing\Tools\CodeHacking\CodeHacker;
* use Automattic\WooCommerce\Testing\Tools\CodeHacking\Hacks\...
*
* public static function before_all($method_name) {
* CodeHacker::add_hack(...);
* //Register as many hacks as needed
* CodeHacker::enable();
* }
*
* $method_name is optional, 'before_all()' is also a valid method signature.
*
* You can also define a test-specific 'before_{$test_method_name}' hook.
* If both exist, first 'before_all' will be executed, then the test-specific one.
*
* 3. Additionally, you can register hacks via class/method annotations
* (note that then you don't need the `use`s anymore):
*
* /**
* * @hack HackClassName param1 param2
* * /
* class Some_Test
* {
* /**
* * @hack HackClassName param1 param2
* * /
* public function test_something() {
* }
* }
*
* If the class name ends with 'Hack' you can omit that suffix in the annotation (e.g. 'Foo' instead of 'FooHack').
* Parameters specified after the class name will be passed to the class constructor.
* Hacks defined as class annotations will be applied to all tests.
*/ */
final class CodeHackerTestHook implements BeforeTestHook, AfterTestHook { final class CodeHackerTestHook implements BeforeTestHook {
// phpcs:disable PHPCompatibility.FunctionDeclarations.NewReturnTypeDeclarations.voidFound
/**
* Runs after each test.
*
* @param string $test "TestClass::TestMethod".
* @param float $time The time it took the test to run, in seconds.
*/
public function executeAfterTest( string $test, float $time ): void {
if ( ! CodeHacker::has_persistent_hacks() ) {
CodeHacker::disable();
}
}
/** /**
* Runs before each test. * Runs before each test.
@ -84,98 +26,7 @@ final class CodeHackerTestHook implements BeforeTestHook, AfterTestHook {
* @throws \ReflectionException Thrown by execute_before_methods. * @throws \ReflectionException Thrown by execute_before_methods.
*/ */
public function executeBeforeTest( string $test ): void { public function executeBeforeTest( string $test ): void {
/** CodeHacker::reset_hacks();
* Possible formats of $test:
* TestClass::TestMethod
* TestClass::TestMethod with data set #...
* Warning
*/
$parts = explode( '::', $test );
if ( count( $parts ) < 2 ) {
return; // "Warning" was supplied as argument
}
$class_name = $parts[0];
$method_name = explode( ' ', $parts[1] )[0];
CodeHacker::clear_hacks();
$this->execute_before_methods( $class_name, $method_name );
$has_class_annotation_hacks = $this->add_hacks_from_annotations( new ReflectionClass( $class_name ) );
$has_method_annotaion_hacks = $this->add_hacks_from_annotations( new ReflectionMethod( $class_name, $method_name ) );
if ( $has_class_annotation_hacks || $has_method_annotaion_hacks || CodeHacker::has_persistent_hacks() ) {
CodeHacker::enable();
}
}
// phpcs:enable PHPCompatibility.FunctionDeclarations.NewReturnTypeDeclarations.voidFound
/**
* Apply hacks defined in @hack annotations.
*
* @param object $reflection_object The class or method reflection object whose doc comment will be parsed.
* @return bool True if at least one valid @hack annotation was found.
* @throws Exception Class specified in @hack directive doesn't exist.
*/
private function add_hacks_from_annotations( $reflection_object ) {
$annotations = Test::parseAnnotations( $reflection_object->getDocComment() );
$hacks_added = false;
foreach ( $annotations as $id => $annotation_instances ) {
if ( 'hack' !== $id ) {
continue;
}
foreach ( $annotation_instances as $annotation ) {
preg_match_all( '/"(?:\\\\.|[^\\\\"])*"|\S+/', $annotation, $matches );
$params = $matches[0];
$hack_class = array_shift( $params );
if ( false === strpos( $hack_class, '\\' ) ) {
$hack_class = __NAMESPACE__ . '\\Hacks\\' . $hack_class;
}
if ( ! class_exists( $hack_class ) ) {
$original_hack_class = $hack_class;
$hack_class .= 'Hack';
if ( ! class_exists( $hack_class ) ) {
throw new Exception( "Hack class '{$original_hack_class}' defined via annotation in {$class_name}::{$method_name} doesn't exist." );
}
}
CodeHacker::add_hack( new $hack_class( ...$params ) );
$hacks_added = true;
}
}
return $hacks_added;
}
/**
* Run the 'before_all' and 'before_{test_method_name}' methods in a class.
*
* @param string $class_name Test class name.
* @param string $method_name Test method name.
* @throws ReflectionException Error when instatiating a ReflectionClass.
*/
private function execute_before_methods( $class_name, $method_name ) {
$methods = array( 'before_all', "before_{$method_name}" );
$methods = array_filter(
$methods,
function( $item ) use ( $class_name ) {
return method_exists( $class_name, $item );
}
);
$rc = new ReflectionClass( $class_name );
foreach ( $methods as $method ) {
if ( 0 === $rc->getMethod( $method_name )->getNumberOfParameters() ) {
$class_name::$method();
} else {
$class_name::$method( $method_name );
}
}
} }
} }

View File

@ -5,8 +5,6 @@
* @package WooCommerce/Testing * @package WooCommerce/Testing
*/ */
// phpcs:disable Squiz.Commenting.FunctionComment.Missing
namespace Automattic\WooCommerce\Testing\Tools\CodeHacking\Hacks; namespace Automattic\WooCommerce\Testing\Tools\CodeHacking\Hacks;
/** /**
@ -16,6 +14,13 @@ namespace Automattic\WooCommerce\Testing\Tools\CodeHacking\Hacks;
*/ */
final class BypassFinalsHack extends CodeHack { final class BypassFinalsHack extends CodeHack {
/**
* Hacks code by removing "final" keywords from class definitions.
*
* @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 ) { public function hack( $code, $path ) {
if ( stripos( $code, 'final' ) !== false ) { if ( stripos( $code, 'final' ) !== false ) {
$tokens = $this->tokenize( $code ); $tokens = $this->tokenize( $code );
@ -27,6 +32,11 @@ final class BypassFinalsHack extends CodeHack {
return $code; return $code;
} }
/**
* Revert the hack to its initial state - nothing to do since finals can't be reverted.
*/
public function reset() {
}
} }
// phpcs:enable Squiz.Commenting.FunctionComment.Missing

View File

@ -24,6 +24,11 @@ abstract class CodeHack {
*/ */
abstract public function hack( $code, $path); abstract public function hack( $code, $path);
/**
* Revert the hack to its initial state.
*/
abstract public function reset();
/** /**
* Tokenize PHP source code. * Tokenize PHP source code.
* *

View File

@ -5,8 +5,6 @@
* @package WooCommerce/Testing * @package WooCommerce/Testing
*/ */
// phpcs:disable Squiz.Commenting.FunctionComment.Missing
namespace Automattic\WooCommerce\Testing\Tools\CodeHacking\Hacks; namespace Automattic\WooCommerce\Testing\Tools\CodeHacking\Hacks;
use ReflectionMethod; use ReflectionMethod;
@ -15,15 +13,28 @@ use ReflectionClass;
/** /**
* Hack to mock standalone functions. * Hack to mock standalone functions.
* *
* Usage: * How to use:
* *
* 1. Create a mock class that contains public static methods with the same * 1. Invoke 'FunctionsMockerHack::initialize' once, passing an array with the names of the functions
* names and signatures as the functions you want to mock. * that can be mocked.
* *
* 2. Pass a 'new FunctionsMockerHack(mock_class_name)' to CodeHacker. * 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 { final class FunctionsMockerHack extends CodeHack {
/** /**
* Tokens that precede a non-standalone-function identifier. * Tokens that precede a non-standalone-function identifier.
* *
@ -34,28 +45,46 @@ final class FunctionsMockerHack extends CodeHack {
T_DOUBLE_COLON, T_DOUBLE_COLON,
T_OBJECT_OPERATOR, T_OBJECT_OPERATOR,
T_FUNCTION, 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. * FunctionsMockerHack constructor.
* *
* @param string $mock_class Name of the class containing function mocks as public static methods. * @param array $mockable_functions An array containing the names of the functions that will become mockable.
* @throws ReflectionException Error when instantiating ReflectionClass.
*/ */
public function __construct( $mock_class ) { private function __construct( $mockable_functions ) {
$this->mock_class = $mock_class; $this->mockable_functions = $mockable_functions;
$rc = new ReflectionClass( $mock_class );
$static_methods = $rc->getMethods( ReflectionMethod::IS_PUBLIC | ReflectionMethod::IS_STATIC );
$this->mocked_methods = array_map(
function( $item ) {
return $item->getName();
},
$static_methods
);
} }
/**
* 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 ) { public function hack( $code, $path ) {
$tokens = $this->tokenize( $code ); $tokens = $this->tokenize( $code );
$code = ''; $code = '';
@ -65,8 +94,9 @@ final class FunctionsMockerHack extends CodeHack {
$token_type = $this->token_type_of( $token ); $token_type = $this->token_type_of( $token );
if ( T_WHITESPACE === $token_type ) { if ( T_WHITESPACE === $token_type ) {
$code .= $this->token_to_string( $token ); $code .= $this->token_to_string( $token );
} elseif ( T_STRING === $token_type && ! $previous_token_is_non_global_function_qualifier && in_array( $token[1], $this->mocked_methods, true ) ) { } elseif ( T_STRING === $token_type && ! $previous_token_is_non_global_function_qualifier && in_array( $token[1], $this->mockable_functions, true ) ) {
$code .= "{$this->mock_class}::{$token[1]}"; $code .= __CLASS__ . "::{$token[1]}";
$previous_token_is_non_global_function_qualifier = false;
} else { } else {
$code .= $this->token_to_string( $token ); $code .= $this->token_to_string( $token );
$previous_token_is_non_global_function_qualifier = in_array( $token_type, self::$non_global_function_tokens, true ); $previous_token_is_non_global_function_qualifier = in_array( $token_type, self::$non_global_function_tokens, true );
@ -75,6 +105,76 @@ final class FunctionsMockerHack extends CodeHack {
return $code; return $code;
} }
}
// phpcs:enable Squiz.Commenting.FunctionComment.Missing /**
* @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;
}
}

View File

@ -5,108 +5,189 @@
* @package WooCommerce/Testing * @package WooCommerce/Testing
*/ */
// phpcs:disable Squiz.Commenting.FunctionComment.Missing
namespace Automattic\WooCommerce\Testing\Tools\CodeHacking\Hacks; namespace Automattic\WooCommerce\Testing\Tools\CodeHacking\Hacks;
use ReflectionMethod;
use ReflectionClass;
/** /**
* Hack to mock public static methods and properties. * Hack to mock public static methods and properties.
* *
* How to use: * How to use:
* *
* 1. Create a mock class that contains public static methods and properties with the same * 1. Invoke 'StaticMockerHack::initialize' once, passing an array with the names of the classes
* names and signatures as the ones in the class you want to mock. * that can be mocked.
* *
* 2. Pass a 'new StaticMockerHack(original_class_name, mock_class_name)' to CodeHacker. * 2. Invoke 'CodeHacker::add_hack(StaticMockerHack::get_hack_instance())' once.
* *
* Invocations of public static members from the original class that exist in the mock class * 3. Use 'add_method_mocks' in tests as needed to register callbacks to be executed instead of the functions, e.g.:
* will be replaced with invocations of the same members for the mock class.
* Invocations of members not existing in the mock class will be left unmodified.
* *
* If the mock class defines a __callStatic method you can pass the $replace_always constructor argument * StaticMockerHack::add_method_mocks(
* as true. * [
* 'SomeClass' => [
* 'some_method' => function($some_arg) {
* return 'foo' === $some_arg ? 'bar' : SomeClass::some_method($some_arg);
* }
* ]
* ]);
*
* 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 StaticMockerHack extends CodeHack { final class StaticMockerHack extends CodeHack {
// phpcs:ignore Squiz.Commenting.VariableComment.Missing /**
private $replace_always = false; * @var StaticMockerHack Holds the only existing instance of the class.
*/
private static $instance;
/**
* Initializes the class.
*
* @param array $mockable_classes An associative array of class name => array of class methods.
*
* @throws \Exception $mockable_functions is not an array or is empty.
*/
public static function initialize( $mockable_classes ) {
if ( ! is_array( $mockable_classes ) || empty( $mockable_classes ) ) {
throw new \Exception( 'StaticMockerHack::initialize:: $mockable_classes must be a non-empty associative array of class name => array of class methods.' );
}
self::$instance = new StaticMockerHack( $mockable_classes );
}
/** /**
* StaticMockerHack constructor. * StaticMockerHack constructor.
* *
* @param string $source_class Name of the original class (the one having the members to be mocked). * @param array $mockable_classes An associative array of class name => array of class methods.
* @param string $mock_class Name of the mock class (the one having the replacement mock members).
* @param bool $replace_always If true, all method invocations will be always be redirected to the mock class.
* @throws ReflectionException Error when instantiating ReflectionClass.
*/ */
public function __construct( $source_class, $mock_class, $replace_always = false ) { private function __construct( $mockable_classes ) {
$this->source_class = $source_class; $this->mockable_classes = $mockable_classes;
$this->target_class = $mock_class;
if ( $replace_always ) {
$this->replace_always = true;
return;
}
$rc = new ReflectionClass( $mock_class );
$static_methods = $rc->getMethods( ReflectionMethod::IS_PUBLIC | ReflectionMethod::IS_STATIC );
$static_methods = array_map(
function( $item ) {
return $item->getName();
},
$static_methods
);
$static_properties = $rc->getProperties( ReflectionMethod::IS_PUBLIC | ReflectionMethod::IS_STATIC );
$static_properties = array_map(
function( $item ) {
return '$' . $item->getName();
},
$static_properties
);
$this->members_implemented_in_mock = array_merge( $static_methods, $static_properties );
} }
/**
* Hacks code by replacing elegible method invocations with an invocation a static method on this class composed from the class and the method names.
*
* @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 ) { public function hack( $code, $path ) {
$last_item = null; $last_item = null;
if ( stripos( $code, $this->source_class . '::' ) !== false ) { $tokens = $this->tokenize( $code );
$tokens = $this->tokenize( $code ); $code = '';
$code = ''; $current_token = null;
$current_token = null;
// phpcs:ignore WordPress.CodeAnalysis.AssignmentInCondition.FoundInWhileCondition // phpcs:ignore WordPress.CodeAnalysis.AssignmentInCondition.FoundInWhileCondition
while ( $current_token = current( $tokens ) ) { while ( $current_token = current( $tokens ) ) {
if ( $this->is_token_of_type( $current_token, T_STRING ) && $this->source_class === $current_token[1] ) { if ( $this->is_token_of_type( $current_token, T_STRING ) && in_array( $current_token[1], $this->mockable_classes, true ) ) {
$next_token = next( $tokens ); $class_name = $current_token[1];
if ( $this->is_token_of_type( $next_token, T_DOUBLE_COLON ) ) { $next_token = next( $tokens );
$called_member = next( $tokens )[1]; if ( $this->is_token_of_type( $next_token, T_DOUBLE_COLON ) ) {
if ( $this->replace_always || in_array( $called_member, $this->members_implemented_in_mock, true ) ) { $called_member = next( $tokens )[1];
// Reference to source class member that exists in mock class, or replace always requested: replace. $code .= __CLASS__ . "::invoke__{$called_member}__for__{$class_name}";
$code .= "{$this->target_class}::{$called_member}";
} else {
// Reference to source class member that does NOT exists in mock class, leave unchanged.
$code .= "{$this->source_class}::{$called_member}";
}
} else {
// Reference to source class, but not followed by '::'.
$code .= $this->token_to_string( $current_token ) . $this->token_to_string( $next_token );
}
} else { } else {
// Not a reference to source class. // Reference to source class, but not followed by '::'.
$code .= $this->token_to_string( $current_token ); $code .= $this->token_to_string( $current_token ) . $this->token_to_string( $next_token );
} }
next( $tokens ); } else {
// Not a reference to source class.
$code .= $this->token_to_string( $current_token );
} }
next( $tokens );
} }
return $code; return $code;
} }
}
// phpcs:enable Squiz.Commenting.FunctionComment.Missing /**
* @var array Associative array of class name => associative array of method name => callback.
*/
private $method_mocks = array();
/**
* Register method mocks.
*
* @param array $mocks Mocks as an associative array of class name => associative array of method name => mock method with the same arguments as the original method.
*
* @throws \Exception Invalid input.
*/
public function register_method_mocks( $mocks ) {
$exception_text = 'StaticMockerHack::register_method_mocks: $mocks must be an associative array of class name => associative array of method name => callable.';
if ( ! is_array( $mocks ) ) {
throw new \Exception( $exception_text );
}
foreach ( $mocks as $class_name => $class_mocks ) {
if ( ! is_string( $class_name ) || ! is_array( $class_mocks ) ) {
throw new \Exception( $exception_text );
}
foreach ( $class_mocks as $method_name => $method_mock ) {
if ( ! is_string( $method_name ) || ! is_callable( $method_mock ) ) {
throw new \Exception( $exception_text );
}
if ( ! in_array( $class_name, $this->mockable_classes, true ) ) {
throw new \Exception( "FunctionsMockerHack::add_function_mocks: Can't mock methods of the '$class_name' class since it isn't in the list of mockable classes supplied to 'initialize'." );
}
}
}
$this->method_mocks = array_merge_recursive( $this->method_mocks, $mocks );
}
/**
* Register method mocks.
*
* @param array $mocks Mocks as an associative array of class name => associative array of method name => mock method with the same arguments as the original method.
*
* @throws \Exception Invalid input.
*/
public static function add_method_mocks( $mocks ) {
self::$instance->register_method_mocks( $mocks );
}
/**
* Unregister all the registered method mocks.
*/
public function reset() {
$this->method_mocks = array();
}
/**
* Handler for undefined static methods on this class, it invokes the mock for the method if both the class and the method are registered, or the original method in the original class if not.
*
* @param string $name Name of the method.
* @param array $arguments Arguments for the function.
*
* @return mixed The return value from the invoked callback or method.
*
* @throws \Exception Invalid method name.
*/
public static function __callStatic( $name, $arguments ) {
preg_match( '/invoke__(.+)__for__(.+)/', $name, $matches );
if ( empty( $matches ) ) {
throw new \Exception( 'Invalid method ' . __CLASS__ . "::{$name}" );
}
$class_name = $matches[2];
$method_name = $matches[1];
if ( array_key_exists( $class_name, self::$instance->method_mocks ) && array_key_exists( $method_name, self::$instance->method_mocks[ $class_name ] ) ) {
return call_user_func_array( self::$instance->method_mocks[ $class_name ][ $method_name ], $arguments );
} else {
return call_user_func_array( "{$class_name}::{$method_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 StaticMockerHack The only existing instance of this class.
*/
public static function get_hack_instance() {
return self::$instance;
}
}

View File

@ -1,170 +1,149 @@
# Code Hacking # Code Hacking
Code hacking is a mechanism that allows to temporarily modifying PHP code files while they are loaded. It's intended to ease unit testing code that would otherwise be very difficult or impossible to test (and **only** for this - see [An important note](#an-important-note) about that). Code hacking is a mechanism that modifies PHP code files while they are loaded. It's intended to ease unit testing code that would otherwise be very difficult or impossible to test (and **only** for this - see [An important note](#an-important-note) about that).
The code hacker consists of the following classes: Currently, the code hacker allows to do the following inside unit tests:
* `CodeHacker`: the core class that performs the hacking and has some public configuration and activation methods. * Replace standalone functions with custom callbacks.
* `CodeHackerTestHook`: a PHPUnit hook class that wires everything so that the code hacking mechanism can be used within unit tests. * Replace invocations to public static methods with custom callbacks.
* Hack classes inside the `Hack` folders: some predefined frequently used hacks. * Create subclasses of `final` classes.
## How to use ## How to use
Let's go through an example. Let's go through an example.
First, create a file named `class-wc-admin-hello-worlder` in `includes/admin/class-wc-admin-hello-worlder.php` with the following code: First, create a file named `class-wc-admin-foobar.php` in `includes/admin` with the following code:
``` ```
<?php <?php
class WC_Admin_Hello_Worlder { class WC_Admin_Foobar {
public function do_something_that_depends_on_an_option() {
return 'The option returns: ' . get_option('some_option', 'default option value');
}
public function say_hello( $to_who ) { public function do_something_that_depends_on_the_legacy_service( $what ) {
Logger::log_hello( $to_who ); return 'The legacy service returns: ' . WC_Some_Legacy_Service::do_something( $what );
$site_name = get_option( 'site_name' ); }
return "Hello {$to_who}, welcome to {$site_name}!"; }
class WC_Some_Legacy_Service {
public static function do_something( $what ) {
return "The legacy service does something with: " . $what;
} }
} }
``` ```
This class has two bits that are difficult to unit test: the call to `Logger::log_hello` and the `get_option` invocation. Let's see how the code hacker can help us with that. This class has two bits that are difficult to unit test: the call to `WC_Some_Legacy_Service::do_something` (let's assume that we can't refactor that one) and the `get_option` invocation. Let's see how the code hacker can help us with that.
Create a file named `class-wc-tests-admin-hello-worlder.php` in `tests/unit-tests/admin` with this code: Now, modify `tests/legacy/mockable-functions.php` so that the returned array contains `'get_option'` (if it doesn't already), and modify `tests/legacy/classes-with-mockable-static-methods.php` so that the returned array contains `WC_Some_Legacy_Service`.
Create a file named `class-wc-tests-admin-foobar.php` in `tests/unit-tests/admin` with this code:
``` ```
<?php <?php
use Automattic\WooCommerce\Testing\CodeHacking\CodeHacker; use Automattic\WooCommerce\Testing\Tools\CodeHacking\Hacks\FunctionsMockerHack;
use Automattic\WooCommerce\Testing\CodeHacking\Hacks\StaticMockerHack; use Automattic\WooCommerce\Testing\Tools\CodeHacking\Hacks\StaticMockerHack;
use Automattic\WooCommerce\Testing\CodeHacking\Hacks\FunctionsMockerHack;
class LoggerMock { class WC_Tests_Admin_Foobar extends WC_Unit_Test_Case {
public function test_functions_mocking() {
$tested = new WC_Admin_Foobar();
public static $_logged = null; FunctionsMockerHack::add_function_mocks([
'get_option' => function( $name, $default = false ) {
return "Mocked get_option invoked for '$name'";
}
]);
public static function log_hello( $to_who ) { $expected = "The option returns: Mocked get_option invoked for 'some_option'";
self::$_logged = $to_who; $actual = $tested->do_something_that_depends_on_an_option();
} $this->assertEquals( $expected, $actual );
}
class FunctionsMock {
public static $_option_requested = null;
public static $_option_value = null;
public static function get_option( $option, $default = false ) {
self::$_option_requested = $option;
return self::$_option_value;
}
}
class WC_Tests_Admin_Hello_Worlder extends WC_Unit_Test_Case {
public static function before_test_say_hello() {
CodeHacker::add_hack( new StaticMockerHack( 'Logger', 'LoggerMock' ) );
CodeHacker::add_hack( new FunctionsMockerHack( 'FunctionsMock' ) );
CodeHacker::enable();
} }
public function test_say_hello() { public function test_static_method_mocking() {
FunctionsMock::$_option_value = 'MSX world'; $tested = new WC_Admin_Foobar();
$sut = new WC_Admin_Hello_Worlder(); StaticMockerHack::add_method_mocks([
$actual = $sut->say_hello( 'Nestor' ); 'WC_Some_Legacy_Service' => [
'do_something' => function( $what ) {
return "MOCKED do_something invoked for '$what'";
}
]
]);
$this->assertEquals( 'Hello Nestor, welcome to MSX world!', $actual ); $expected = "The legacy service returns: MOCKED do_something invoked for 'foobar'";
$this->assertEquals( 'site_name', FunctionsMock::$_option_requested ); $actual = $tested->do_something_that_depends_on_the_legacy_service( 'foobar' );
$this->assertEquals( 'Nestor', LoggerMock::$_logged ); $this->assertEquals( $expected, $actual );
} }
} }
``` ```
Then run `vendor/bin/phpunit tests/unit-tests/admin/class-wc-tests-admin-hello-worlder.php` and see the magic happen. Note that this works because `CodeHackerTestHook` has been registered in `phpunit.xml`. Then run `vendor/bin/phpunit tests/legacy/unit-tests/admin/class-wc-tests-admin-foobar.php` and see the magic happen.
As you can see, the basic mechanism consists of creating a `public static before_[test_method_name]` method in the test class, where you register whatever hacks you need with `CodeHacker::add_hack` and finally you invoke `CodeHacker::enable()` to enable the hacking. `StaticMockerHack` and `FunctionsMockerHack` are two of the predefined hack classes inside the `Hack` folder, see their source code for details on how they work and how to use them. ### Mocking functions
You can define a `before_all` method too, which will run before all the tests (and before the `before_[test_method_name]` method). For a function to be mockable its name needs to be included in the array returned by `tests/legacy/mockable-functions.php`, so if you need to mock a function that is not included in the array, just go and add it.
You might be asking why special `before_` are required if we already have PHPUnit's `setUp` method. The answer is: by the time `setUp` runs the code files to test are already loaded, so there's no way to hack them. Function mocks can be defined by using `FunctionsMockerHack::add_function_mocks`. This method accepts an associative array where keys are function names and values are callbacks with the same signature as the functions they are replacing.
Alternatively, hacks can be defined using class and method annotations as well (with the added bonus that you don't need the `use` statements anymore): If you ever need to remove the configured function mocks from inside a test, you can do so by executing `FunctionsMockerHack::get_hack_instance()->reset()`. This is done automatically before each test via PHPUnit's `BeforeTestHook`, so normally you won't need to do that.
Note that the code hacker is configured so that only the production code files are modified, the tests code itself is **not** modified. This means that you can use the original functions within your tests even if you have mocked them, for example the following would work:
``` ```
/** //Mock get_option but only if the requested option name is 'foo'
* @hack StaticMocker Logger LoggerMock FunctionsMockerHack::add_function_mocks([
*/ 'get_option' => function($name, $default = false) {
class WC_Tests_Admin_Hello_Worlder extends WC_Unit_Test_Case { return 'foo' === $name ? 'mocked value for option foo' : get_option( $name, $default );
}
/** ]);
* @hack FunctionsMocker FunctionsMock
* @hack BypassFinals
*/
public function test_say_hello() {
...
}
}
``` ```
The syntax is `@hack HackClassName [hack class constructor parameters]`. Important bits: ### Mocking public static methods
* Specify constructor parameters after the class name. If a parameter has a space, enclose it in quotation marks, `""`. For a public static method to be mockable the name of the class that defines it needs to be included in the array returned by `tests/legacy/classes-with-mockable-static-methods.php`, so if you need to mock a static method for a class that is not included in the array, just go and add it.
* If the hack class is inside the `Automattic\WooCommerce\Testing\CodeHacking\Hacks` namespace you don't need to specify the namespace.
* If the hack class name has the `Hack` suffix you can omit the suffix (e.g. `@hack FunctionsMocker` for the `FunctionsMockerHack` class).
* If the annotation is applied to the test class definition the hack will be applied to all the tests within the class.
* You don't need to `CodeHacker::enable()` when using `@hack`, this will be done for you.
## Creating new hacks Static method mocks can be defined by using `StaticMockerHack::add_method_mocks`. This method accepts an associative array where keys are class names and values are in turn associative arrays, those having method names as keys and callbacks with the same signature as the methods they are replacing as values.
New hacks can be created and used the same way as the predefined ones. A hack is defined as one of these: If you ever need to remove the configured static method mocks from inside a test, you can do so by executing `StaticMockerHack::get_hack_instance()->reset()`. This is done automatically before each test via PHPUnit's `BeforeTestHook`, so normally you won't need to do that.
* A function with the signature `hack($code, $path)`. Note that the code hacker is configured so that only the production code files are modified, the tests code itself is **not** modified. This means that you can use the original static methods within your tests even if you have mocked them, for example the following would work:
* An object containing a `public function hack($code, $path)`.
The `hack` function/method receives a string with the entire contents of the code file in `$code`, and the full path of the code file in `$path`. It must return a string with the hacked file contents (or, if no hacking is required, the unmodified value of `$code`). ```
StaticMockerHack::add_method_mocks([
'WC_Some_Legacy_Service' => [
//Mock WC_Some_Legacy_Service::do_something but only if the supplied parameter is 'foo'
'do_something' => function( $what ) {
return 'foo' === $what ? "MOCKED do_something invoked for '$what'" : WC_Some_Legacy_Service::do_something( $what );
}
]
]);
```
There's a `CodeHack` abstract class inside the `Hacks` directory that can be useful to develop new hacks, but that's provided just for convenience and it's not mandatory to use it (any class with a proper `hack` method will work). ### Subclassing `final` classes
Inside your test files you can create classes that extend classes marked as `final` thanks to the `BypassFinalsHack` that is registered at bootstrap time. No extra configuration is needed.
If you want to try it out, mark the `WC_Admin_Foobar` in the previos example as `final`, then add the following to the tests file: `class WC_Admin_Foobar_Subclass extends WC_Admin_Foobar {}`. Without the hack you would get a `Class WC_Admin_Foobar_Subclass may not inherit from final class (WC_Admin_Foobar)` error when trying to run the tests.
## How it works under the hood ## How it works under the hood
The Code Hacker is based on [the Bypass Finals project](https://github.com/dg/bypass-finals) by David Grudl. The core of the code hacker is the `CodeHacker` class, which is based on [the Bypass Finals project](https://github.com/dg/bypass-finals) by David Grudl. This class is actually [a streamWrapper class](https://www.php.net/manual/en/class.streamwrapper.php) for the regular filesystem, most of its methods are just short-circuited to the regular PHP filesystem handling functions but the `stream_open` method contains some code that allows the magic to happen. What it does (for PHP files only) is to read the file contents and apply all the necessary modifications to it, then if the code has been modified it is stored in a temporary file which is then the one that receives any further filesystem operations instead of the original file. That way, for all practical purposes the content of the file is the "hacked" content.
The `CodeHacker` class is actually [a streamWrapper class](https://www.php.net/manual/en/class.streamwrapper.php) for the regular filesystem. Most of its methods are just short-circuited to the regular PHP filesystem handling functions, but the `stream_open` method contains some code that allows the magic to happen. What it does (for PHP files only) is to read the file contents and apply all the hacks to it, then if the code has been modified it is stored in a temporary file which is then the one that receives any further filesystem operations instead of the original file. That way, for all practical purposes the content of the file is the "hacked" content. The files inside `tests/Tools/CodeHacking/Hacks` implement the "hacks" (code file modifications) that are registered via `CodeHacker::add_hack` within `tests/legacy/bootstrap.php`.
The `CodeHackerTestHook` then uses reflection to find `before_` methods and `@hack` anotations, putting everything in place right before the tests are executed. A `BeforeTestHook` is used to reset all hacks to its initial state to ensure that no functions or methods are being mocked when the test starts.
## Workaround for static mocking of already loaded code: the `StaticWrapper` The functions mocker works by replacing all instances of `the_function(...)` with `FunctionsMockerHack::the_function(...)`, then `FunctionsMockerHack::__call_static` is implemented in a way that invokes the appropriate callback if defined for the invoked function, or reverts to executing the original function if not. The static methods mocker works similarly, but replacing instances of `TheClass::the_method(...)` with `StaticMockerHack::invoke__the_method__for__TheClass(...)`.
Before the test hooks that configure the code hacker run there's already a significant amount of code files already loaded, as a result of WooCommerce being loaded and initialized within the unit tests bootstrap files. These code file can't be hacked using the described approach, and therefore require to _hack the hack_. ## Creating new hacks
A workaround for a particular case is provided. If you need to register a `StaticMockerHack` for a class that has been already loaded (you will notice because registering the hack the usual way does nothing), do the following instead: If you ever need to define a new hack to cover a different kind of code that's difficult to test, that's what you need to do.
1. Add the class name (NOT the file name) to the array returned by `tests/classes-that-need-static-wrapper.php`. First, implement the hack as a class that contains a `public function hack($code, $path)` method and a `public function reset()` method. The former takes in `$code` a string with the contents of the file pointed by `$path` and returns the modified code, the later reverts the hack to its original state (e.g. for `FunctionsMockerHack` it unregisters all the previously registered function mocks). For convenience you can make your hack a subclass of `CodeHack` but that's not mandatory.
2. Configure the mock using `StaticWrapper::set_mock_class_for`. Second, configure the hack as required inside the `initialize_code_hacker` method in `tests/legacy/bootstrap.php`, and register it using `CodeHacker::add_hack`.
```
class WC_Tests_Admin_Hello_Worlder extends WC_Unit_Test_Case {
public function test_say_hello() {
FunctionsMock::$_option_value = 'MSX world';
StaticWrapper::set_mock_class_for('Logger', 'LoggerMock');
$sut = new WC_Admin_Hello_Worlder();
$actual = $sut->say_hello( 'Nestor' );
$this->assertEquals( 'Hello Nestor, welcome to MSX world!', $actual );
$this->assertEquals( 'site_name', FunctionsMock::$_option_requested );
$this->assertEquals( 'Nestor', LoggerMock::$_logged );
}
}
```
Note that in this case you don't explicitly interact with the code hacker, neither directly nor by using `@hack` annotations.
Under the hood this is hacking all the classes in the list with a "clean" `StaticWrapper`-derived class; here "clean" means that all static methods are redirected to the original class via `__callStatic`. This hacking happens at the beginning of the bootstrapping process, when nothing from WooCommerce has been loaded yet. Later on `StaticWrapper::set_mock_class_for` can be used at any time to selectively mock any static method in the class.
Alternatively, you can configure mock functions instead of a mock class. See the source of `StaticWrapper` for more details.
## Temporarily disabling the code hacker ## Temporarily disabling the code hacker

View File

@ -1,156 +0,0 @@
<?php
/**
* StaticWrapper class file.
*
* @package WooCommerce/Testing
*/
namespace Automattic\WooCommerce\Testing\Tools\CodeHacking;
use ReflectionClass;
use ReflectionMethod;
/**
* This is the base class for defining a dynamically configurable wrapper for static methods in a class.
*
* How to use:
*
* 1. Define a wrapper for the class and register it as a code hack using StaticMockerHack:
*
* $wrapper_class = StaticWrapper::define_for('MyClass');
* CodeHacker::add_hack(new StaticMockerHack($class, $wrapper_class, true));
* CodeHacker::enable();
*
* 2. Define a class with the mock methods, and register it with set_mock_class_for:
*
* class MyClass_Mock {
* public static function method_to_mock() { ... }
* }
*
* StaticWrapper::set_mock_class_for('MyClass', 'MyClass_Mock');
*
* 3. Or alternatively, define the mocked methods as standalone functions, and register them with set_mock_functions_for:
*
* $functions = array(
* 'method_to_mock' => function(...);
* );
*
* StaticWrapper::set_mock_functions_for('MyClass', $functions);
*
* You can use 2 and 3 at the same time, functions have precedence over mock class methods in case of conflict.
*
* @package Automattic\WooCommerce\Testing\Tools\CodeHacking
*/
abstract class StaticWrapper {
// phpcs:disable Squiz.Commenting.VariableComment.Missing
protected static $mock_class_name = null;
protected static $methods_in_mock_class = array();
protected static $mocking_functions = array();
private static $wrapper_suffix = '_Wrapper';
// phpcs:enable Squiz.Commenting.VariableComment.Missing
/**
* Define a new class that inherits from StaticWrapper, to be used as a static wrapper for a given class.
*
* @param string $class_name Class for which the static wrapper will be generated.
*
* @return string Name of the generated class.
*/
public static function define_for( $class_name ) {
$wrapper_name = self::wrapper_for( $class_name );
// phpcs:ignore Squiz.PHP.Eval.Discouraged
eval( 'class ' . $wrapper_name . ' extends ' . __CLASS__ . ' {}' );
return $wrapper_name;
}
/**
* Returns the name of the wrapper class that define_for will create for a given class.
*
* @param string $class_name Class to return the wrapper name for.
*
* @return string Name of the class that define_for defines.
*/
public static function wrapper_for( $class_name ) {
return $class_name . self::$wrapper_suffix;
}
/**
* Registers the class that will be used to mock the original class.
* Static methods called in the wrapper class that also exist in the mock class will be redirected
* to the mock class.
*
* @param string $mock_class_name Mock class whose methods will be invoked.
*
* @throws \ReflectionException Error when creating ReflectionClass or ReflectionMethod.
*/
public static function set_mock_class( $mock_class_name ) {
self::$mock_class_name = $mock_class_name;
$rc = new ReflectionClass( $mock_class_name );
$static_methods = $rc->getMethods( ReflectionMethod::IS_PUBLIC | ReflectionMethod::IS_STATIC );
$static_methods = array_map(
function( $item ) {
return $item->getName();
},
$static_methods
);
self::$methods_in_mock_class = $static_methods;
}
/**
* This method is the same as set_mock_class, but it's intended to be invoked directly on StaticWrapper,
* passing the original class name as a parameter.
*
* @param string $class_name Class name whose set_mock_class method will be invoked.
* @param string $mock_class_name Parameter that will be passed to set_mock_class method in $class_name.
*/
public static function set_mock_class_for( $class_name, $mock_class_name ) {
( self::wrapper_for( $class_name ) )::set_mock_class( $mock_class_name );
}
/**
* Registers an array of functions that will be used to mock the original class. It must be an associative
* array of name => function. Static methods called in the wrapper class having a corresponding key in the array
* will be redirected to the corresponding function.
*
* @param array $mocking_functions Associative array where keys are method names and values are functions.
*/
public static function set_mock_functions( $mocking_functions ) {
self::$mocking_functions = $mocking_functions;
}
/**
* This method is the same as set_mock_functions, but it's intended to be invoked directly on StaticWrapper,
* passing the original class name as a parameter.
*
* @param string $class_name Class name whose set_mock_functions method will be invoked.
* @param array $mocking_functions Parameter that will be passed to set_mock_functions method in $class_name.
*/
public static function set_mock_functions_for( $class_name, $mocking_functions ) {
( self::wrapper_for( $class_name ) )::set_mock_functions( $mocking_functions );
}
/**
* Intercepts calls to undefined static methods in the wrapper and redirects them to the mock class/function
* if available, or to the original class otherwise.
*
* @param string $name Method name.
* @param array $arguments Method arguments.
*
* @return mixed Return value from the invoked method.
*/
public static function __callStatic( $name, $arguments ) {
if ( array_key_exists( $name, self::$mocking_functions ) ) {
return call_user_func( self::$mocking_functions[ $name ], ...$arguments );
} elseif ( ! is_null( self::$mock_class_name ) && in_array( $name, self::$methods_in_mock_class, true ) ) {
return ( self::$mock_class_name )::$name( ...$arguments );
} else {
$original_class_name = preg_replace( '/' . self::$wrapper_suffix . '$/', '', get_called_class() );
return $original_class_name::$name( ...$arguments );
}
}
}

View File

@ -7,8 +7,9 @@
*/ */
use Automattic\WooCommerce\Testing\Tools\CodeHacking\CodeHacker; use Automattic\WooCommerce\Testing\Tools\CodeHacking\CodeHacker;
use Automattic\WooCommerce\Testing\Tools\CodeHacking\StaticWrapper;
use Automattic\WooCommerce\Testing\Tools\CodeHacking\Hacks\StaticMockerHack; use Automattic\WooCommerce\Testing\Tools\CodeHacking\Hacks\StaticMockerHack;
use Automattic\WooCommerce\Testing\Tools\CodeHacking\Hacks\FunctionsMockerHack;
use Automattic\WooCommerce\Testing\Tools\CodeHacking\Hacks\BypassFinalsHack;
use Composer\Autoload\ClassLoader; use Composer\Autoload\ClassLoader;
/** /**
@ -34,27 +35,14 @@ class WC_Unit_Tests_Bootstrap {
* @since 2.2 * @since 2.2
*/ */
public function __construct() { public function __construct() {
//phpcs:disable WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase
$classLoader = new ClassLoader(); $classLoader = new ClassLoader();
$classLoader->addPsr4("Automattic\\WooCommerce\\Testing\\Tools\\", __DIR__ . '/../Tools', false); $classLoader->addPsr4( 'Automattic\\WooCommerce\\Testing\\Tools\\', __DIR__ . '/../Tools', false );
$classLoader->register(); $classLoader->register();
//phpcs:enable WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase
//Includes needed to initialize the static wrapper $this->tests_dir = dirname( __FILE__ );
$this->tests_dir = dirname( __FILE__ ); $this->initialize_code_hacker();
$this->plugin_dir = dirname( dirname( $this->tests_dir ) );
$hacking_base = $this->plugin_dir . '/tests/Tools/CodeHacking';
require_once $hacking_base . '/StaticWrapper.php';
require_once $hacking_base . '/CodeHacker.php';
require_once $hacking_base . '/Hacks/CodeHack.php';
require_once $hacking_base . '/Hacks/StaticMockerHack.php';
// Define a static wrapper for all the classes that need it.
$classes_that_need_static_wrapper = include_once __DIR__ . '/classes-that-need-static-wrapper.php';
foreach ( $classes_that_need_static_wrapper as $class ) {
$wrapper_class = StaticWrapper::define_for( $class );
CodeHacker::add_hack( new StaticMockerHack( $class, $wrapper_class, true ), true );
}
CodeHacker::enable();
ini_set( 'display_errors', 'on' ); // phpcs:ignore WordPress.PHP.IniSet.display_errors_Blacklisted ini_set( 'display_errors', 'on' ); // phpcs:ignore WordPress.PHP.IniSet.display_errors_Blacklisted
error_reporting( E_ALL ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.prevent_path_disclosure_error_reporting, WordPress.PHP.DiscouragedPHPFunctions.runtime_configuration_error_reporting error_reporting( E_ALL ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.prevent_path_disclosure_error_reporting, WordPress.PHP.DiscouragedPHPFunctions.runtime_configuration_error_reporting
@ -84,6 +72,39 @@ class WC_Unit_Tests_Bootstrap {
$this->includes(); $this->includes();
} }
/**
* Initialize the code hacker.
*
* @throws Exception Error when initializing one of the hacks.
*/
private function initialize_code_hacker() {
$this->plugin_dir = dirname( dirname( $this->tests_dir ) );
$hacking_base = $this->plugin_dir . '/tests/Tools/CodeHacking';
require_once $hacking_base . '/CodeHacker.php';
require_once $hacking_base . '/Hacks/CodeHack.php';
require_once $hacking_base . '/Hacks/StaticMockerHack.php';
require_once $hacking_base . '/Hacks/FunctionsMockerHack.php';
require_once $hacking_base . '/Hacks/BypassFinalsHack.php';
CodeHacker::initialize( array( __DIR__ . '/../../includes/' ) );
$replaceable_functions = include_once __DIR__ . '/mockable-functions.php';
if ( ! empty( $replaceable_functions ) ) {
FunctionsMockerHack::initialize( $replaceable_functions );
CodeHacker::add_hack( FunctionsMockerHack::get_hack_instance() );
}
$mockable_static_classes = include_once __DIR__ . '/classes-with-mockable-static-methods.php';
if ( ! empty( $mockable_static_classes ) ) {
StaticMockerHack::initialize( $mockable_static_classes );
CodeHacker::add_hack( StaticMockerHack::get_hack_instance() );
}
CodeHacker::add_hack( new BypassFinalsHack() );
CodeHacker::enable();
}
/** /**
* Load WooCommerce. * Load WooCommerce.
* *

View File

@ -1,18 +0,0 @@
<?php
/**
* The code hacker won't work with hacks defined for files that are loaded during the WooCommerce initialization
* that is triggered in the unit testing bootstrap (files that are already loaded by the time the code hacker
* is enabled during test hooks aren't hacked).
*
* As a workaround, a StaticWrapper class is defined (and configured via StaticMockerHack) for each class having
* static methods that need to be replaced in tests but are used from code already loaded; this happens
* right before WooCommerce initialization.
*
* This file simply returns the list of files that require a StaticWrapper to be registered during bootstrap.
*
* @package WooCommerce Tests
*/
return array(
'WC_Admin_Settings',
);

View File

@ -0,0 +1,12 @@
<?php
/**
* This array contains the names of the classes whose static methods will become mockable via StaticMockerHack
* when running unit tests. If you need to mock a public static method of a class that isn't in the list,
* simply add it. Please keep it sorted alphabetically.
*
* @package WooCommerce Tests
*/
return array(
// 'WC_Admin_Settings'
);

View File

@ -0,0 +1,12 @@
<?php
/**
* This array contains the names of the standalone functions that will become mockable via FunctionsMockerHack
* when running unit tests. If you need to mock a function that isn't in the list, simply add it.
* Please keep it sorted alphabetically.
*
* @package WooCommerce Tests
*/
return array(
// 'get_option'
);