The practice of WordPress unit testing

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 PHPUnitFrameworkTestCase;

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 PHPUnitFrameworkTestCase;

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 PHPUnitFrameworkTestCase;

final class SampleWithBrainMonkeyTest extends TestCase {
	public function setUp(): void {
		parent::setUp();
		BrainMonkeysetUp();
	}

	public function tearDown(): void {
		BrainMonkeytearDown();
		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 wppunkSubscribe;
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 wppunkSubscribeProcess;
use PHPUnitFrameworkTestCase;
use function BrainMonkeysetUp;
use function BrainMonkeytearDown;
use function BrainMonkeyFunctionsstubs;
use function BrainMonkeyFunctionsexpect;
use function BrainMonkeyActionsexpectDone;
use function BrainMonkeyFiltersexpectApplied;
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          = 'i@wp-punk.com';
		$_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 wppunkSubscribeProcess;
use PHPUnitFrameworkTestCase;

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          = 'i@wp-punk.com';
		$_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.

Источник: wp-punk.com

%d такие блоггеры, как: