Daniel Bachhuber, Founder, Hand Built Reading estimate: 11 minutes
Test Coverage for Your WP REST API Project
Alex the developer is pretty excited about the WordPress REST API. Because the infrastructural components were introduced in WordPress 4.4, they too can use register_rest_route() to easily register their own WP REST API endpoints. In fact, they love registering routes so much that they’re creating API endpoints for every project they work on.
Sound like you too? Are you writing full test coverage for your endpoints as you go? If not, you absolutely need to be, for two primary reasons: security and reliability. If you aren’t writing test coverage for your endpoints, sorry Charlie—your endpoints are probably insecure, and probably behave unexpectedly for clients.
This blog post is everything you need to get started. Also, if you want to see all of this code in context, check out the example repository, which is also linked to throughout the tutorial.
What are you talking about, test coverage?
To start at the beginning, “writing tests” is a way for you, as the developer of a complex application, to define assertions of how the application’s functionality is expected to work. Pairing your tests with a continuous integration system like Travis CI means your suite of tests will be run automatically on every push or pull request, making it much easier to incorporate tests into your development workflow.
As it relates to your WP REST API endpoints, there are two common ways to think about test coverage. “Unit tests” test the smallest testable part of your application (e.g. the phone formatting function in this tutorial). “Integration tests” test groups of application functionality (e.g. the WP REST API endpoints in this tutorial).
Test coverage is additive; the only place to start is at the very beginning. Continual investment over time leads to an increasing amount of test coverage, and greater confidence that your application isn’t breaking unexpectedly as it becomes more complex.
Say, for instance, you’ve written a rad_format_phone_number( $input ) function to format phone numbers within your WordPress application. Your first pass at the function produces something like this:
function rad_format_phone_number( $input ) {
$bits = explode( '-', $input );
return "({$bits[0]}) {$bits[1]}-{$bits[2]}";
}
To ensure the function works as expected, you write a test case for it like this:
function test_format_phone_number() {
$this->assertEquals( '(555) 212-2121', rad_format_phone_number( '555-212-2121' ) );
}
You run phpunit to see if the test passes—and it does, woo hoo!
But wait, what if a user passes a value like 5552122121 or +1 (555) 212 2121? Or even an empty string? Well, you then need to spend a bit more time to make sure your function can handle these alternative formats, as well as the original input format you created the function for.
Using a methodology called Test-Driven Development, you can actually write the test cases first, and then adapt your function until the tests pass.
function test_format_phone_number() {
$this->assertEquals( '(555) 212-2121', rad_format_phone_number( '555-212-2121' ) );
$this->assertEquals( '(555) 212-2121', rad_format_phone_number( '5552122121' ) );
$this->assertEquals( '(555) 212-2121', rad_format_phone_number( '+1 (555) 212 2121' ) );
$this->assertEquals( '', rad_format_phone_number( '' ) );
}
Twenty minutes of regex later, you’ve created a function to handle the assertions above:
function rad_format_phone_number( $input ) {
if ( preg_match( '#([\d]{3})[^\d]*([\d]{3})[^\d]*([\d]{4})#', $input, $matches ) ) {
return "({$matches[1]}) {$matches[2]}-{$matches[3]}";
}
return '';
}
Congratulations! You’ve introduced test coverage into your code.
Why is test coverage even more important with a WP REST API project?
“Ok, I already knew how to write unit tests, and my application doesn’t need to format any phone numbers,” you might be thinking. “Why are these tests so important for my WP REST API project?”
For two very important reasons: security and reliability.
Because the WP REST API offers a direct read/write interface into WordPress, you need to make absolutely sure you:
- Aren’t unintentionally disclosing private information to unauthorized requests.
- Aren’t unintentionally permitting unauthorized requests to perform write operations on your application.
You may be manually verifying the security of your endpoints while building your WordPress-based application, but test coverage enables you to make those security assertions explicit.
Furthermore, even if your WP REST API endpoints are read-only and don’t deal with private information, you want to make sure your application returns consistent responses. The clients built on top of your API expect consistent responses above all else—and can break unexpectedly when they receive unexpected data.
OK, you’ve convinced me. How should I write test coverage for my WP REST API endpoints?
If you’re familiar with PHPUnit and the WordPress project’s PHPUnit test suite, then you’re already part of the way there. If you’re not, you’ll want to get yourself up to speed, and then come back to this tutorial. You can also open the entire test class in a separate tab if you’d like to refer to it as we go along.
To make it possible to test your registered WP REST API endpoint in a PHPUnit test, you’ll need to first set up a WP_REST_Server instance for your test class. If you just have one test class, you can perform this step in the Tests_REST_API_Demo::setUp() method:
public function setUp() {
parent::setUp();
global $wp_rest_server;
$this->server = $wp_rest_server = new WP_REST_Server;
do_action( 'rest_api_init' );
}
The call to rest_api_init ensures your routes are registered to the server within the test. Make sure you also reset the $wp_rest_server global on Tests_REST_API_Demo::tearDown():
public function tearDown() {
parent::tearDown();
global $wp_rest_server;
$wp_rest_server = null;
}
Let’s imagine we want to make this phone number accessible through the WP REST API. However, because a phone number is semi-private information, it should only be readable by logged-in users and editable by administrators.
Switching to the plugin file, our first attempt at registering our WP REST API endpoint looks like this:
register_rest_route( 'rad/v1', 'site-info', array(
array(
'methods' => 'GET',
'callback' => function( $request ) {
return array(
'phone_number' => get_option( 'phone_number' ),
);
},
),
array(
'methods' => 'POST',
'callback' => function( $request ) {
update_option( 'phone_number', $request['phone_number'] );
return array(
'phone_number' => get_option( 'phone_number' ),
);
},
),
) );
Because we have $this→server available on our test class, we can create a WP_REST_Request object, dispatch it on WP_REST_Server, and inspect what the server includes on WP_REST_Response . In the example that follows, notice how we test both the response data and the response status. Clients interpret HTTP status codes to have a higher-level understanding of the type of response, so we want to also make sure we’re returning the proper status code.
public function test_get() {
$request = new WP_REST_Request( 'GET', '/rad/v1/site-info' );
$response = $this->server->dispatch( $request );
$this->assertResponseStatus( 200, $response );
$this->assertResponseData( array(
'phone_number' => '(555) 212-2121',
), $response );
}
public function test_update() {
$request = new WP_REST_Request( 'POST', '/rad/v1/site-info' );
$request->set_param( 'phone_number', '(111) 222-3333' );
$response = $this->server->dispatch( $request );
$this->assertResponseStatus( 200, $response );
$this->assertResponseData( array(
'phone_number' => '(111) 222-3333',
), $response );
$this->assertEquals( '(111) 222-3333', get_option( 'phone_number' ) );
}
Uh oh! If the warning bells aren’t going off already, the endpoint we’ve registered is hugely insecure; any request, including logged-in and logged-out users can both read or update our phone number. We need to patch this right away.
Because we’re practicing Test-Driven Development, we first write failing tests (changeset) for the security vulnerability (see the actual pull request on Github). Our tests of our WP REST API endpoints now look like this:
public function test_get_unauthorized() {
wp_set_current_user( 0 );
$request = new WP_REST_Request( 'GET', '/rad/v1/site-info' );
$response = $this->server->dispatch( $request );
$this->assertResponseStatus( 401, $response );
}
public function test_get_authorized() {
wp_set_current_user( $this->subscriber );
$request = new WP_REST_Request( 'GET', '/rad/v1/site-info' );
$response = $this->server->dispatch( $request );
$this->assertResponseStatus( 200, $response );
$this->assertResponseData( array(
'phone_number' => '(555) 212-2121',
), $response );
}
public function test_update_unauthorized() {
wp_set_current_user( $this->subscriber );
$request = new WP_REST_Request( 'POST', '/rad/v1/site-info' );
$request->set_param( 'phone_number', '(111) 222-3333' );
$response = $this->server->dispatch( $request );
$this->assertResponseStatus( 403, $response );
$this->assertEquals( '(555) 212-2121', get_option( 'phone_number' ) );
}
public function test_update_authorized() {
wp_set_current_user( $this->administrator );
$request = new WP_REST_Request( 'POST', '/rad/v1/site-info' );
$request->set_param( 'phone_number', '(111) 222-3333' );
$response = $this->server->dispatch( $request );
$this->assertResponseStatus( 200, $response );
$this->assertResponseData( array(
'phone_number' => '(111) 222-3333',
), $response );
$this->assertEquals( '(111) 222-3333', get_option( 'phone_number' ) );
}
For the tests above, there a couple key details to note.
- wp_set_current_user() lets us set the scope of the test to a given user that already exists. Because our tests are against the endpoint itself, and not the authentication system WordPress uses to verify the response, we can safely assume the current user within the scope of the code is the actual user making the request. If authentication fails, WordPress will wp_set_current_user( 0 ); , which is functionally equivalent to a logged out request.
- It’s incredibly important to take to heart the difference between authentication and authorization. Authentication refers to whether or not a request is associated with a valid user in the system. Authorization refers to whether or not a given user has permission to perform a given action. Even though a user may be authenticated, they might not be authorized. Your WP REST API endpoint should return a 401 when a user isn’t authenticated, and a 403 when a user isn’t authorized.
- assertResponseStatus()and assertResponseData() are helper methods you are more than welcome to copy into your own test suite.
Given our new knowledge about authentication and authorization, we can update our endpoint to use the permission_callback to authorize the request before our callback handles it.
add_action( 'rest_api_init', function() {
register_rest_route( 'rad/v1', 'site-info', array(
array(
'methods' => 'GET',
'callback' => function( $request ) {
return array(
'phone_number' => get_option( 'phone_number' ),
);
},
'permission_callback' => function() {
if ( is_user_logged_in() ) {
return true;
}
return new WP_Error(
'rad_unauthorized',
'You do not have permission to view this resource.',
array( 'status' => 401 ) );
},
),
array(
'methods' => 'POST',
'callback' => function( $request ) {
update_option( 'phone_number', $request['phone_number'] );
return array(
'phone_number' => get_option( 'phone_number' ),
);
},
'permission_callback' => function() {
if ( current_user_can( 'manage_options' ) ) {
return true;
}
return new WP_Error(
'rad_unauthorized',
'You do not have permission to update this resource.',
array( 'status' => is_user_logged_in() ? 403 : 401 ) );
},
),
) );
} );
We’ve made huge progress in the security of our endpoints, but we’re permitting authorized requests to write arbitrary data to the database.
To be as helpful as possible to clients, let’s adapt our endpoint to only accept input when the data is close to a phone number, and ensure our response data is formatted as a phone number or empty string.
Again, because we’re practicing Test-Driven Development, we first write failing tests (see the actual pull request on Github). These failing tests look like this:
public function test_get_authorized_reformatted() {
update_option( 'phone_number', '555 555 5555' );
wp_set_current_user( $this->subscriber );
$request = new WP_REST_Request( 'GET', '/rad/v1/site-info' );
$response = $this->server->dispatch( $request );
$this->assertResponseStatus( 200, $response );
$this->assertResponseData( array(
'phone_number' => '(555) 555-5555',
), $response );
}
public function test_get_authorized_invalid_format() {
update_option( 'phone_number', 'will this work?' );
wp_set_current_user( $this->subscriber );
$request = new WP_REST_Request( 'GET', '/rad/v1/site-info' );
$response = $this->server->dispatch( $request );
$this->assertResponseStatus( 200, $response );
$this->assertResponseData( array(
'phone_number' => '',
), $response );
}
public function test_update_authorized_reformatted() {
wp_set_current_user( $this->administrator );
$request = new WP_REST_Request( 'POST', '/rad/v1/site-info' );
$request->set_param( 'phone_number', '555 555 5555' );
$response = $this->server->dispatch( $request );
$this->assertResponseStatus( 200, $response );
$this->assertResponseData( array(
'phone_number' => '(555) 555-5555',
), $response );
$this->assertEquals( '(555) 555-5555', get_option( 'phone_number' ) );
}
public function test_update_authorized_empty() {
wp_set_current_user( $this->administrator );
$request = new WP_REST_Request( 'POST', '/rad/v1/site-info' );
$request->set_param( 'phone_number', '' );
$response = $this->server->dispatch( $request );
$this->assertResponseStatus( 200, $response );
$this->assertResponseData( array(
'phone_number' => '',
), $response );
$this->assertEquals( '', get_option( 'phone_number' ) );
}
public function test_update_authorized_invalid_format() {
wp_set_current_user( $this->administrator );
$request = new WP_REST_Request( 'POST', '/rad/v1/site-info' );
$request->set_param( 'phone_number', 'will this work?' );
$response = $this->server->dispatch( $request );
$this->assertResponseStatus( 400, $response );
$this->assertResponseData( array(
'message' => 'Invalid parameter(s): phone_number',
), $response );
$this->assertEquals( '(555) 212-2121', get_option( 'phone_number' ) );
}
Given our new knowledge about making to sure consistently handle data, we can update our endpoint to register the phone_number resource argument with a validation callback, and make sure to return data through our rad_format_phone_number() function:
register_rest_route( 'rad/v1', 'site-info', array(
array(
'methods' => 'GET',
'callback' => function( $request ) {
return array(
'phone_number' => rad_format_phone_number( get_option( 'phone_number' ) ),
);
},
'permission_callback' => function() {
if ( is_user_logged_in() ) {
return true;
}
return new WP_Error(
'rad_unauthorized',
'You are not authorized to view this resource.',
array( 'status' => 401 ) );
},
),
array(
'methods' => 'POST',
'callback' => function( $request ) {
update_option( 'phone_number', rad_format_phone_number( $request['phone_number'] ) );
return array(
'phone_number' => get_option( 'phone_number' ),
);
},
'permission_callback' => function() {
if ( current_user_can( 'manage_options' ) ) {
return true;
}
return new WP_Error(
'rad_unauthorized',
'You are not authorized to update this resource.',
array( 'status' => is_user_logged_in() ? 403 : 401 ) );
},
'args' => array(
'phone_number' => array(
'validate_callback' => function( $value ) {
if ( '' === $value ) {
return true;
}
if ( $value && rad_format_phone_number( $value ) ) {
return true;
}
return false;
},
),
),
),
) );
Et, voila!
The end of this tutorial is only the beginning
First of all, give yourself a pat on the back for making it this far. The client developers of your future WordPress-based REST API thank you greatly for your persistence.
To summarize what you’ve learned:
- Test coverage is critically important for two reasons: security and reliability. You want to make triply sure your API isn’t disclosing private information, permitting unauthorized operations, and responds consistently to correct and incorrect client requests.
- Using the WordPress project’s PHPUnit test suite, you can write integration tests for your endpoints. Include assertions for both the response data and the response status. For every successful request test you write, include 4 or 5 permutations of erred requests.
- Clients will always send your application unexpected or incorrect data. If your endpoints can provide consistent, clear, and expected responses, then the client developer’s life will be greatly improved, as they won’t have to spend hours or days trying to debug cryptic errors from an application they don’t have access to.
Topics
Discover More
Safely Publish to Web from Google Docs with Pantheon Content Publisher
Roland Benedetti (Senior Director, Product) and Zack Rosen (Co-Founder)
Reading estimate: 7 minutes
Unifying Content and Code: Inside Pantheon’s Vision for Content Operations
Chris Yates
Reading estimate: 5 minutes
How Pantheon Protects Your Site from Software Supply Chain Risks in Open Source
Steve Persch
Reading estimate: 8 minutes