All developers hear about mystic unit tests that must-have, but no one has. As a developer, who has written unit tests for a few years yet, I can say that it’s impossible to write great code without unit tests.
Unit testing is the quality tool that helps developers be sure that new code doesn’t break old business logic. Also, the unit tests are the best documentation for developers, and when you deep into unit testing, your mindset will change. You will start writing code in another way — simpler and divided into logical modules.
First of all, you need to deep catch on the main target for unit testing — functions and methods for your own code in a state where no third-party core/modules/functions/libraries exist. If it sounds complicated or you don’t know the difference in the testing layers, please, read the article about automated testing before.
Before the start, we need to define the third-party dependencies. If we are testing our own plugin, we need writing tests without core, other plugins, themes, and libraries or packages in your plugin — only your code, nothing more. You should isolate your plugin from all dependencies.
Testing framework
The gold standard of unit testing for PHP is a PHPUnit. PHPUnit is a testing framework that can simplify your testing process. The framework helps run, reset the default state before and after tests, show results, make metrics, and more.
Let’s install the great tool in our project via Composer:
composer require --dev phpunit/phpunit
The next thing is creating the tests/php
folder in your project. In the tests’ folder need to create a PHPUnit config — phpunit.xml
:
<?xml version="1.0"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
bootstrap="./bootstrap.php"
colors="true"
convertErrorsToExceptions="true"
convertNoticesToExceptions="true"
convertWarningsToExceptions="true"
beStrictAboutTestsThatDoNotTestAnything="false"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.3/phpunit.xsd"
>
<testsuites>
<testsuite name="your-plugin-tests">
<directory suffix=".php">./tests/</directory>
</testsuite>
</testsuites>
</phpunit>
Let’s check the main settings of this config:
bootstrap="./bootstrap.php"
Path to the bootsrap
file that we’ll create later.
convertErrorsToExceptions="true"
convertNoticesToExceptions="true"
convertWarningsToExceptions="true"
Throw the exception instead of notices, warnings, and errors.
beStrictAboutTestsThatDoNotTestAnything="false"
Significant settings for WordPress testing because some tests could be written without any assertions.
And the last but not least, create the bootstrap.php
file that will be run by PHPUnit before testings:
<?php
require_once __DIR__ . '/../../vendor/autoload.php';
Write the first test
Let’s create the tests
folder nearby bootstrap and config files and add a first test file SampleTest.php
with:
<?php
use PHPUnit\Framework\TestCase;
final class SampleTest extends TestCase {
public function test_sample() {
$this->assertTrue( true );
}
}
Then run into your console:
vendor/bin/phpunit --configuration tests/php/phpunit.xml
If you receive a similar response in your console that means you are on the right track.
Unit tests’ Mocks for WordPress
WordPress has a lot of functions, classes, and an awesome hooks system. How to test without including them? Using mocks. Mocks are fake functions or objects that we create and describe its behavior before the launch tested method.
You can use WP_Mock or Brain Monkey. What a difference? Syntax and name :). So as the punk, I slightly prefer Brain Monkey just for the awesome name.
But jokes aside, I use both libraries from time to time and deeply respect both authors and contributors.
So, the next examples will have two variants for each of these libraries.
WP_Mock environment setup
Let’s install and add setup settings for WP_Mock:
composer require --dev 10up/wp_mock
Modify the /tests/php/bootstrap.php file
:
<?php
require_once __DIR__ . '/../../vendor/autoload.php';
WP_Mock::bootstrap();
WP_Mock::bootstrap();
just run the WP_Mock library.
The next step is create a SampleWithWPMockTest
class:
<?php
use PHPUnit\Framework\TestCase;
final class SampleWithWPMockTest extends TestCase {
public function setUp(): void {
parent::setUp();
WP_Mock::setUp();
}
public function tearDown(): void {
WP_Mock::tearDown();
Mockery::close();
parent::tearDown();
}
public function test_sample() {
$this->assertTrue( true );
}
}
setUp and tearDown are fixtures — methods, that run before(setUp) and after(tearDown) each tested method. Remember I’ve written about the isolated state. Congratulations, WP_Mock ready for testing.
Brain Monkey environment setup
Let’s install Brain Monkey:
composer require --dev brain/monkey
Brain Monkey needs to modify only fixtures. Let’s create SampleWithBrainMonkeyTest
class:
<?php
use PHPUnit\Framework\TestCase;
final class SampleWithBrainMonkeyTest extends TestCase {
public function setUp(): void {
parent::setUp();
Brain\Monkey\setUp();
}
public function tearDown(): void {
Brain\Monkey\tearDown();
parent::tearDown();
}
public function test_sample() {
$this->assertTrue( true );
}
}
I can’t wait to start testing. Hopefully, you are too.
Tested code
I tried to find simple, small, and real code that includes all basic WordPress features and shows how to test them. This is an ajax handler for subscribe form in the src/Process.php
file:
<?php
namespace wppunk\Subscribe;
class Process {
public function add_hooks() {
add_action( 'wp_ajax_save_form', [ $this, 'save' ] );
add_action( 'wp_ajax_nopriv_save_form', [ $this, 'save' ] );
}
public function save() {
check_ajax_referer( 'subscribe', 'nonce' );
if ( empty( $_POST['email'] ) ) {
wp_send_json_error( esc_html__( 'Fill the email address', 'subscribe' ), 400 );
}
$email = apply_filters(
'subscriber_email',
sanitize_email( wp_unslash( $_POST['email'] ) )
);
global $wpdb;
$subscriber = $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching
$wpdb->prepare(
'INSERT INTO ' . $wpdb->prefix . 'subscribers (email) VALUES (%s)',
$email
)
);
if ( ! $subscriber ) {
wp_send_json_error( esc_html__( 'You are already subscribed', 'subscribe' ), 400 );
}
do_action( 'subscriber_added', $email );
wp_send_json_success( esc_html__( 'You have successfully subscribed', 'subscribe' ) );
}
}
Don’t forget to add this class to the composer autoload.
Testing with Brain Monkey
<?php
use wppunk\Subscribe\Process;
use PHPUnit\Framework\TestCase;
use function Brain\Monkey\setUp;
use function Brain\Monkey\tearDown;
use function Brain\Monkey\Functions\stubs;
use function Brain\Monkey\Functions\expect;
use function Brain\Monkey\Actions\expectDone;
use function Brain\Monkey\Filters\expectApplied;
final class SampleWithBrainMonkeyTest extends TestCase {
public function setUp(): void {
$_POST = [];
parent::setUp();
setUp();
}
public function tearDown(): void {
tearDown();
parent::tearDown();
}
public function test_success_process() {
global $wpdb;
$email = '[email protected]';
$_POST['email'] = $email;
expect( 'check_ajax_referer' )
->with( 'subscribe', 'nonce' )
->once();
stubs(
[
'esc_html__',
'wp_unslash',
'sanitize_email',
]
);
$wpdb = Mockery::mock( 'wpdb' );
$wpdb->prefix = 'punk_';
$wpdb
->shouldReceive( 'prepare' )
->once()
->with(
'INSERT INTO ' . $wpdb->prefix . 'subscribers (email) VALUES (%s)',
$email
)
->andReturn( "INSERT INTO punk_subscribers (email) VALUES ('$email')" );
$wpdb
->shouldReceive( 'query' )
->with( "INSERT INTO punk_subscribers (email) VALUES ('$email')" )
->once()
->andReturn( 1 );
expect( 'wp_send_json_success' )
->with( 'You have successfully subscribed' )
->once();
expectDone( 'subscriber_added' )
->once()
->with( $email );
expectApplied( 'subscriber_email' )
->once()
->with( $email )
->andReturn( $email );
$process = new Process();
$process->save();
}
public function test_add_hooks() {
$process = new Process();
$process->add_hooks();
$this->assertEquals( 10, has_action( 'wp_ajax_save_form', [ $process, 'save' ] ) );
$this->assertEquals( 10, has_action( 'wp_ajax_nopriv_save_form', [ $process, 'save' ] ) );
}
}
expect
— function to mock any WordPress or 3rd party plugins/themes functions. Then you could use the mock’ methods to describe arguments, how many times this function will call, and which result will be returned.
stubs
— function to bulk mock for simple functions that return their first argument.
Mockery::mock( 'wpdb' )
— construction of Mockery (awesome library for mocking objects). Also, you could describe methods that will call, describe the method’ arguments, how many times this method will call and which result will be returned.
expectDone
— function to check, that will run do_action function.
expectApplied
— function to check, that will apply some filter.
has_action
— function to check, that action was added. Also, you can use has_filter
for filters.
Testing with WP_Mock
<?php
use wppunk\Subscribe\Process;
use PHPUnit\Framework\TestCase;
final class SampleWithWPMockTest extends TestCase {
public function setUp(): void {
$_POST = [];
parent::setUp();
WP_Mock::setUp();
}
public function tearDown(): void {
WP_Mock::tearDown();
Mockery::close();
parent::tearDown();
}
public function test_success_process() {
global $wpdb;
$email = '[email protected]';
$_POST['email'] = $email;
WP_Mock::userFunction( 'check_ajax_referer' )->
with( 'subscribe', 'nonce' )->
once();
WP_Mock::passthruFunction( 'wp_unslash' );
WP_Mock::passthruFunction( 'sanitize_email' );
$wpdb = Mockery::mock( 'wpdb' );
$wpdb->prefix = 'punk_';
$wpdb
->shouldReceive( 'prepare' )
->once()
->with(
'INSERT INTO ' . $wpdb->prefix . 'subscribers (email) VALUES (%s)',
$email
)
->andReturn( "INSERT INTO punk_subscribers (email) VALUES ('$email')" );
$wpdb
->shouldReceive( 'query' )
->with( "INSERT INTO punk_subscribers (email) VALUES ('$email')" )
->once()
->andReturn( 1 );
WP_Mock::userFunction( 'wp_send_json_success' )->
with( 'You have successfully subscribed' )->
once();
WP_Mock::expectAction( 'subscriber_added', $email );
WP_Mock::expectFilter( 'subscriber_email', $email );
$process = new Process();
$process->save();
}
public function test_add_hooks() {
$process = new Process();
WP_Mock::expectActionAdded( 'wp_ajax_save_form', [ $process, 'save' ] );
WP_Mock::expectActionAdded( 'wp_ajax_nopriv_save_form', [ $process, 'save' ] );
$process->add_hooks();
}
}
WP_Mock::userFunction
— static method to mock any WordPress or 3rd party plugins/themes. Then you can use the methods to describe arguments, how many times this function will call, and which result will be returned.
WP_Mock::passthruFunction
— static method to mock for simple functions that return their first argument.
Mockery::mock( 'wpdb' )
— you already know.
WP_Mock::expectAction
— static method to check that will run do_action function.
WP_Mock::expectFilter
— static method to check that will apply some filter.
WP_Mock::expectActionAdded
— static method to check that action was added. Also, you can use WP_Mock::expectFilterAdded
static method to check the add_filter
function.
Tests coverage
Tests coverage is PHPUnit metrics that show how many classes/methods/lines were touched during testing.
Let’s add to the tests/php/phpunit.xml
file coverage settings:
<?xml version="1.0"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
bootstrap="./bootstrap.php"
colors="true"
convertErrorsToExceptions="true"
convertNoticesToExceptions="true"
convertWarningsToExceptions="true"
beStrictAboutTestsThatDoNotTestAnything="false"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.3/phpunit.xsd"
>
<coverage>
<include>
<directory suffix=".php">../../src/</directory>
</include>
</coverage>
<testsuites>
<testsuite name="your-plugin-tests">
<directory suffix=".php">./tests/</directory>
</testsuite>
</testsuites>
</phpunit>
Then, you need to enable Xdebug or PCOV in your php config.
And then run your tests using command:
vendor/bin/phpunit -c tests/php/phpunit.xml --coverage-text
As you could guess, we need to write two more tests for cases that return errors (wp_json_send_error
) for full coverage of our code.
Summarize
WordPress hasn’t installed infrastructure for unit testing how it is done in popular PHP frameworks, but nothing hard to use a test environment via composer and start writing unit tests for your code. Unit tests should cover a great code. Be great, change your mindset, start testing.
10 minutes to read, a whole life to apply. Great text!