Brain-WP/BrainMonkey

Testing w/o class or class instantiation within plugin

Closed this issue · 2 comments

This is probably more of a user error or opportunity for documentation/example updates than issue with BrainMonkey code itself.

In both of the scenarios below I encounter an error about call to undefined function for add_action.

Scenario 1 - Equivalent of functions.php

plugin-dir/logging.php

<?php
add_action( 'http_api_debug', 'verify_http_api_debug', 10, 5 );
function verify_http_api_debug( $response, $type, $class, $args, $url ) {
	if ( WP_ENV == 'development' ) {
		error_log( 'HTTP_API_DEBUG - Request URL: ' . var_export( $url, true ) );
	}
}

plugin-dir/tests/logging-Test.php

<?php
use Brain\Monkey\Filters;
use Brain\Monkey\Actions;
use Brain\Monkey\Functions;

class ExampleLoggingTest extends MonkeyTestCase {
	public function test_verify_http_api_debug() {
		self::assertSame( 10, has_action( 'http_api_debug', 'verify_http_api_debug'  ) );
	}
}

Actual error
Call to undefined function add_action()

Scenario 2 - OOP/Class based

plugin-dir/logging.php

<?php
namespace Example\WP\MUPlugins\Example_Core;

class ExampleCoreLogging {
	static $instance = false;
	public static function get_instance () {
		if ( ! self::$instance ) {
		  self::$instance = new self;
		}
		return self::$instance;
	}
	public function __construct() {
		add_action( 'http_api_debug', [ __CLASS__, 'verify_http_api_debug' ], 10, 5 );
	}
	static function verify_http_api_debug( $response, $type, $class, $args, $url ) {
		if ( WP_ENV == 'development' ) {
			error_log( 'HTTP_API_DEBUG - Request URL: ' . var_export( $url, true ) );
		}
	}
	
}

if ( class_exists( 'Example\WP\MUPlugins\Example_Core\ExampleCoreLogging' ) ) {
	$ExampleCoreLogging= \Example\WP\MUPlugins\Example_Core\ExampleCoreLogging::get_instance();
}

plugin-dir/tests/logging-Test.php

<?php
namespace Example\WP\MUPlugins\Example_Core;

use Brain\Monkey\Filters;
use Brain\Monkey\Actions;
use Brain\Monkey\Functions;

class ExampleCoreLogging extends MonkeyTestCase {
	public function test_verify_http_api_debug() {
		self::assertSame( 10, has_action( 'http_api_debug', [ 'Example\WP\MUPlugins\Example_Core\ExampleCoreLogging', 'verify_http_api_debug' ] ) );
	}
}

Actual error
Fatal error: Uncaught Error: Call to undefined function Example\WP\MUPlugins\Example_Core\add_action()

Other Notes
The closest I've been able to get either scenario to work is:

  • Remove the class instantiation from the plugin file
  • Add it to the test_verify_http_api_debug function
  • The unit test will pass, but use of the plugin within WP does not call/execute expected hook.
  • The MonkeyTestCase is identical to your docs example for WP in both cases for MonkeyClass.php
  • The bootstrap.php file requires the MonkeyClass, vendor\autoload.php and the plugin file.

If I understand correctly, the problem is that by loading your plugin file in the bootstrap.php file your code calls add_action before setUp() method is called, and because setUp() is what defines the mocked version of add_action you end up with an error.

There are different ways to solve this issue.

Without touching your plugin code, you could remove the plugin require from the bootstrap.php file and move it to your TestCase.

For the procedural case, assuming a MyPluginTestCase.php that contains:

<?php

use Brain\Monkey;
use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration;
use PHPUnit\Framework\TestCase as FrameworkTestCase;

abstract class MyPluginTestCase extends FrameworkTestCase
{
    use MockeryPHPUnitIntegration;

    protected function setUp() {
        parent::setUp();
        Monkey\setUp();
    }

    protected function tearDown() {
        Monkey\tearDown();
        parent::tearDown();
    }

    protected function loadPluginFile()
    {
       if (!function_exists('verify_http_api_debug')) {
           require_once '../plugin-dir/logging.php'; // make sure the path is correct
       }
    }
}

Then you could write a test case like this:

<?php
use Brain\Monkey;

/**
 * @runTestsInSeparateProcesses 
 */
class ExampleLoggingTest extends MyPluginTestCase
{
    public function test_verify_http_api_debug_with_has_action()
    {
        $this->loadPluginFile();
        self::assertSame( 10, has_action( 'http_api_debug', 'verify_http_api_debug'  ) );
    }

    public function test_verify_http_api_debug_with_expectations()
    {
        Monkey\Actions::expectAdded('http_api_debug')->once()->with('verify_http_api_debug', 10, 5);

        $this->loadPluginFile();
    }
}

Note how I used @runTestsInSeparateProcesses (see docs) to make each test run in a separate PHP process, so that including the file in a test does not affect the other tests.

Sticking with the procedural example, what you could do is to have a file that defines the function verify_http_api_debug, and the main plugin file requires that file and then call a function that adds the action.

For example, a /plugin-dir/functions.php:

function verify_http_api_debug( $response, $type, $class, $args, $url )
{
    if ( WP_ENV == 'development' ) {
        error_log( 'HTTP_API_DEBUG - Request URL: ' . var_export( $url, true ) );
    }
}

function setup_http_api_debug()
{
    add_action( 'http_api_debug', 'verify_http_api_debug', 10, 5 );
}

at that point the main plugin file. would be:

require_once __DIR__ . '/functions.php';
setup_http_api_debug();

Which means that there's nothing to test there. At that point, in your bootstrap.php you could require /plugin-dir/functions.php and the in your tests use Brain Monkey to tests your functions.

For the OOP example, that is even easier. If you define the class in its own file with a setup() (or whatever you want to call it) that adds the hooks (instead of adding hooks in the constructor), then in your main plugin file you can require the class file, instantiate the class, and call the setup() method on the instance.

require_once __DIR__ . '/class-example-core-logging.php';
$logging = new ExampleCoreLogging();
$logging->setup();

After that, in bootstrap.php you could require the class file and in your tests, you can use Brain Monkey to tests the class methods.

If your plugin grows, and you'll have more classes, this workflow will prove to work very well and will help you keep things organized, and if you define an autoload (or you let Composer generate an autoload for you) things will be even easier.

I cannot thank you enough for how amazing your detailed answer to this question is. I know that closing it with comment is the right thing to do from GH perspective - but that also means that it will become just that little bit harder for others to find and make use of your great response as I have been able to. Perhaps a link to it in the docs (#93)?