Merge pull request #26136 from woocommerce/feature/code-hacker-for-unit-tests

Code hacker for unit tests
This commit is contained in:
Claudio Sanches 2020-06-03 08:41:28 -03:00 committed by GitHub
commit 4922c34180
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 1340 additions and 8 deletions

View File

@ -45,7 +45,8 @@
},
"autoload-dev": {
"psr-4": {
"Automattic\\WooCommerce\\Tests\\": "tests/php/src"
"Automattic\\WooCommerce\\Tests\\": "tests/php/src",
"Automattic\\WooCommerce\\Testing\\Tools\\": "tests/Tools"
}
},
"scripts": {

View File

@ -59,5 +59,6 @@
<exclude-pattern>i18n/</exclude-pattern>
<exclude-pattern>src/</exclude-pattern>
<exclude-pattern>tests/php</exclude-pattern>
<exclude-pattern>tests/Tools/</exclude-pattern>
</rule>
</ruleset>

View File

@ -49,4 +49,7 @@
<listeners>
<listener class="SpeedTrapListener" file="tests/legacy/includes/listener-loader.php" />
</listeners>
<extensions>
<extension class="\Automattic\WooCommerce\Testing\Tools\CodeHacking\CodeHackerTestHook" />
</extensions>
</phpunit>

View File

@ -0,0 +1,511 @@
<?php
/**
* CodeHacker class file.
*
* @package WooCommerce/Testing
*/
//phpcs:disable WordPress.WP.AlternativeFunctions, WordPress.PHP.NoSilencedErrors.Discouraged
namespace Automattic\WooCommerce\Testing\Tools\CodeHacking;
use \ReflectionObject;
use \ReflectionException;
/**
* CodeHacker - allows to hack (alter on the fly) the content of PHP code files.
*
* Based on BypassFinals: https://github.com/dg/bypass-finals
*
* How to use:
*
* 1. Register hacks using CodeHacker::add_hack(hack). A hack is either:
* - A function with 'hack($code, $path)' signature, or
* - An object having a public 'hack($code, $path)' method.
*
* Where $code is a string containing the code to hack, and $path is the full path of the file
* containing the code. The function/method must return a string with the code already hacked.
*
* 2. Run CodeHacker::enable()
*
* For using with PHPUnit, see CodeHackerTestHook.
*/
final class CodeHacker {
const PROTOCOL = 'file';
const HACK_CALLBACK_ARGUMENT_COUNT = 2;
/**
* Value of "context" parameter to be passed to the native PHP filesystem related functions.
*
* @var mixed
*/
public $context;
/**
* File handle of the file that is open.
*
* @var mixed
*/
private $handle;
/**
* Optional white list of files to hack, if empty all the files will be hacked.
*
* @var array
*/
private static $paths_with_files_to_hack = array();
/**
* Registered hacks.
*
* @var array
*/
private static $hacks = array();
/**
* Is the code hacker enabled?.
*
* @var bool
*/
private static $enabled = false;
/**
* Enable the code hacker.
*/
public static function enable() {
if ( ! self::$enabled ) {
stream_wrapper_unregister( self::PROTOCOL );
stream_wrapper_register( self::PROTOCOL, __CLASS__ );
self::$enabled = true;
}
}
/**
* Disable the code hacker.
*/
public static function disable() {
if ( self::$enabled ) {
stream_wrapper_restore( self::PROTOCOL );
self::$enabled = false;
}
}
/**
* Check if the code hacker is enabled.
*
* @return bool True if the code hacker is enabled.
*/
public static function is_enabled() {
return self::$enabled;
}
/**
* Execute the 'reset()' method in all the registered hacks.
*/
public static function reset_hacks() {
foreach ( self::$hacks as $hack ) {
call_user_func( array( $hack, 'reset' ) );
}
}
/**
* Register a new hack.
*
* @param mixed $hack A function with signature "hack($code, $path)" or an object containing a method with that signature.
* @throws \Exception Invalid input.
*/
public static function add_hack( $hack ) {
if ( ! self::is_valid_hack_object( $hack ) ) {
$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." );
}
self::$hacks[] = $hack;
}
/**
* Check if the supplied argument is a valid hack object (has a public "hack" method with two mandatory arguments).
*
* @param mixed $callback Argument to check.
*
* @return bool rue if the argument is a valid hack object, false otherwise.
*/
private static function is_valid_hack_object( $callback ) {
if ( ! is_object( $callback ) ) {
return false;
}
$ro = new ReflectionObject( ( $callback ) );
try {
$rm = $ro->getMethod( 'hack' );
$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 ) {
return false;
}
}
/**
* Initialize the code hacker.
*
* @param array $paths Paths of the directories containing the files to hack.
* @throws \Exception Invalid input.
*/
public static function initialize( array $paths ) {
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
);
}
/**
* Close directory handle.
*/
public function dir_closedir() {
closedir( $this->handle );
}
/**
* Open directory handle.
*
* @param string $path Specifies the URL that was passed to opendir().
* @param int $options Whether or not to enforce safe_mode (0x04).
*
* @return bool TRUE on success or FALSE on failure.
*/
public function dir_opendir( $path, $options ) {
$this->handle = $this->context
? $this->native( 'opendir', $path, $this->context )
: $this->native( 'opendir', $path );
return (bool) $this->handle;
}
/**
* Read entry from directory handle.
*
* @return false|string string representing the next filename, or FALSE if there is no next file.
*/
public function dir_readdir() {
return readdir( $this->handle );
}
/**
* Rewind directory handle.
*
* @return TRUE on success or FALSE on failure.
*/
public function dir_rewinddir() {
return rewinddir( $this->handle );
}
/**
* Create a directory.
*
* @param string $path Directory which should be created.
* @param int $mode The value passed to mkdir().
* @param int $options A bitwise mask of values, such as STREAM_MKDIR_RECURSIVE.
*
* @return bool TRUE on success or FALSE on failure.
*/
public function mkdir( $path, $mode, $options ) {
$recursive = (bool) ( $options & STREAM_MKDIR_RECURSIVE );
return $this->native( 'mkdir', $path, $mode, $recursive, $this->context );
}
/**
* Renames a file or directory.
*
* @param string $path_from The URL to the current file.
* @param string $path_to The URL which the path_from should be renamed to.
*
* @return bool TRUE on success or FALSE on failure.
*/
public function rename( $path_from, $path_to ) {
return $this->native( 'rename', $path_from, $path_to, $this->context );
}
/**
* Removes a directory.
*
* @param string $path The directory URL which should be removed.
* @param int $options A bitwise mask of values, such as STREAM_MKDIR_RECURSIVE.
*
* @return bool TRUE on success or FALSE on failure.
*/
public function rmdir( $path, $options ) {
return $this->native( 'rmdir', $path, $this->context );
}
/**
* Retrieve the underlying resource.
*
* @param mixed $cast_as Can be STREAM_CAST_FOR_SELECT when stream_select() is calling stream_cast() or STREAM_CAST_AS_STREAM when stream_cast() is called for other uses.
*
* @return mixed The underlying stream resource used by the wrapper, or FALSE.
*/
public function stream_cast( $cast_as ) {
return $this->handle;
}
/**
* Close a resource.
*/
public function stream_close() {
fclose( $this->handle );
}
/**
* Tests for end-of-file on a file pointer.
*
* @return bool TRUE if the read/write position is at the end of the stream and if no more data is available to be read, or FALSE otherwise.
*/
public function stream_eof() {
return feof( $this->handle );
}
/**
* Flushes the output.
*
* @return bool TRUE if the cached data was successfully stored (or if there was no data to store), or FALSE if the data could not be stored.
*/
public function stream_flush() {
return fflush( $this->handle );
}
/**
* Advisory file locking.
*
* @param int $operation LOCK_SH, LOCK_EX, LOCK_UN, or LOCK_NB.
*
* @return bool TRUE on success or FALSE on failure.
*/
public function stream_lock( $operation ) {
return $operation
? flock( $this->handle, $operation )
: true;
}
/**
* Change stream metadata.
*
* @param string $path The file path or URL to set metadata. Note that in the case of a URL, it must be a :// delimited URL. Other URL forms are not supported.
* @param int $option STREAM_META_TOUCH, STREAM_META_OWNER_NAME, STREAM_META_OWNER, STREAM_META_GROUP_NAME, STREAM_META_GROUP, or STREAM_META_ACCESS.
* @param mixed $value Depends on $option.
*
* @return bool TRUE on success or FALSE on failure. If option is not implemented, FALSE should be returned.
*/
public function stream_metadata( $path, $option, $value ) {
switch ( $option ) {
case STREAM_META_TOUCH:
$value += array( null, null );
return $this->native( 'touch', $path, $value[0], $value[1] );
case STREAM_META_OWNER_NAME:
case STREAM_META_OWNER:
return $this->native( 'chown', $path, $value );
case STREAM_META_GROUP_NAME:
case STREAM_META_GROUP:
return $this->native( 'chgrp', $path, $value );
case STREAM_META_ACCESS:
return $this->native( 'chmod', $path, $value );
}
}
/**
* Opens file or URL. Note that this is where the hacking actually happens.
*
* @param string $path Specifies the URL that was passed to the original function.
* @param string $mode The mode used to open the file, as detailed for fopen().
* @param int $options Holds additional flags set by the streams API: STREAM_USE_PATH, STREAM_REPORT_ERRORS.
* @param string $opened_path If the path is opened successfully, and STREAM_USE_PATH is set in options, opened_path should be set to the full path of the file/resource that was actually opened.
*
* @return bool TRUE on success or FALSE on failure.
*/
public function stream_open( $path, $mode, $options, &$opened_path ) {
$use_path = (bool) ( $options & STREAM_USE_PATH );
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 );
if ( false === $content ) {
return false;
}
$modified = self::hack( $content, $path );
if ( $modified !== $content ) {
$this->handle = tmpfile();
$this->native( 'fwrite', $this->handle, $modified );
$this->native( 'fseek', $this->handle, 0 );
return true;
}
}
$this->handle = $this->context
? $this->native( 'fopen', $path, $mode, $use_path, $this->context )
: $this->native( 'fopen', $path, $mode, $use_path );
return (bool) $this->handle;
}
/**
* Read from stream.
*
* @param int $count How many bytes of data from the current position should be returned.
*
* @return false|string If there are less than count bytes available, return as many as are available. If no more data is available, return either FALSE or an empty string.
*/
public function stream_read( $count ) {
return fread( $this->handle, $count );
}
/**
* Seeks to specific location in a stream.
*
* @param int $offset The stream offset to seek to.
* @param int $whence SEEK_SET, SEEK_CUR, or SEEK_END.
*
* @return bool TRUE if the position was updated, FALSE otherwise.
*/
public function stream_seek( $offset, $whence = SEEK_SET ) {
return fseek( $this->handle, $offset, $whence ) === 0;
}
/**
* Change stream options.
*
* @param int $option STREAM_OPTION_BLOCKING, STREAM_OPTION_READ_TIMEOUT, or STREAM_OPTION_WRITE_BUFFER.
* @param int $arg1 Depends on $option.
* @param int $arg2 Depends on $option.
*/
public function stream_set_option( $option, $arg1, $arg2 ) {
}
/**
* Retrieve information about a file resource.
*
* @return array See stat().
*/
public function stream_stat() {
return fstat( $this->handle );
}
/**
* Retrieve the current position of a stream.
*
* @return false|int The current position of the stream.
*/
public function stream_tell() {
return ftell( $this->handle );
}
/**
* Truncate stream.
*
* @param int $new_size The new size.
*
* @return bool TRUE on success or FALSE on failure.
*/
public function stream_truncate( $new_size ) {
return ftruncate( $this->handle, $new_size );
}
/**
* Write to stream.
*
* @param string $data Should be stored into the underlying stream.
*
* @return false|int The number of bytes that were successfully stored, or 0 if none could be stored.
*/
public function stream_write( $data ) {
return fwrite( $this->handle, $data );
}
/**
* Delete a file.
*
* @param string $path The file URL which should be deleted.
*
* @return bool TRUE on success or FALSE on failure.
*/
public function unlink( $path ) {
return $this->native( 'unlink', $path );
}
/**
* Retrieve information about a file.
*
* @param string $path The file path or URL to stat. Note that in the case of a URL, it must be a :// delimited URL. Other URL forms are not supported.
* @param int $flags Holds additional flags set by the streams API. It can hold one or more of the following values OR'd together.
*
* @return mixed Should return as many elements as stat() does. Unknown or unavailable values should be set to a rational value (usually 0). Pay special attention to mode as documented under stat().
*/
public function url_stat( $path, $flags ) {
$func = $flags & STREAM_URL_STAT_LINK ? 'lstat' : 'stat';
return $flags & STREAM_URL_STAT_QUIET
? @$this->native( $func, $path )
: $this->native( $func, $path );
}
/**
* Executes a native PHP function.
*
* @param string $func Name of the function to execute. Pass the arguments for the PHP function after this one.
*
* @return mixed Return value from the native PHP function.
*/
private function native( $func ) {
stream_wrapper_restore( self::PROTOCOL );
$res = call_user_func_array( $func, array_slice( func_get_args(), 1 ) );
stream_wrapper_unregister( self::PROTOCOL );
stream_wrapper_register( self::PROTOCOL, __CLASS__ );
return $res;
}
/**
* Apply the reigstered hacks to the contents of a file.
*
* @param string $code Code content to hack.
* @param string $path Path of the file being hacked.
*
* @return string The code after applying all the registered hacks.
*/
private static function hack( $code, $path ) {
foreach ( self::$hacks as $hack ) {
if ( is_callable( $hack ) ) {
$code = call_user_func( $hack, $code, $path );
} else {
$code = $hack->hack( $code, $path );
}
}
return $code;
}
/**
* Check if a file path is in the white list.
*
* @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.
*
* @throws \Exception The class is not initialized.
*/
private static function path_in_list_of_paths_to_hack( $path ) {
if ( empty( self::$paths_with_files_to_hack ) ) {
throw new \Exception( "CodeHacker is not initialized, it must initialized by invoking 'initialize'" );
}
foreach ( self::$paths_with_files_to_hack as $white_list_item ) {
if ( substr( $path, 0, strlen( $white_list_item ) ) === $white_list_item ) {
return true;
}
}
return false;
}
}
//phpcs:enable WordPress.WP.AlternativeFunctions, WordPress.PHP.NoSilencedErrors.Discouraged

View File

@ -0,0 +1,32 @@
<?php
/**
* CodeHackerTestHook class file.
*
* @package WooCommerce/Testing
*/
namespace Automattic\WooCommerce\Testing\Tools\CodeHacking;
use PHPUnit\Runner\BeforeTestHook;
/**
* Helper to use the CodeHacker class in PHPUnit. To use, add this to phpunit.xml:
*
* <extensions>
* <extension class="CodeHackerTestHook" />
* </extensions>
*/
final class CodeHackerTestHook implements BeforeTestHook {
/**
* Runs before each test.
*
* @param string $test "TestClass::TestMethod".
*
* @throws \ReflectionException Thrown by execute_before_methods.
*/
public function executeBeforeTest( string $test ): void {
CodeHacker::reset_hacks();
}
}

View File

@ -0,0 +1,42 @@
<?php
/**
* BypassFinalsHack class file.
*
* @package WooCommerce/Testing
*/
namespace Automattic\WooCommerce\Testing\Tools\CodeHacking\Hacks;
/**
* Code hack to bypass finals.
*
* Removes all the "final" keywords from class definitions.
*/
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 ) {
if ( stripos( $code, 'final' ) !== false ) {
$tokens = $this->tokenize( $code );
$code = '';
foreach ( $tokens as $token ) {
$code .= $this->is_token_of_type( $token, T_FINAL ) ? '' : $this->token_to_string( $token );
}
}
return $code;
}
/**
* Revert the hack to its initial state - nothing to do since finals can't be reverted.
*/
public function reset() {
}
}

