Unit Testing Custom WP-CLI Commands

Recently I’ve been spending pretty much all my time working on Mergebot for Delicious Brains, which mainly consists of building the WordPress plugin part of the project. I’ve also been spending a good deal of time refactoring code, making code testable, and generally implementing best practices to ensure code is maintainable and has high unit test coverage. Yesterday I sat down to further increase that coverage by adding unit tests around the three custom WP-CLI commands the plugin has, and hit a roadblock. Let me show you what I mean.

Our custom WP-CLI commands allow you to do things like start/stop recording of queries, view and discard the current changeset, and connect your current site to the Mergebot application. The commands look pretty much like this:

namespace DeliciousBrains\Mergebot\CLI;

class CLI_Recordings extends \WP_CLI_Command {
...
}

The classes also have a constructor that take instances of other classes as dependencies, this makes the class decoupled and easier to test as those dependencies can be mocked in PHPUnit. However, the issue arises when you try to instantiate CLI_Recordings in the testcase:

Fatal error: Class 'WP_CLI_Command' not found in /src/mergebot/includes/cli/cli-recordings.php on line 20

This is because WP-CLI is not loaded as we aren’t actually running it. We need to somehow manually load WP-CLI when we bootstrap PHPUnit so the relevant classes are loaded. Strictly speaking when it comes to unit tests, we shouldn’t need to bootstrap any dependency like this, as we aren’t testing WP-CLI and so don’t care about it, but as our classes extend from a WP-CLI class, there isn’t a way I could see to swap that out for a mocked class without some over-engineered class SPL aliasing in our actual plugin.

After some digging into how WP-CLI loads itself, I discovered what is the bare minimum WP-CLI needs to be loaded so that are commands can be instantiated:

if ( ! defined( 'WP_CLI_ROOT' ) ) {
	define( 'WP_CLI_ROOT', 'path/to/vendor/wp-cli/wp-cli' );
}

include WP_CLI_ROOT . '/php/utils.php';
include WP_CLI_ROOT . '/php/dispatcher.php';
include WP_CLI_ROOT . '/php/class-wp-cli.php';
include WP_CLI_ROOT . '/php/class-wp-cli-command.php';

\WP_CLI\Utils\load_dependencies();

We also need to set the logger for WP-CLI so it knows what to do when methods like \WP_CLI::success() are called in our commands. Instead of using the CLI to output the messages and errors, I decided on using a custom logger for PHPUnit tests that prints success and error messages so we can assert on returned values from command methods using $this->expectOutputString( 'Some output' );.

However, keep in mind that if you use \WP_CLI::error( __( 'Some Error!' ) ); in your custom commands, that the script will exit(), which isn’t great when running unit tests! So it is best to pass a second argument of false to the method to stop it exiting, e.g. \WP_CLI::error( __( 'Some Error!' ), false );.

I put all the bootstrapping code and logger class in a GitHub repository and also on Packagist so you can easily include in your project via Composer. Simply add this to the bootstrap.php file in PHPUnit:

$vendorDir = '/path/to/vendor';
\Polevaultweb\PHPUnit_WP_CLI_Runner\Runner::init( $vendorDir );

Then whenever you instantiate your custom CLI command classes in unit test cases, WP-CLI will be loaded and available.

Hopefully that helps someone else in the same boat. If you know of anything I missed or have a better way of unit testing custom CLI commands, let me know in the comments below.

About Iain

I am a WordPress and PHP developer building my own plugins and working with Delicious Brains. I like to blog about things, especially WordPress.

  • Hey Iain,

    If you look at the `WP_CLI_Command` class, you can see that you can safely stub it: https://github.com/wp-cli/wp-cli/blob/v1.1.0/php/class-wp-cli-command.php

    There are several ways you can stub classes like this. One of them is to have a folder with stub classes, and load them with Composer through an `autoload-dev` entry. This is the same as the `autoload` entry, but only loaded when the `–no-dev` flag has not been provided.

    Cheers,
    Alain