Mocking constants in tests
October 20, 2015
Mocking constants in PHP unit tests.
It is not possible
Let's make it clear it is not possible out of the box in vanilla PHP.
To avoid this user-land limitation I can think of two approaches:
- the runkit PHP extension packs the
runkit_constant_remove($costname)
function that will take care of un-defining a PHP constant - wrap constants reading (and possibly writing) operations to allow for a simpler object mocking
The case
In the process of putting the code of the wp-browser add-on for Codeception under tests I'm testing the component of the WP Loader module that's responsible for finding the various WordPress components around the filesystem.
The test methods are the ones below
/**
* @test
* it should return custom plugins folder if set by WP_PLUGIN_DIR
*/
public function it_should_return_custom_plugins_folder_if_set_by_wp_plugin_dir()
{
$id = md5(time());
vfsStream::setup($id, null, [
'wordpress' => ['wp-settings.php' => '<?php // contents']
]);
$wpRoot = vfsStream::url($id . '/wordpress');
$sut = new Paths($wpRoot);
$pluginsDir = $wpRoot . '/content/plugins';
define('WP_PLUGIN_DIR', $pluginsDir);
$this->assertEquals($pluginsDir, $sut->getWpPluginsFolder());
}
/**
* @test
* it should return path to plugins folder if set via config
*/
public function it_should_return_path_to_plugins_folder_if_set_via_config()
{
$id = md5(time());
vfsStream::setup($id, null, [
'wordpress' => ['wp-settings.php' => '<?php // contents']
]);
$wpRoot = vfsStream::url($id . '/wordpress');
$pluginsDir = __DIR__;
$config = ['pluginsFolder' => $pluginsDir];
$sut = new Paths($wpRoot, __DIR__, $config);
$this->assertEquals($pluginsDir, $sut->getWpPluginsFolder());
}
now: the second test will fail.
The cause of this false negative is easy to spot in the method code
public function getWpPluginsFolder()
{
if (defined('WP_PLUGIN_DIR')) {
return WP_PLUGIN_DIR;
}
if (!empty($this->config['pluginsFolder'])) {
return $this->config['pluginsFolder'];
}
return $this->getWPContentFolder() . '/plugins';
}
the check for the defined WP_PLUGIN_DIR
constant will return true
and with it the method will return the value of the constant that has been set in the previous test. Constants are part of WordPress core and there is no way around them.
Runkit
Provided the PHP runkit extension is installed the second test method could make sure the WP_PLUGIN_DIR
will not be defined undefining it
/**
* @test
* it should return path to plugins folder if set via config
*/
public function it_should_return_path_to_plugins_folder_if_set_via_config()
{
if (!extension_loaded('runkit')) {
$this->markTestSkipped('This test requires the runkit extension.');
}
runkit_constant_remove('WP_PLUGIN_DIR');
$id = md5(time());
vfsStream::setup($id, null, [
'wordpress' => ['wp-settings.php' => '<?php // contents']
]);
$wpRoot = vfsStream::url($id . '/wordpress');
$pluginsDir = __DIR__;
$config = ['pluginsFolder' => $pluginsDir];
$sut = new Paths($wpRoot, __DIR__, $config);
$this->assertEquals($pluginsDir, $sut->getWpPluginsFolder());
}
this works as intended and I've added a conditional to skip the test should the runkit
extension not be installed in the current PHP version.
Wrapping
The wrapping involves the creation of an adapter object to filter calls to read and write constants.
An example as simple as it could get of it might be the one below
class Constants
{
public function define($key,
$value)
{
if (defined($key)) {
throw new \RuntimeException("Constant $key is already defined.");
}
define($key, $value);
}
public function defined($key)
{
return defined($key);
}
public function constant($key)
{
return constant($key);
}
}
The object functions might be incorporated in another adapter object to avoid more dependency injection but that's a matter of developer choice.
An instance of said class is then allowed as a injected dependency in the Paths
class constructor method
public function __construct($wpRootFolder, $rootDir = null, array $config = null, Constants $constants = null)
to rewrite the getWpPluginsFolder
to use that dependency
public function getWpPluginsFolder()
{
if ($this->filesystem->defined('WP_PLUGIN_DIR')) {
return $this->filesystem->constant('WP_PLUGIN_DIR');
}
if (!empty($this->config['pluginsFolder'])) {
return $this->config['pluginsFolder'];
}j
return $this->getWPContentFolder() . '/plugins';
}
and with it the tests (I'm mocking the objects using function-mocker aliased to Test
)
/**
* @test
* it should return custom plugins folder if set by WP_PLUGIN_DIR
*/
public function it_should_return_custom_plugins_folder_if_set_by_wp_plugin_dir()
{
$id = md5(time());
vfsStream::setup($id, null, [
'wordpress' => ['wp-settings.php' => '<?php // contents']
]);
$wpRoot = vfsStream::url($id . '/wordpress');
$pluginsDir = $wpRoot . '/content/plugins';
$constants = Test::replace('\tad\WPBrowser\Environment\Constants')
->method('defined', function ($key) {
return $key === 'WP_PLUGIN_DIR' ? true : defined($key);
})
->method('constant', function ($key) use ($pluginsDir) {
return $key === 'WP_PLUGIN_DIR' ? $pluginsDir : constant($key);
})->get();
$rootDir = __DIR__;
$sut = new Paths($wpRoot, $rootDir, [], $constants);
$this->assertEquals($pluginsDir, $sut->getWpPluginsFolder());
}
/**
* @test
* it should return path to plugins folder if set via config
*/
public function it_should_return_path_to_plugins_folder_if_set_via_config()
{
$id = md5(time());
vfsStream::setup($id, null, [
'wordpress' => ['wp-settings.php' => '<?php // contents']
]);
$wpRoot = vfsStream::url($id . '/wordpress');
$pluginsDir = $rootDir = __DIR__;
$config = ['pluginsFolder' => $pluginsDir];
$env = Test::replace('tad\WPBrowser\Environment\Constants::defined', function ($key) {
return $key === 'WP_PLUGIN_DIR' ? false : defined($key);
});
$sut = new Paths($wpRoot, $rootDir, $config, $env);
$this->assertEquals($pluginsDir, $sut->getWpPluginsFolder());
}
now this tests might not be as elegant and slim as the one using the runkit
extension but I see some advantages:
- the tests will run as long as minimum Codeception PHP version is present
- I can avoid defensive coding in tests, changing the order of execution of the test classes or methods is not going to break a define/undefine balance
- each test method contains its own independent test fixture
I will keep this second solution in the dire necessity of having to "mock constants".
A lesson learned
One of the first lessons I was ever given regarding code is not to use globals and constants: once again the simple truth that writing testable code will enforce good coding practices is proven true
.