Unit-testing with mocks in WordPress
Unit-testing your WordPress plugin can be challenging. Luckily there are tools out there making it a lot easier. In this post, we will be covering the following tools: PHPUnit, Mockery and BrainMonkey. Together these tools can be a powerful tool to ensure the technical quality of your plugin.
Before we started using these tools at Yoast, our “unit”-tests were technically integration tests. They depended on a fully-fledged WordPress environment, database and all. Running one test could affect another if we didn’t revert everything back to the way it was and I won’t have to tell you this is not what you want. The very definition of a unit test is that it runs in a sandbox where you, as the developer, control every aspect of what is going on outside of the piece of code you want to test.
Setting up the project
First, you shape the environment you want your code to live in. This means installing PHPUnit in your WordPress plugin directory (or globally), installing BrainMonkey and Mockery, setting up a unit-tests folder, and so forth.
Note: For the writing of this post, I used PHPUnit 8 since it’s the latest available version and uses a requires a PHP version that is still supported for a long time. If your plugin supports an earlier version of PHP, don’t worry, you can still run your unit-tests with a newer version of PHP. If you are worried about using syntax or functionality your plugin’s minimum required PHP version doesn’t support, you can use tools like PHPCS to catch these.
You can follow along with this tutorial using this project. It contains the initial source code (hello-rammstein-1-initial-project) and the finished source code (hello-rammstein-2-first-test).
What will we be testing?
Let us now explore what it is we will be testing. A small plugin I dubbed Hello Rammstein
. It is actually class-based and initialized in the plugin’s main file. Writing your code object-oriented provides a lot of benefits when writing your unit-tests.
<?php namespace Xyfi\Hello_Rammstein; /** * Entry point for the plugin. * * Class Hello_Rammstein */ class Hello_Rammstein { public function register_hooks() { add_action( 'admin_head', [ $this, 'output_css' ] ); add_action( 'admin_notices', [ $this, 'output_lyric' ] ); } public function output_css() { $x = is_rtl() ? 'left' : 'right'; $padding = "padding-$x: 15px"; echo " <style type='text/css'> #rammstein { float: $x; $padding; padding-top: 5px; margin: 0; font-size: 11px; } </style> "; } public function output_lyric() { $chosen = $this->get_random_lyric(); echo "<p id='rammstein'>$chosen</p>"; } public function get_random_lyric() { $lyrics = [ "Du", "Du hast", "Du hast mich", "Du hast mich gefragt", "Du hast mich gefragt und ich hab nichts gesagt", "Nein", "Sie lieben auch in schlechten Tagen", "Treue sein", "Treue sein für alle Tage", "Willst du bis der Tod uns scheidet", "Willst du bis zum Tod, der scheidet" ]; return wptexturize( $lyrics[ mt_rand( 0, count( $lyrics ) - 1 ) ] ); } }
This code is based on WordPress’ own plugin, called Hello Dolly
. All it does is display a random song lyric in the top right of your admin, but it showcases WordPress’ easy extensibility.
Adjusting your application’s code to enhance the testing capability
This also poses our first issue: Randomness is not something we can test. For now, we will mitigate this problem by moving the function that returns a random value to a separate protected function so we can mock it. Mocking means that you overwrite the behavior of a function, making it much easier to test.
<?php namespace Xyfi\Hello_Rammstein; /** * Entry point for the plugin. * * Class Hello_Rammstein */ class Hello_Rammstein { /* Previous code */ protected function get_random_lyric() { $lyrics = [ "Du", "Du hast", "Du hast mich", "Du hast mich gefragt", "Du hast mich gefragt und ich hab nichts gesagt", "Nein", "Sie lieben auch in schlechten Tagen", "Treue sein", "Treue sein für alle Tage", "Willst du bis der Tod uns scheidet", "Willst du bis zum Tod, der scheidet" ]; return wptexturize( $lyrics[ $this->get_random_number( count( $lyrics ) - 1 ) ] ); } /** * @codeCoverageIgnore */ protected function get_random_number( $max, $min = 0 ) { return mt_rand( $min, $max ); } }
Now, if you make the return value of get_random_number
predictable, you are able to predict the outcome of get_random_lyric
.
The unit test
Now it’s time for your first unit test. Create tests/classes/hello-rammstein-test.php
.
<?php use Xyfi\Hello_Rammstein\Tests\Doubles\Hello_Rammstein_Double; use Xyfi\Hello_Rammstein\Tests\TestCase; class Hello_Rammstein_Test extends TestCase { /** * @var Hello_Rammstein_Double */ private $instance; /** * This function will be executed before each test. */ public function setUp(): void { parent::setUp(); $this->instance = new Hello_Rammstein_Double(); } /** * Tests the return value of Hello_Rammstein::get_random_lyric. * * @covers Hello_Rammstein::get_random_lyric */ public function test_get_random_lyric() { $expected = "?"; $actual = $this->instance->get_random_lyric(); $this->assertEquals( $expected, $actual ); } }
A few things to note: We use the setUp
function, that is actually called by PHPUnit before each unit tests. This allows us to build a new instance of Hello_Rammstein
(double in this case) before each test. This ensures we have a clean instance to test against, so we encounter no side effects from previously ran tests.
All public functions that start with test
(either snake-case or camel-case) will be treated by as unit-tests by PHPUnit.
In the first unit-test called test_get_random_lyric
we use the assertEquals
function provided by PHPUnit’s TestCase
class, which we inherit because we extend it. This function receives two arguments: This first argument is what we expect the output of a function to be, and the second argument is what the actual output is. As you can see, we pass ?
to it, because we have no way to determine what the output will be. This test will inevitably fail.
Running the tests for the first time.
Now we can run the unit tests for the first time. In your project directory run composer test
in your terminal in the root of your project. The output should be something like this:
> ./vendor/bin/phpunit
PHPUnit 8.4.0 by Sebastian Bergmann and contributors.
Runtime: PHP 7.2.18
Configuration: /Users/alexander/vagrant-local/www/wordpress-two/public_html/wp-content/plugins/php-unit-tests/hello-rammstein-day-2/phpunit.xml.dist
F 1 / 1 (100%)
Time: 27 ms, Memory: 4.00 MB
There was 1 failure:
1) Hello_Rammstein_Test::test_get_random_lyric
Failed asserting that two strings are equal.
--- Expected
+++ Actual
@@ @@
-'?'
+'Sie lieben auch in schlechten Tagen'
/Users/alexander/vagrant-local/www/wordpress-two/public_html/wp-content/plugins/php-unit-tests/hello-rammstein-day-2/tests/classes/hello-rammstein-test.php:30
FAILURES!
Tests: 1, Assertions: 1, Failures: 1.
Let’s analyze the most important part of this log:
There was 1 failure:
1) Hello_Rammstein_Test::test_get_random_lyric
Failed asserting that two strings are equal.
--- Expected
+++ Actual
@@ @@
-'?'
+'Sie lieben auch in schlechten Tagen'
This tells you how many tests have failed, what test has failed in which class. And what the failure is exactly. In this case, we expected the function to return “?”, but instead, it returned a lyric. To be honest, we didn’t expect the function to return “?”, but in order to know what to expect, we have to remove the randomness factor in this unit test.
Mocking functions in your classes
If we want to control what get_random_lyric
returns, we must specify what get_random_number
should return. We can accomplish this by mocking the function. In order to mock a function, we have to mock the class using Mockery:
class Hello_Rammstein_Test extends TestCase { /** * @var Hello_Rammstein_Double */ private $instance; /** * This function will be executed before each test. */ public function setUp(): void { parent::setUp(); $this->instance = Mockery::mock( Hello_Rammstein_Double::class ) ->shouldAllowMockingProtectedMethods() ->makePartial(); } /* tests */ }
We now have an instance of Hello_Rammstein_Double
that behaves exactly the same, but now we can overwrite public and protected methods with defined behavior. We still want to test the functionality of get_random_lyric
, but we want to make its output predictable by defining what get_random_number
should return.
Here is how you mock a function on a mocked class using Mockery:
class Hello_Rammstein_Test extends TestCase { /* Setup */ /** * Tests the return value of Hello_Rammstein::get_random_lyric. * * @covers Hello_Rammstein::get_random_lyric */ public function test_get_random_lyric() { $this->instance // The function we want to mock. ->expects( 'get_random_number' ) // The amount of times we expect the function to be called. ->once() // The value the function should return. ->andReturn( 6 ); $expected = "Sie lieben auch in schlechten Tagen"; $actual = $this->instance->get_random_lyric(); $this->assertEquals( $expected, $actual ); }
We can now safely assume what get_random_lyric
will return because we define the behavior of get_random_number
, which is called by get_random_lyric
. If we run the tests again the test should all pass.
Testing echoed output
Let’s move on to testing echoed output. For this, we will need a utility function in our own defined tests/test-case.php
class.
/** * TestCase base class. */ abstract class TestCase extends BaseTestCase { /** * Tests for expected output. * * @param string $expected Expected output. * @param string $description Explanation why this result is expected. */ protected function expectOutput( $expected, $description = '' ) { $output = \ob_get_contents(); \ob_clean(); $output = \preg_replace( '|\R|', "\r\n", $output ); $expected = \preg_replace( '|\R|', "\r\n", $expected ); $this->assertEquals( $expected, $output, $description ); } }
PHPUnit automatically starts storing output data per test in a temporary buffer. We can read this buffer using this utility function.
We can use this function in the following way:
class Hello_Rammstein_Test extends TestCase { /* Previous code */ /** * Tests the return value of Hello_Rammstein::output_lyric. * * @covers Hello_Rammstein::output_lyric */ public function test_output_lyric() { $this->instance // The function we want to mock. ->expects( 'get_random_lyric' ) // The amount of times we expect the function to be called. ->once() // The value the function should return. ->andReturn( "This is the output lyric" ); $expected = "<p id='rammstein'>This is the output lyric</p>"; $this->instance->output_lyric(); $this->expectOutput( $expected ); } }
Here you can see how extending your TestCase class can be beneficial because it allows you to add custom assertions in your tests.
Testing code with WordPress functions
Let’s look into how to test code that contains WordPress code. In our previous integration setup, we would load in the entire WordPress codebase. Besides the complexity of managing a test suite like this, it also made our tests unnecessarily sluggish and slowed down development.
Now that we don’t have to worry about this, we need a way to mock the behavior of WordPress functions. For this, we use a library called BrainMonkey. This library adds a set of tools to test WordPress specific things, like hooks and WordPress functions.
In the project provided above, the library is already installed using composer
. Now all you have to do is initialize and tear down the suite before and after every test to ensure the library functions properly. In your tests/test-case.php
class add the following functions:
<?php namespace Xyfi\Hello_Rammstein\Tests; use PHPUnit\Framework\TestCase as BaseTestCase; use Brain; /** * TestCase base class. */ abstract class TestCase extends BaseTestCase { /* Previous code */ /** * Runs before each test. */ protected function setUp(): void { parent::setUp(); Brain\Monkey\setUp(); } /** * Runs after each test. */ protected function tearDown(): void { Brain\Monkey\tearDown(); parent::tearDown(); } }
Stubs
BrainMonkey already sets up a few functions for us. So we don’t have to. For some functions, it’s easy to have stubs
. These are definitions of functions with simple behaviors we don’t want to worry about during testing. Let’s say we made a small adjustment to the output_lyric
function that is translatable:
/** * Entry point for the plugin. * * Class Hello_Rammstein */ class Hello_Rammstein { /* Previous code */ public function output_lyric() { $pre = __( 'Your random lyric:', 'hello-rammstein' ); $chosen = $this->get_random_lyric(); echo "<p id='rammstein'>$pre $chosen</p>"; } }
And we adjusted our test accordingly:
class Hello_Rammstein_Test extends TestCase { /* Previous code */ /** * Tests the return value of Hello_Rammstein::output_lyric. * * @covers Hello_Rammstein::output_lyric */ public function test_output_lyric() { $this->instance // The function we want to mock. ->expects( 'get_random_lyric' ) // The amount of times we expect the function to be called. ->once() // The value the function should return. ->andReturn( "This is the output lyric" ); $expected = "<p id='rammstein'>Your random lyric: This is the output lyric</p>"; $this->instance->output_lyric(); $this->expectOutput( $expected ); } }
If we run our test suite now we get the following error:
There was 1 error:
1) Hello_Rammstein_Test::test_output_lyric
Error: Call to undefined function Xyfi\Hello_Rammstein\__()
This is because of translation functions not being one of the functions that BrainMonkey already sets up for us. But this is also where stubs can come in handy because for translation functions unless specified differently, we would like to return the string as-is.
We can add a stub that is added for each unit-test we run by adding the following code in our tests/test-case.php
, specifically in the setUp
function:
<?php namespace Xyfi\Hello_Rammstein\Tests; use PHPUnit\Framework\TestCase as BaseTestCase; use Brain; /** * TestCase base class. */ abstract class TestCase extends BaseTestCase { /** * Runs before each test. */ protected function setUp(): void { parent::setUp(); Brain\Monkey\setUp(); Brain\Monkey\Functions\stubs( [ '__' => null, ] ); } }
By specifying null
, we say that each time we call the __()
function, we should simply return the first argument. This means we never have to worry about the output of that function again and we can assume it’s the same as the input. In the array provided to the Brain\Monkey\Functions\stubs
function, we can define all WordPress functions we want to be available during a unit test. By replacing null
by any other value, it will return that value instead.
Mocking functions
For more complex mocking of WordPress functions, we use BrainMonkey’s expects
API. Let’s say every user has a switch to toggle the lyrics in their WordPress admin. User-specific options are saved as user meta options. We’ll not go into how to set user meta options, but only reading them.
<?php namespace Xyfi\Hello_Rammstein; /** * Entry point for the plugin. * * Class Hello_Rammstein */ class Hello_Rammstein { /* Previous code */ public function output_lyric() { $user_id = get_current_user_id(); $show = get_user_meta( $user_id, 'hello-rammstein-show-lyric', true ); if ( ! $show ) { return; } $pre = __( 'Your random lyric:', 'hello-rammstein' ); $chosen = $this->get_random_lyric(); echo "<p id='rammstein'>$pre $chosen</p>"; } /* Following code */ }
As before, we will get an error running the test suite because the get_current_user_id
and get_user_meta
functions are not declared. Because these functions are more specific than the translation function we will be using BrainMonkey’s expects
API to fix this. Also, we will not declare these functions in the setUp
function, but in the specific unit test, because we could expect different behavior for different tests.
<?php use Xyfi\Hello_Rammstein\Hello_Rammstein; use Xyfi\Hello_Rammstein\Tests\TestCase; use Brain; class Hello_Rammstein_Test extends TestCase { /* Previous code */ /** * Tests the return value of Hello_Rammstein::output_lyric. * * @covers Hello_Rammstein::output_lyric */ public function test_output_lyric() { Brain\Monkey\Functions\expect( 'get_current_user_id' ) // We expect the function to be called once. ->once() // What the function should return when called. ->andReturn( 1 ); Brain\Monkey\Functions\expect( 'get_user_meta' ) // We expect the function to be called once. ->once() // With what EXACT parameters should the function be called. ->with( 1, 'hello-rammstein-show-lyric', true ) // What the function should return when called. ->andReturn( true ); $this->instance // The function we want to mock. ->expects( 'get_random_lyric' ) // The amount of times we expect the function to be called. ->once() // The value the function should return. ->andReturn( "This is the output lyric" ); $expected = "<p id='rammstein'>Your random lyric: This is the output lyric</p>"; $this->instance->output_lyric(); $this->expectOutput( $expected ); } }
If we run the tests now everything should pass. You have now exactly described how the function should behave, giving you a lot of flexibility on how to test your function.
But output_lyric
has two flows now. Once where output is generated, and one where no output is generated. To fix this, you can now create a second test for output_lyric
, test_output_lyric_no_show
, for example, where you specify that a user would not like to receive a lyric on his or her admin pages. I’ll leave writing this test up to you.
Conclusion
We just set up a WordPress plugin project with testing capabilities and learned how to run tests. We also looked at what things we should keep in mind when writing code that has to be tested, how to access protected methods in a test environment and how to overwrite the behaviour of protected and public functions.
There is more to learn about testing WordPress functionality that can surely help you build a more reliable plugin. Take a look at the BrainMonkey documentation, for example.
Good luck testing!
Coming up next!
-
Event
WordCamp Netherlands 2024
November 29 - 30, 2024 Team Yoast is at Sponsoring WordCamp Netherlands 2024! Click through to see who will be there, what we will do, and more! See where you can find us next » -
SEO webinar
Webinar: How to start with SEO (November 19, 2024)
19 November 2024 Learn how to start your SEO journey the right way with our free webinar. Get practical tips and answers to all your questions in the live Q&A! All Yoast SEO webinars »