woocommerce/tests/Tools/CodeHacking/README.md

9.5 KiB

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 about that).

The code hacker consists of the following classes:

  • CodeHacker: the core class that performs the hacking and has some public configuration and activation methods.
  • CodeHackerTestHook: a PHPUnit hook class that wires everything so that the code hacking mechanism can be used within unit tests.
  • Hack classes inside the Hack folders: some predefined frequently used hacks.

How to use

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:

<?php

class WC_Admin_Hello_Worlder {

	public function say_hello( $to_who ) {
		Logger::log_hello( $to_who );
		$site_name = get_option( 'site_name' );
		return "Hello {$to_who}, welcome to {$site_name}!";
	}
}

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.

Create a file named class-wc-tests-admin-hello-worlder.php in tests/unit-tests/admin with this code:

<?php

use Automattic\WooCommerce\Testing\CodeHacking\CodeHacker;
use Automattic\WooCommerce\Testing\CodeHacking\Hacks\StaticMockerHack;
use Automattic\WooCommerce\Testing\CodeHacking\Hacks\FunctionsMockerHack;

class LoggerMock {

	public static $_logged = null;

	public static function log_hello( $to_who ) {
		self::$_logged = $to_who;
	}
}

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() {
		FunctionsMock::$_option_value = 'MSX world';

		$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 );
	}
}

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.

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.

You can define a before_all method too, which will run before all the tests (and before the before_[test_method_name] method).

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.

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):

/**
 * @hack StaticMocker Logger LoggerMock
 */
class WC_Tests_Admin_Hello_Worlder extends WC_Unit_Test_Case {

	/**
	 * @hack FunctionsMocker FunctionsMock
	 * @hack BypassFinals
	 */
	public function test_say_hello() {
            ...
	}
}

The syntax is @hack HackClassName [hack class constructor parameters]. Important bits:

  • Specify constructor parameters after the class name. If a parameter has a space, enclose it in quotation marks, "".
  • 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

New hacks can be created and used the same way as the predefined ones. A hack is defined as one of these:

  • A function with the signature hack($code, $path).
  • 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).

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).

How it works under the hood

The Code Hacker is based on the Bypass Finals project by David Grudl.

The CodeHacker class is actually a streamWrapper class 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 CodeHackerTestHook then uses reflection to find before_ methods and @hack anotations, putting everything in place right before the tests are executed.

Workaround for static mocking of already loaded code: the StaticWrapper

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.

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:

  1. Add the class name (NOT the file name) to the array returned by tests/classes-that-need-static-wrapper.php.

  2. Configure the mock using StaticWrapper::set_mock_class_for.

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

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.