View File

@ -0,0 +1,94 @@
<?php
/**
* CodeHack class file.
*
* @package WooCommerce/Testing
*/
namespace Automattic\WooCommerce\Testing\Tools\CodeHacking\Hacks;
/**
* Base class to define Hacks for CodeHacker.
*
* This class is included for convenience only, any class having a 'public function hack($code, $path)'
* can be used as a hack class for CodeHacker.
*/
abstract class CodeHack {
/**
* The hack method to implement.
*
* @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.
*/
abstract public function hack( $code, $path);
/**
* Revert the hack to its initial state.
*/
abstract public function reset();
/**
* Tokenize PHP source code.
*
* @param string $code PHP code to tokenize.
* @return array Tokenized code.
* @throws \Exception PHP version is less than 7.0.
*/
protected function tokenize( $code ) {
if ( PHP_VERSION_ID < 70000 ) {
throw new \Exception( 'The code hacker can be used in PHP 7.0+ only.' );
}
return token_get_all( $code, TOKEN_PARSE );
}
/**
* Check if a token is of a given type.
*
* @param mixed $token Token to check.
* @param int $type Type of token to check (see https://www.php.net/manual/en/tokens.php).
* @return bool True if it's a token of the given type, false otherwise.
*/
protected function is_token_of_type( $token, $type ) {
return is_array( $token ) && $type === $token[0];
}
/**
* Return the type of a given token.
*
* @param mixed $token Token to check.
* @return mixed|null Type of token (see https://www.php.net/manual/en/tokens.php), or null if it's a character.
*/
protected function token_type_of( $token ) {
return is_array( $token ) ? $token[0] : null;
}
/**
* Converts a token to its string representation.
*
* @param mixed $token Token to convert.
* @return mixed String representation of the token.
*/
protected function token_to_string( $token ) {
return is_array( $token ) ? $token[1] : $token;
}
/**
* Checks if a string ends with a certain substring.
* This method is added to help processing path names within 'hack' methods needing to do so.
*
* @param string $haystack The string to search in.
* @param string $needle The substring to search for.
* @return bool True if the $haystack ends with $needle, false otherwise.
*/
protected function string_ends_with( $haystack, $needle ) {
$length = strlen( $needle );
if ( 0 === $length ) {
return true;
}
return ( substr( $haystack, -$length ) === $needle );
}
}

View File

@ -0,0 +1,180 @@
<?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;
}
}

View File

@ -0,0 +1,193 @@
<?php
/**
* StaticMockerHack class file.
*
* @package WooCommerce/Testing
*/
namespace Automattic\WooCommerce\Testing\Tools\CodeHacking\Hacks;
/**
* Hack to mock public static methods and properties.
*
* How to use:
*
* 1. Invoke 'StaticMockerHack::initialize' once, passing an array with the names of the classes
* that can be mocked.
*
* 2. Invoke 'CodeHacker::add_hack(StaticMockerHack::get_hack_instance())' once.
*
* 3. Use 'add_method_mocks' in tests as needed to register callbacks to be executed instead of the functions, e.g.:
*
* StaticMockerHack::add_method_mocks(
* [
* '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 {
/**
* @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.
*
* @param array $mockable_classes An associative array of class name => array of class methods.
*/
private function __construct( $mockable_classes ) {
$this->mockable_classes = $mockable_classes;
}
/**
* 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 ) {
$last_item = null;
$tokens = $this->tokenize( $code );
$code = '';
$current_token = null;
// phpcs:ignore WordPress.CodeAnalysis.AssignmentInCondition.FoundInWhileCondition
while ( $current_token = current( $tokens ) ) {
if ( $this->is_token_of_type( $current_token, T_STRING ) && in_array( $current_token[1], $this->mockable_classes, true ) ) {
$class_name = $current_token[1];
$next_token = next( $tokens );
if ( $this->is_token_of_type( $next_token, T_DOUBLE_COLON ) ) {
$called_member = next( $tokens )[1];
$code .= __CLASS__ . "::invoke__{$called_member}__for__{$class_name}";
} else {
// Reference to source class, but not followed by '::'.
$code .= $this->token_to_string( $current_token ) . $this->token_to_string( $next_token );
}
} else {
// Not a reference to source class.
$code .= $this->token_to_string( $current_token );
}
next( $tokens );
}
return $code;
}
/**
* @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

@ -0,0 +1,156 @@
# Code Hacking
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).
Currently, the code hacker allows to do the following inside unit tests:
* Replace standalone functions with custom callbacks.
* Replace invocations to public static methods with custom callbacks.
* Create subclasses of `final` classes.
## How to use
Let's go through an example.
First, create a file named `class-wc-admin-foobar.php` in `includes/admin` with the following code:
```
<?php
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 do_something_that_depends_on_the_legacy_service( $what ) {
return 'The legacy service returns: ' . WC_Some_Legacy_Service::do_something( $what );
}
}
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 `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.
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
use Automattic\WooCommerce\Testing\Tools\CodeHacking\Hacks\FunctionsMockerHack;
use Automattic\WooCommerce\Testing\Tools\CodeHacking\Hacks\StaticMockerHack;
class WC_Tests_Admin_Foobar extends WC_Unit_Test_Case {
public function test_functions_mocking() {
$tested = new WC_Admin_Foobar();
FunctionsMockerHack::add_function_mocks([
'get_option' => function( $name, $default = false ) {
return "Mocked get_option invoked for '$name'";
}
]);
$expected = "The option returns: Mocked get_option invoked for 'some_option'";
$actual = $tested->do_something_that_depends_on_an_option();
$this->assertEquals( $expected, $actual );
}
public function test_static_method_mocking() {
$tested = new WC_Admin_Foobar();
StaticMockerHack::add_method_mocks([
'WC_Some_Legacy_Service' => [
'do_something' => function( $what ) {
return "MOCKED do_something invoked for '$what'";
}
]
]);
$expected = "The legacy service returns: MOCKED do_something invoked for 'foobar'";
$actual = $tested->do_something_that_depends_on_the_legacy_service( 'foobar' );
$this->assertEquals( $expected, $actual );
}
}
```
Then run `vendor/bin/phpunit tests/legacy/unit-tests/admin/class-wc-tests-admin-foobar.php` and see the magic happen.
### Mocking functions
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.
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.
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'
FunctionsMockerHack::add_function_mocks([
'get_option' => function($name, $default = false) {
return 'foo' === $name ? 'mocked value for option foo' : get_option( $name, $default );
}
]);
```
### Mocking public static methods
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.
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.
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.
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:
```
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 );
}
]
]);
```
### 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
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 files inside `tests/Tools/CodeHacking/Hacks` implement the "hacks" (code file modifications) that are registered via `CodeHacker::add_hack` within `tests/legacy/bootstrap.php`.
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.
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(...)`.
## Creating new hacks
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.
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.
Second, configure the hack as required inside the `initialize_code_hacker` method in `tests/legacy/bootstrap.php`, and register it using `CodeHacker::add_hack`.
## Temporarily disabling the code hacker
In a few rare cases the code hacker will cause problems with tests that do write operations on the local filesystem. In these cases it is possible to temporarily disable the code hacker using `self::disable_code_hacker()` and `self::reenable_code_hacker()` in the test (these methods are defined in `WC_Unit_Test_Case`). These methods are carefully written so that they won't enable the code hacker if it wasn't enabled when the test started, and there's a disabling requests count in place to ensure that the code hacker isn't enabled before it should.
One of these cases is the usage of the `copy` command to copy files. Since this function is used in a few tests, a convenience `file_copy` method is defined in `WC_Unit_Test_Case`; it just temporarily disables the hacker, does the copy, and reenables the hacker.
## An important note
The code hacker is intended to be a **last resort** mechanism to test stuff that it's **really** difficult or impossible to test otherwise - the mechanisms already in place to help testing (e.g. the PHPUnit's mocks or the Woo helpers) should still be used whenever possible. And of course, the code hacker should not be an excuse to write code that's difficult to test.

View File

@ -6,6 +6,12 @@
* @package WooCommerce Tests
*/
use Automattic\WooCommerce\Testing\Tools\CodeHacking\CodeHacker;
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;
/**
* Class WC_Unit_Tests_Bootstrap
*/
@ -29,6 +35,8 @@ class WC_Unit_Tests_Bootstrap {
* @since 2.2
*/
public function __construct() {
$this->tests_dir = dirname( __FILE__ );
$this->initialize_code_hacker();
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
@ -40,8 +48,6 @@ class WC_Unit_Tests_Bootstrap {
}
// phpcs:enable WordPress.VIP.SuperGlobalInputUsage.AccessDetected
$this->tests_dir = dirname( __FILE__ );
$this->plugin_dir = dirname( dirname( $this->tests_dir ) );
$this->wp_tests_dir = getenv( 'WP_TESTS_DIR' ) ? getenv( 'WP_TESTS_DIR' ) : sys_get_temp_dir() . '/wordpress-tests-lib';
// load test function so tests_add_filter() is available.
@ -60,6 +66,39 @@ class WC_Unit_Tests_Bootstrap {
$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.
*

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

@ -5,6 +5,8 @@
* @package WooCommerce\Tests
*/
use Automattic\WooCommerce\Testing\Tools\CodeHacking\CodeHacker;
/**
* WC Unit Test Case.
*
@ -22,6 +24,37 @@ class WC_Unit_Test_Case extends WP_HTTP_TestCase {
*/
protected $factory;
/**
* @var int Keeps the count of how many times disable_code_hacker has been invoked.
*/
private static $code_hacker_temporary_disables_requested = 0;
/**
* Increase the count of Code Hacker disable requests, and effectively disable it if the count was zero.
* Does nothing if the code hacker wasn't enabled when the test suite started running.
*/
protected static function disable_code_hacker() {
if ( CodeHacker::is_enabled() ) {
CodeHacker::disable();
self::$code_hacker_temporary_disables_requested = 1;
} elseif ( self::$code_hacker_temporary_disables_requested > 0 ) {
self::$code_hacker_temporary_disables_requested++;
}
}
/**
* Decrease the count of Code Hacker disable requests, and effectively re-enable it if the count reaches zero.
* Does nothing if the count is already zero.
*/
protected static function reenable_code_hacker() {
if ( self::$code_hacker_temporary_disables_requested > 0 ) {
self::$code_hacker_temporary_disables_requested--;
if ( 0 === self::$code_hacker_temporary_disables_requested ) {
CodeHacker::enable();
}
}
}
/**
* Setup test case.
*
@ -99,4 +132,22 @@ class WC_Unit_Test_Case extends WP_HTTP_TestCase {
$message = $message ? $message : "We're all doomed!";
throw new Exception( $message, $code );
}
/**
* Copies a file, temporarily disabling the code hacker.
* Use this instead of "copy" in tests for compatibility with the code hacker.
*
* TODO: Investigate why invoking "copy" within a test with the code hacker active causes the test to fail.
*
* @param string $source Path to the source file.
* @param string $dest The destination path.
* @return bool true on success or false on failure.
*/
public static function file_copy( $source, $dest ) {
self::disable_code_hacker();
$result = copy( $source, $dest );
self::reenable_code_hacker();
return $result;
}
}

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

View File

@ -147,7 +147,7 @@ class WC_Tests_Product_CSV_Importer extends WC_Unit_Test_Case {
* @return void
*/
public function test_server_file() {
copy( $this->csv_file, ABSPATH . '/sample.csv' );
self::file_copy( $this->csv_file, ABSPATH . '/sample.csv' );
$_POST['file_url'] = 'sample.csv';
$import_controller = new WC_Product_CSV_Importer_Controller();
$this->assertEquals( ABSPATH . 'sample.csv', $import_controller->handle_upload() );
@ -644,7 +644,7 @@ class WC_Tests_Product_CSV_Importer extends WC_Unit_Test_Case {
if ( false !== strpos( $url, 'http://demo.woothemes.com' ) ) {
if ( ! empty( $request['filename'] ) ) {
copy( WC_Unit_Tests_Bootstrap::instance()->tests_dir . '/data/Dr1Bczxq4q.png', $request['filename'] );
self::file_copy( WC_Unit_Tests_Bootstrap::instance()->tests_dir . '/data/Dr1Bczxq4q.png', $request['filename'] );
}
$mocked_response = array(

View File

@ -57,7 +57,12 @@ class WC_Tests_MaxMind_Database extends WC_Unit_Test_Case {
$database_service = new WC_Integration_MaxMind_Database_Service( '' );
$expected_database = sys_get_temp_dir() . '/GeoLite2-Country_20200100/GeoLite2-Country.mmdb';
self::disable_code_hacker();
$result = $database_service->download_database( 'testing_license' );
self::reenable_code_hacker();
if ( is_wp_error( $result ) ) {
$this->fail( $result->get_error_message() );
}
$this->assertEquals( $expected_database, $result );
@ -126,7 +131,7 @@ class WC_Tests_MaxMind_Database extends WC_Unit_Test_Case {
if ( 'https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-Country&license_key=testing_license&suffix=tar.gz' === $url ) {
// We need to copy the file to where the request is supposed to have streamed it.
copy( WC_Unit_Tests_Bootstrap::instance()->tests_dir . '/data/GeoLite2-Country.tar.gz', $request['filename'] );
self::file_copy( WC_Unit_Tests_Bootstrap::instance()->tests_dir . '/data/GeoLite2-Country.tar.gz', $request['filename'] );
$mocked_response = array(
'response' => array( 'code' => 200 ),

View File

@ -236,14 +236,14 @@ class WC_Tests_API_Functions extends WC_Unit_Test_Case {
} elseif ( 'http://somedomain.com/invalid-image-2.png' === $url ) {
// image with an unsupported mime type.
// we need to manually copy the file as we are mocking the request. without this an empty file is created.
copy( WC_Unit_Tests_Bootstrap::instance()->tests_dir . '/data/file.txt', $request['filename'] );
self::file_copy( WC_Unit_Tests_Bootstrap::instance()->tests_dir . '/data/file.txt', $request['filename'] );
$mocked_response = array(
'response' => array( 'code' => 200 ),
);
} elseif ( 'http://somedomain.com/' . $this->file_name === $url ) {
// we need to manually copy the file as we are mocking the request. without this an empty file is created.
copy( WC_Unit_Tests_Bootstrap::instance()->tests_dir . '/data/Dr1Bczxq4q.png', $request['filename'] );
self::file_copy( WC_Unit_Tests_Bootstrap::instance()->tests_dir . '/data/Dr1Bczxq4q.png', $request['filename'] );
$mocked_response = array(
'response' => array( 'code' => 200 ),