How to write unit tests for custom WordPress REST API controllers?

So, you’ve built a custom endpoint for your WordPress site using the REST API, likely with a custom controller. That’s awesome! Now, the question pops up: how do you actually test this thing? Writing unit tests for custom WordPress REST API controllers might sound a bit daunting, but it’s really about setting up your environment and then using WordPress’s own testing tools to mimic requests and check the responses. We’ll walk through the essentials, focusing on practical steps to get your tests up and running.

Before you can write any tests, you need a solid foundation. This means having WordPress set up in a way that allows for automated testing.

The Importance of a Testing Environment

Trying to test your code directly on a live site is a recipe for disaster. You want a separate, isolated environment where you can break things and fix them without impacting your users. This is typically a local development setup or a dedicated staging server.

Setting Up WP_TESTS_DIR

WordPress has a dedicated testing suite, and the first step is to tell it where to find the necessary files. This is done by defining WP_TESTS_DIR.

  • What is WP_TESTS_DIR? This constant points to the wordpress-develop/tests/ directory. This directory contains all the core WordPress test files, including the setup and teardown scripts, and helper functions.
  • Where to get the testing suite? You can download it as part of the WordPress development repository or by cloning the wordpress-develop repository from GitHub.
  • Defining the constant: In your wp-config.php file (for your test installation, not your live site!), you’ll add a line like this:

“`php

define( ‘WP_TESTS_DIR’, ‘/path/to/your/wordpress-develop/tests/’ );

“`

Make sure the path is correct for your local setup.

Installing WordPress for Testing

The testing suite needs a WordPress installation to run against. This isn’t your regular WordPress installation; it’s an installation specifically created for tests.

  • The install.php script: The testing suite comes with an install.php script that sets up a fresh WordPress database and installs a minimal WordPress core.
  • Running the installation: You typically run this script from the command line. Navigate to the wp-tests-configs-sample.php file (which you’ll copy to wp-tests-config.php), edit it to include your database credentials, and then execute the installation script.
  • Database setup: This script will create the necessary database tables for WordPress. Ensure your local database server is running and accessible with the credentials you provide.

The wp-tests-config.php File

This file is crucial. It holds your database connection details and other configurations for the testing environment.

  • Copying the sample: You’ll find a wp-tests-configs-sample.php in the wordpress-develop/tests/ directory. Copy this to wp-tests-config.php in the same directory.
  • Database credentials: Edit wp-tests-config.php to reflect your local database name, username, and password.
  • Domain and URL: You’ll also need to set WP_TESTS_DOMAIN and WP_TESTS_UTIL_DOMAIN. These are typically set to simple, non-resolving domains like example.com and example.org for local testing.

When developing custom WordPress REST API controllers, it’s essential to ensure that your code is robust and performs well. A related article that can help you optimize your website’s performance is titled “How to Improve Your Website’s Performance Using Google PageSpeed Insights.” This article provides valuable insights on how to analyze and enhance your site’s speed, which is crucial when implementing new features like REST API endpoints. You can read it here: How to Improve Your Website’s Performance Using Google PageSpeed Insights.

Understanding WordPress REST API Testing Basics

Now that your environment is set up, let’s dive into how WordPress handles REST API testing. It leverages PHPUnit, the standard PHP testing framework.

Leveraging PHPUnit

PHPUnit is the de facto standard for unit testing in PHP. WordPress’s testing suite is built on top of it, so you’ll be interacting with PHPUnit test cases.

  • Test classes and methods: Your tests will be organized into classes that extend WP_UnitTestCase. Each test method within these classes will start with test_.
  • Assertions: PHPUnit provides a rich set of assertion methods (like assertEquals, assertTrue, assertContains) to check if your code behaves as expected.
  • Setup and Teardown: Test cases can have setUp() and tearDown() methods. setUp() runs before each test method, and tearDown() runs after. This is where you can create test data or clean up after tests.

Mocking and Simulating Requests

The core of testing a REST API controller is simulating incoming requests and then verifying the outgoing response.

  • The WP_REST_Request class: WordPress uses its own WP_REST_Request class to represent incoming API requests. You’ll instantiate this class to mimic what a client would send.
  • The WP_REST_Response class: Similarly, WP_REST_Response represents the output of your controller. Your tests will assert properties of this response object.
  • $_SERVER and $_GET/$_POST: When you’re not using specific WordPress testing helpers, you might need to manually set global arrays like $_SERVER, $_GET, and $_POST to simulate request parameters and headers. However, the WP_REST_Request object is a much cleaner way to do this.

