Applying WordPress filters in acceptance tests

Filtering WordPress from “outside” WordPress.

The problem

The recent addition, in the WPLoader module, of the possibility to “only load” WordPress installations has opened many doors; these doors grant access to functions, classes and methods defined by WordPress itself, plugins, and themes, in the same variable scope as the tests.
Alas, this new power notwithstanding, the test below will fail in the second $I->see assertion:

$I = new WpfunctionalTester($scenario);
$I->wantTo('verify the post title can be filtered');

$postId = $I->havePostInDatabase(['post_title' => 'Original title']);

$I->amOnPage("/index.php?p={$postId}");
$I->see('Original title');

add_filter('the_title', function(){
    return 'Filtered title';
});

$I->amOnPage("/index.php?p={$postId}");
$I->see('Filtered title');

The test above could be running in an acceptance or functional test case that’s relying on the WPDb and WPBrowser modules; the former to manipulate the database ($I->havePostInDatabase) and the second to get around the WordPress site as a user would ($I->amOnPage and $I->see).
The reason the second assertion is failing is that the add_filter function is defined in the test variable scope, the WPLoader module provided this, but the request triggered by the $I->amOnPage method calls will be resolved in a separate PHP process: the test variable scope and the “site” variable scope, the one handling the $I->amOnPqge requests, do not share any information or global values.

The solution

Remaining in the context of the possibilities offered by wp-browser, the way the test above could succeed is by including the WPFilesystem module and leveraging its haveMuPlugin method:

$I = new WpfunctionalTester($scenario);
$I->wantTo('verify the post title can be filtered');

$postId = $I->havePostInDatabase(['post_title' => 'Original title']);

$I->amOnPage("/index.php?p={$postId}");
$I->see('Original title');

$code = <<<PHP
add_filter('the_title', function(){
    return 'Filtered title';
});
PHP;
$I->haveMuPlugin('title-filter.php', $code);

$I->amOnPage("/index.php?p={$postId}");
$I->see('Filtered title');

The WPFilesystem module, a recent addition to the Codeception modules provided by wp-browser, will write the code needed to set up a must-use plugin in the WordPress installation folder; WordPress, in turn, will use the must-use plugin while handling the next requests.
The module will take care of removing any file it created after the tests, whether the result was a success or a failure.

A more realistic example

The example above, while explicative, seems hardly useful in “real life” coding so here it is a more complex, yet more realistic, example.
I might be developing a plugin that shows a cost for each post beside the title; the plugin allows developers to filter the cost string and currency symbol position.
While it makes sense to test this kind of code on an integration level, for the sake of this example, I will test it on an functional level:

$I = new WpfunctionalTester($scenario);
$I->wantTo('verify the cost string and currency symbol position can be filtered');

$fiveId = $I->havePostInDatabase([
    'post_title' => 'Five',
    'meta_input' => ['cost' => '5']
]);
$zeroId = $I->havePostInDatabase([
    'post_title' => 'Zero',
    'meta_input' => ['cost' => '0']
]);

// check default formatting
$I->amOnPage("/index.php/?p={$fiveId}");
$I->see('Five - $5');
$I->amOnPage("/index.php/?p={$zeroId}");
$I->see('Zero - $0');

$code = <<<PHP
add_filter('my_plugin_currency_position', function(){
    return 'after';
});

// the plugin will provide filtering functions the complete cost
// string and the cost value used to generate it
add_filter('my_plugin_cost_string', function(\$cost_string, \$cost){
    return \$cost == 0
        ? 'free!'
        : \$cost_string;
},10,2);
PHP;
$I->haveMuPlugin('cost-filters.php', $code);

// check with filtering in place
$I->amOnPage("/index.php/?p={$fiveId}");
$I->see('Five - 5$');
$I->amOnPage("/index.php/?p={$zeroId}");
$I->see('Zero - free!');

Again: this might not be the best place to test the code in question still it provides one more way to do it and I’m sure I will come across edge cases that will require it.