Developing admin-ajax.php Handlers with PHPUnit and Curl (WordPress WP)

The typical way I’ve made AJAX handlers that hook into admin-ajax.php is with Firebug and little bits of Javascript code to exercise the REST API. The problem with this is that you lose all the development code. This note shows how to use PHPUnit
to write your code as tests, and develop the REST API using something like test driven development (TDD).

This isn’t formally “TDD” because these aren’t unit tests. Even if you wanted to include them in the test suite, they would be a problem because they 1) use the network, and take a long time to run, 2) are hard to run on the staging environment, 3) they are really integration tests, except they don’t integrate the browser and Javascript environments.

Not only that, I don’t write a complete test ahead of time; the testing and coding happen together.

Despite this, it’s just nice to be able to code, and then test, with a single command.

watch -n 5 ./run.sh

Code

The code is at https://github.com/johnkawakami/wp-admin-ajax-phpunit-toy.

Our WP AJAX Handler

WordPress has a new REST API system, but we’re not going
to use it here. Instead, we’re testing against the classic
admin-ajax.php call. If you don’t know how to do this, read
AJAX in Plugins

In our plugin or theme, we have this handler:

namespace JK;

function fe_test_callback($data) {
    $test = $_POST['test'];
    wp_send_json_success(array(
        'test' => $test
    ));
}

add_action('wp_ajax_fe-test', 'JK\fe_test_callback');

Note that I have my code namespaced under JK, so I needed to add the namespace to the handler. I forgot to do this, and went on a long excursion into the WP code 🙁

Also, note that I used a dash in the action name “fe-test”. This is just an old WP convention I’m staying with.

This handler simply finds the “test” parameter, and then returns it as the value of ‘test’ in the result.

The result looks like this:

{"success":true,"data":{"test":"test"}}

It’s JSON formatted. The wp_send_json_success() sends back the result as a JSON object with two properties, success and data. This is standard in WP.

Directory Structure

I’m keeping my tests in a subdirectory of tests.

tests
└── admin-ajax
    ├── tests
    │   └── 001-find.php
    ├── run.sh
    ├── library.php
    └── config.php

Command to create this:

mkdir -p tests/admin-ajax/tests
cd tests/admin-ajax
touch run.sh
chmod u+x run.sh

During development, I just keep running run.sh, which is a one liner:

run.sh

#! /bin/bash

clear
phpunit --bootstrap ~/.composer/vendor/autoload.php tests/001-find.php

I installed PHPUnit with Composer. It was installed globally to my home directory, with this command:

composer global require phpunit/phpunit

When it’s installed this way, you need to include the autoloader that Composer creates, so that your test script can find the PHPUnit classes.

If you want to install it locally, you can, with this:

composer require phpunit/phpunit

and then execute PHPUnit like this:

vendor/phpunit/phpunit/phpunit tests/001-find.php

Configuration

Our tests need to log in to WordPress. Since we don’t want to keep our passwords in the test scripts (or put them into the repo), we use a config file. The config file also has our site’s URL (which is running on a virtual machine).

config.php

<?php
return array(
"url" => "http://192.168.33.21/",
"username" => "admin",
"password" => "admin"
);

Returning a value from a script is an obscure PHP feature. It allows you to write a code that includes the script, and then assigns this return value to a variable:

$config = include('config.php');
$config['username'];

Utility Functions

Warning – there’s a bug in my library. I didn’t use cURL correctly, so session cookies are
lost between calls to *_url(), which is exactly the opposite of what I wanted. Also,
CURLOPT_FOLLOWLOCATION might also lose cookies, but I’m not certain of that. This note will
be deleted when it’s fixed.

library.php contains some utility functions that help shorten our code. I won’t get into it here, but here are the functions and constants defined within it:

define('COOKIEFILE', '/tmp/curl_cookies');
function get_url($url)
function post_url($url, $data)
function erase_cookies()
function url($path)
function parse_headers($text)
function response_body($text)

url() prepends the config’s URL to your path.
get_url() and post_url() get a url and return the response, both the headers and body. You then use parse_headers() to extract the headers as an associative array, and use response_body() to extract the body.

erase_cookies() deletes the COOKIEFILE. The COOKIEFILE is used to remember your login.

See the code for details.

The Test Samples

The tests are kept in the ajax-admin/tests directory.

tests/001-find.php:

<?php

use PHPUnit\Framework\TestCase;

include('library.php');
$config = include('config.php');