Key WP_REST_Controller Methods to Test

When you create a custom REST API controller, you’re usually implementing methods like register_routes(), get_items(), get_item(), create_item(), update_item(), and delete_item(). Your tests should target these.

  • register_routes(): While not always a direct unit test target, you’ll want to ensure your routes are registered correctly when your plugin or theme loads. This is often implicitly tested when you test individual route callbacks.
  • Callback methods: The core of your controller’s logic resides in the callback methods for each HTTP verb and route. These are the primary focus of your unit tests.

Writing Your First Controller Unit Tests

Let’s get practical. We’ll assume you have a simple custom controller for managing “books.”

Creating a Test File

You’ll place your test files within the tests/php directory of your plugin or theme. A common convention is to have a tests/php/class-your-plugin-name-rest-api-test.php structure.

“`php

/**

  • Unit tests for the custom REST API controller.

*/

class My_Custom_Book_Controller_Test extends WP_UnitTestCase {

protected $controller;

protected $server;

public function setUp() {

parent::setUp();

// Instantiate your controller

$this->controller = new My_Book_Controller();

// Get the REST server instance

$this->server = rest_get_server();

}

public function tearDown() {

// Clean up any test data or mocks if necessary

parent::tearDown();

}

// Your test methods will go here…

}

“`

Testing Route Registration

First, let’s make sure your controller actually registers its routes when WordPress loads.

Ensuring Routes are Registered

This test checks if your controller’s routes are available through the REST server object.

“`php

/**

  • Test that routes are registered.

*/

public function test_register_routes() {

// Register the routes for your controller

$this->controller->register_routes();

// Get all registered routes from the server

$routes = $this->server->get_routes();

// Assert that you can find your controller’s namespace and specific routes

$this->assertArrayHasKey( ‘my-custom-plugin/v1’, $routes, ‘Namespace not registered.’ );

$this->assertArrayHasKey( ‘/my-custom-plugin/v1/books’, $routes[‘my-custom-plugin/v1’], ‘Books endpoint not registered.’ );

$this->assertArrayHasKey( ‘/my-custom-plugin/v1/books/(?P[\d]+)’, $routes[‘my-custom-plugin/v1’], ‘Single book endpoint not registered.’ );

}

“`

  • $this->controller->register_routes();: This line is crucial. It explicitly calls your controller’s register_routes() method, which is what tells WordPress about your endpoints.
  • $this->server->get_routes();: This method retrieves all the routes that have been registered with the REST server.
  • assertArrayHasKey(): We use this assertion to check if specific keys (your namespace and route paths) exist in the returned array of routes.

Testing the GET /books Endpoint

Now, let’s test the primary endpoint for retrieving a collection of items.

Simulating a GET Request for Books

We need to create a WP_REST_Request object that mimics a GET request to /books.

“`php

/**

  • Test retrieving a collection of books.

*/

public function test_get_books_collection() {

// Create some sample data

$book_id_1 = $this->factory->post->create( array( ‘post_type’ => ‘book’, ‘post_title’ => ‘The Hitchhiker\’s Guide’ ) );

$book_id_2 = $this->factory->post->create( array( ‘post_type’ => ‘book’, ‘post_title’ => ‘Pride and Prejudice’ ) );

// Create a mock request

$request = new WP_REST_Request( ‘GET’, ‘/my-custom-plugin/v1/books’ );

// Dispatch the request to your controller’s callback

$response = $this->server->dispatch( $request );

// Assertions

$this->assertEquals( 200, $response->get_status(), ‘Request should return 200 OK.’ );

$data = $response->get_data();

$this->assertIsArray( $data, ‘Response data should be an array.’ );

$this->assertCount( 2, $data, ‘Response should contain two books.’ );

// Check if specific items are in the response (based on your controller’s output format)

$titles = wp_list_pluck( $data, ‘title’ );

$this->assertContains( ‘The Hitchhiker\’s Guide’, $titles, ‘Book title not found in response.’ );

$this->assertContains( ‘Pride and Prejudice’, $titles, ‘Book title not found in response.’ );

}

“`

  • $this->factory->post->create(...): This is a powerful tool from the WordPress testing suite. It allows you to create WordPress objects (like posts, users, etc.) directly in your test database. Here, we’re creating two sample ‘book’ posts. You’ll need to ensure your post_type ‘book’ is registered in your test setup (e.g., in a my-custom-plugin-tests.php file that wp_tests_main.php loads).
  • new WP_REST_Request( 'GET', '/my-custom-plugin/v1/books' ): This creates a simulated GET request object targeting your specific endpoint.
  • $this->server->dispatch( $request ): This is the magic. It takes your mock request and sends it through the WordPress REST server’s routing and callback mechanisms, returning the WP_REST_Response object.
  • $response->get_status(): Checks the HTTP status code returned by the API.
  • $response->get_data(): Retrieves the actual data payload from the response.
  • assertCount() and assertContains(): These are standard PHPUnit assertions to check the structure and content of the returned data.
  • wp_list_pluck(): A WordPress helper function very useful for extracting a single column of data from an array of associative arrays.

Testing the GET /books/ Endpoint

Now, let’s test fetching a single book by its ID.

Simulating a GET Request for a Specific Book

Similar to the collection test, we’ll craft a request but this time include an ID.

“`php

/**

  • Test retrieving a single book by ID.

*/

public function test_get_single_book() {

// Create a sample book

$book_id = $this->factory->post->create( array( ‘post_type’ => ‘book’, ‘post_title’ => ‘Dune’ ) );

// Create a mock request for a specific book

$request = new WP_REST_Request( ‘GET’, sprintf( ‘/my-custom-plugin/v1/books/%d’, $book_id ) );

// Dispatch the request

$response = $this->server->dispatch( $request );

// Assertions

$this->assertEquals( 200, $response->get_status(), ‘Request should return 200 OK.’ );

$data = $response->get_data();

$this->assertIsArray( $data, ‘Response data should be an array.’ );

$this->assertEquals( ‘Dune’, $data[‘title’], ‘Incorrect book title returned.’ );

$this->assertEquals( (string) $book_id, $data[‘id’], ‘Incorrect book ID returned.’ ); // Ensure ID is string as it usually is in JSON

}

/**

  • Test retrieving a non-existent book.

*/

public function test_get_non_existent_book() {

// Create a mock request for a non-existent book ID

$non_existent_id = 9999; // Assuming this ID doesn’t exist

$request = new WP_REST_Request( ‘GET’, sprintf( ‘/my-custom-plugin/v1/books/%d’, $non_existent_id ) );

// Dispatch the request

$response = $this->server->dispatch( $request );

// Assertions

$this->assertEquals( 404, $response->get_status(), ‘Request for non-existent book should return 404.’ );

}

“`

  • sprintf( '/my-custom-plugin/v1/books/%d', $book_id ): This dynamically creates the URL for the single item endpoint, inserting the ID of the book we created.
  • Checking id and title: We specifically check that the returned data matches the book we created. Note that the ID is often returned as a string in the REST API response, hence the cast to string in the assertion.
  • Testing for 404: It’s important to test edge cases, like requesting an item that doesn’t exist. We expect a 404 Not Found status code in this scenario.

Testing the POST /books Endpoint (Creating a Book)

Creating new items is a key functionality. Your tests should cover successful creation and potential validation errors.

Simulating a POST Request to Create a Book

We’ll build a WP_REST_Request with POST parameters.

“`php

/**

  • Test creating a new book.

*/

public function test_create_book() {

// Prepare the data to send in the request

$book_data = array(

‘title’ => ‘Foundation’,

‘content’ => ‘A classic science fiction novel.’,

‘status’ => ‘publish’,

// Add any other relevant fields your controller handles

);

// Create a mock request with POST data

$request = new WP_REST_Request( ‘POST’, ‘/my-custom-plugin/v1/books’ );

$request->set_body_params( $book_data ); // Use set_body_params for POST data

// Dispatch the request

$response = $this->server->dispatch( $request );

// Assertions

$this->assertEquals( 201, $response->get_status(), ‘Request should return 201 Created.’ );

$data = $response->get_data();

$this->assertIsArray( $data, ‘Response data should be an array.’ );

$this->assertEquals( ‘Foundation’, $data[‘title’], ‘Incorrect book title in response.’ );

$this->assertEquals( ‘A classic science fiction novel.’, $data[‘content’], ‘Incorrect book content in response.’ );

$this->assertEquals( ‘publish’, $data[‘status’], ‘Incorrect book status in response.’ );

// Verify the book was actually created in the database

$created_book_id = $data[‘id’];

$this->assertGreaterThan( 0, $created_book_id, ‘Book ID should be a positive integer.’ );

$post = get_post( $created_book_id );

$this->assertNotFalse( $post, ‘The post should exist in the database.’ );

$this->assertEquals( ‘Foundation’, $post->post_title, ‘Database post title mismatch.’ );

}

/**

  • Test creating a book with missing required fields.

*/

public function test_create_book_missing_title() {

$book_data = array(

// ‘title’ is missing

‘content’ => ‘This book will fail validation.’,

);

$request = new WP_REST_Request( ‘POST’, ‘/my-custom-plugin/v1/books’ );

$request->set_body_params( $book_data );

$response = $this->server->dispatch( $request );

$this->assertEquals( 400, $response->get_status(), ‘Request should return 400 Bad Request due to missing title.’ );

$error_data = $response->get_data();

$this->assertArrayHasKey( ‘code’, $error_data, ‘Error response should contain a code.’ );

$this->assertStringContainsString( ‘missing_param’, $error_data[‘code’], ‘Error code should indicate a missing parameter.’ );

}

“`

  • $request->set_body_params( $book_data ): This is the correct method to set parameters for POST, PUT, and PATCH requests.
  • 201 Created: The standard HTTP status code for successful resource creation.
  • assertGreaterThan(0, $created_book_id): Ensures that an ID was generated, indicating a successful creation.
  • get_post($created_book_id): A direct database check to confirm the item was persisted.
  • Testing Validation: The second test demonstrates how to check for expected validation errors. You’d typically return a 400 Bad Request status with a relevant error code and message.

Testing Permissions and Authentication

A robust API needs to handle who can access what.

Checking Permissions Callbacks

Your REST API endpoints should have permission callbacks to control access.

Testing with Different User Roles

You can use the wp_set_current_user() function to set the current user for your test.

“`php

/**

  • Test that only authenticated users can create books.

*/

public function test_create_book_unauthenticated() {

// Ensure no user is logged in

wp_set_current_user( 0 );

$book_data = array(

‘title’ => ‘Secret Book’,

);

$request = new WP_REST_Request( ‘POST’, ‘/my-custom-plugin/v1/books’ );

$request->set_body_params( $book_data );

$response = $this->server->dispatch( $request );

$this->assertEquals( 401, $response->get_status(), ‘Unauthenticated request should return 401 Unauthorized.’ );

}

/**

  • Test that an administrator can create books.

*/

public function test_create_book_as_admin() {

// Set current user to an administrator

$admin_user_id = $this->factory->user->create( array( ‘role’ => ‘administrator’ ) );

wp_set_current_user( $admin_user_id );

$book_data = array(

‘title’ => ‘Admin Book’,

);

$request = new WP_REST_Request( ‘POST’, ‘/my-custom-plugin/v1/books’ );

$request->set_body_params( $book_data );

$response = $this->server->dispatch( $request );

$this->assertEquals( 201, $response->get_status(), ‘Admin request should return 201 Created.’ );

}

/**

  • Test that a subscriber cannot create books.

*/

public function test_create_book_as_subscriber() {

// Set current user to a subscriber

$subscriber_user_id = $this->factory->user->create( array( ‘role’ => ‘subscriber’ ) );

wp_set_current_user( $subscriber_user_id );

$book_data = array(

‘title’ => ‘Subscriber Book’,

);

$request = new WP_REST_Request( ‘POST’, ‘/my-custom-plugin/v1/books’ );

$request->set_body_params( $book_data );

$response = $this->server->dispatch( $request );

$this->assertEquals( 403, $response->get_status(), ‘Subscriber request should return 403 Forbidden.’ );

}

“`

  • wp_set_current_user( 0 ): Resets the current user to none (unauthenticated).
  • $this->factory->user->create( array( 'role' => '...' ) ): Creates a new user with a specific role for testing.
  • wp_set_current_user( $user_id ): Logs in the created user for the duration of the test.
  • 401 Unauthorized vs. 403 Forbidden: Be clear about the difference. 401 is for missing authentication altogether, while 403 is for authenticated users who lack the necessary permissions.

Using rest_pre_permission_check Filter

You can also test how your controller interacts with the rest_pre_permission_check filter.

  • Hooking into the filter: Your controller’s register_routes() method should be adding the permission callback. You can also test how other plugins might hook into this filter to override or add checks.
  • Simulating filter conditions: If your permission callback relies on specific conditions, you might need to mock those conditions or set up your test data accordingly.

When developing custom WordPress REST API controllers, it’s essential to ensure that your code is robust and reliable through effective unit testing. A great resource that complements this topic is an article on migrating servers, which discusses the intricacies of transferring your WordPress site while maintaining its functionality. You can find it here. This information can be particularly useful when you need to test your API controllers in different environments, ensuring that your unit tests cover all scenarios.

Testing Data Transformation and Sanitization

What your controller receives and what it returns are often different.

Verifying Schema and Params

The REST API uses schemas to define the expected input and output.

Testing get_item_schema() and get_collection_params()

These methods define how your controller expects data and what parameters it accepts.

“`php

/**

  • Test the item schema.

*/

public function test_get_item_schema() {

$schema = $this->controller->get_item_schema();

$this->assertIsArray( $schema, ‘Schema should be an array.’ );

$this->assertArrayHasKey( ‘properties’, $schema, ‘Schema should have properties.’ );

$this->assertArrayHasKey( ‘title’, $schema[‘properties’], ‘Schema should define a title property.’ );

$this->assertEquals( ‘string’, $schema[‘properties’][‘title’][‘type’], ‘Title property should be a string.’ );

$this->assertTrue( $schema[‘properties’][‘title’][‘required’], ‘Title property should be required.’ );

}

/**

  • Test the collection parameters.

*/

public function test_get_collection_params() {

$params = $this->controller->get_collection_params();

$this->assertIsArray( $params, ‘Collection parameters should be an array.’ );

$this->assertArrayHasKey( ‘per_page’, $params, ‘Collection params should include per_page.’ );

$this->assertArrayHasKey( ‘page’, $params, ‘Collection params should include page.’ );

}

“`

  • get_item_schema(): This method defines the structure of a single item, including its properties, types, and whether they are required.
  • get_collection_params(): This defines the pagination and filtering parameters available for collection endpoints (like per_page, page, search).

Validating Input and Sanitizing Output

Your controller should be responsible for cleaning up incoming data and formatting output correctly.

  • rest_sanitize_string() and rest_sanitize_int(): WordPress provides helper functions for sanitizing input. Ensure your controller uses them. Your tests will then verify that the data is cleaned as expected.
  • rest_ensure_response(): This function helps ensure that a response is a WP_REST_Response object, often used in your controller’s callback methods.
  • WP_Error: When validation fails, your controller should return a WP_Error object. Your tests should assert that specific errors are returned.

Testing Edge Cases and Error Handling

Your API needs to be resilient.

Handling Invalid Data Formats

What happens if a client sends JSON when you expect form data, or vice versa?

Simulating Malformed Requests

  • Invalid JSON: If your controller expects JSON, try sending it malformed or as plain text.
  • Incorrect parameter types: Send strings where numbers are expected, or vice versa.
  • Testing for WP_Error: Assert that your controller correctly returns a WP_Error object with an appropriate status code (e.g., 400 Bad Request).

Testing Rate Limiting

If your API is subject to rate limiting, you need to test that too.

  • Simulating multiple requests: Send a burst of requests to test if the rate limiting is being enforced.
  • Checking response headers: See if rate limiting headers (like X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset) are returned correctly.

Testing with Empty/Null Values

How does your controller behave when fields are intentionally left null or empty?

  • Null titles, empty content: Test these scenarios to ensure your controller doesn’t crash and handles them gracefully (e.g., by setting default values or returning specific errors).

Automating Your Tests

Getting your tests to run automatically is key to continuous integration.

Running Tests from the Command Line

PHPUnit is typically run from your terminal.

  • Navigating to the test directory: Open your terminal and cd into the wordpress-develop/tests/ directory of your setup.
  • Executing phpunit: Run the command phpunit. This will discover and run all the tests in your tests/php directory. You can also specify specific test files to run.

Integrating with CI/CD Pipelines

For a professional workflow, you’ll want to integrate these tests into your continuous integration/continuous deployment pipeline.

  • GitHub Actions, GitLab CI, Jenkins: Most CI platforms support running PHPUnit tests. You’ll configure your pipeline to check out your code, set up the WordPress testing environment, and then run the PHPUnit commands.
  • Snapshotting/Database Dumps: For faster test runs in CI, you might consider snapshotting your database after the initial WordPress installation and restoring from that snapshot for each test run.

By following these steps, you’ll be well on your way to writing comprehensive and reliable unit tests for your custom WordPress REST API controllers. This not only gives you confidence in your code but also makes future development and refactoring much smoother. Happy testing!