class APITest extends TestCase 
{
    public function setUp() 
    {
        global $config;
        erase_cookies();
        $login = post_url('/wp-login.php', array(
            'log'=>$config['username'],
            'pwd'=>$config['password'],
            'redirect_to'=>'/wp-admin',
            'testcookie'=>'1'
        ));
    }

    public function testFindAjax() 
    {
        $text = get_url('/wp-admin/admin-ajax.php');
        $headers = parse_headers($text);
        $this->assertEquals(200, $headers['HTTP']['status']);
    }

    public function testAjaxTest()
    {
        $text = post_url('/wp-admin/admin-ajax.php', array(
            'action'=>'fe-test',
            'test'=>'test'
        ));
        $body = response_body($text);
        /*
         *  Body looks like:
         *  {"success":true,"data":{"test":"test"}}
         */
        $json = json_decode($body);
        $this->assertEquals(true, $json->success);
        $this->assertEquals("test", $json->data->test);
    }
}

If you know PHPUnit, this will all look pretty easy. The rest of this article walks through the code.

The boilerplate at the top brings in the PHPUnit classes, our library, and our config.

use PHPUnit\Framework\TestCase;

include('library.php');
$config = include('config.php');

PHPUnit tests are subclasses for TestCase. TestCase has methods like assertEquals, which we used in our tests.

class APITest extends TestCase

The setUp method is run before each test. Our setUp clears out old cookies, then logs us into the wp-admin, and saves our login cookies to a file. (post_url() saves the cookies to a file.)

    public function setUp() 
    {
        global $config;
        erase_cookies();
        $login = post_url('/wp-login.php', array(
            'log'=>$config['username'],
            'pwd'=>$config['password'],
            'redirect_to'=>'/wp-admin',
            'testcookie'=>'1'
        ));
    }

The first test, testFindAjax(), just connects to the API endpoint. If this fails, it might mean the configuration is wrong, or some modification to the libraries broke code.

    public function testFindAjax() 
    {
        $text = get_url('/wp-admin/admin-ajax.php');
        $headers = parse_headers($text);
        $this->assertEquals(200, $headers['HTTP']['status']);
    }

get_url() and post_url() retrieve both the HTTP headers and the body. Here’s what that looks like:

HTTP/1.1 200 OK
Date: Tue, 22 Nov 2016 21:49:28 GMT
Server: Apache/2.4.7 (Ubuntu)
Content-Length: 1
Content-Type: text/html

0

parse_headers() takes the header part and turns it into an associative array that looks like this:

Array
(
    [HTTP] => Array
        (
            [header] => HTTP/1.1 200 OK
            [version] => 1.1
            [status] => 200
            [message] => OK
        )

    [date] => Tue, 22 Nov 2016 21:50:36 GMT
    [server] => Apache/2.4.7 (Ubuntu)
    [content-length] => 1
    [content-type] => text/html
)

Our test assertion checks that the status code is 200.

The second test is to our test function, which we defined above.

    public function testAjaxTest()
    {
        $text = post_url('/wp-admin/admin-ajax.php', array(
            'action'=>'fe-test',
            'test'=>'test'
        ));
        $body = response_body($text);
        /*
         *  Body looks like:
         *  {"success":true,"data":{"test":"test"}}
         */
        $json = json_decode($body);
        $this->assertEquals(true, $json->success);
        $this->assertEquals("test", $json->data->test);
    }

Setting up a call to our endpoint’s a little more elaborate. The second parameter contains the POST data, and the required ‘action’ parameter specifies which handler to call.

In this test, we use response_body() to get the body of the response. Responses can be anything from text to a JSON object, but the standard is to return a JSON object created with the wp_send_json_success() and wp_send_json_error() functions.

We decode the body with json_decode(), which returns an object, not an associative array.

We then test for two expected conditions.

If the endpoint doesn’t return JSON, then json_decode() returns nothing, and the tests will fail with error messages.

Comparison with JS Testing

Arguably, this is inferior to testing from the Javascript side, because it doesn’t involve the browser environment.

I created it because, for what I was doing, setting up a
JS IDE was a little too much. There’s also the mental
hopscotch you need to do when you’re shifting from
JS to PHP, and from Jasmine to PHPUnit. I’d rather write
tests for the plugin in PHPUnit, and then work on the
REST API, and continue to use PHPUnit, and not involve
the browser, or PhantomJS, or other tools.

(Well, this was a real rabbithole. I thought it’d take a few hours, but it took a day.)