Clicking through the admin after every change does not scale, and it misses the bugs that matter: a booking saved with the wrong user, a REST route that suddenly accepts anonymous requests. This lesson sets up the official WordPress test suite for the events plugin and writes integration tests: tests that run your code inside a real WordPress with a real database, so they check behaviour, not just syntax.
Unit tests and integration tests
A unit test runs one class in isolation, with WordPress functions replaced by fakes. It is fast, but plugin code is mostly glue between WordPress APIs, so heavy faking tests your fakes more than your plugin.
An integration test boots WordPress against a dedicated test database and calls your code for real. WordPress core tests itself this way, and the tooling is available to plugins. Most plugin teams write mainly integration tests and keep unit tests for pure logic, such as price calculations.
Scaffolding the test suite
WP-CLI generates the files:
cd wp-content/plugins/myplugin
wp scaffold plugin-tests myplugin
It creates:
| File | Purpose |
|---|---|
phpunit.xml.dist | PHPUnit configuration: bootstrap file and test folder |
tests/bootstrap.php | Loads the WordPress test library and your plugin |
tests/test-sample.php | An example test to delete later |
bin/install-wp-tests.sh | Downloads WordPress and the test library, creates the test database |
| A CI configuration file | Runs the suite on a CI service; choose the provider with --ci |
Install PHPUnit and the polyfills library that the WordPress test library requires:
composer require --dev phpunit/phpunit yoast/phpunit-polyfills
Use a PHPUnit major version that the WordPress test library supports; its documentation and the polyfills library's readme list the compatible range.
Now create the test environment. The arguments are the database name, user, password, host and WordPress version:
bash bin/install-wp-tests.sh wordpress_test root '' 127.0.0.1 latest
Use a separate database that holds nothing you care about. The test suite installs a fresh WordPress into it and deletes its data as tests run. Never point it at your development site's database.
The script installs WordPress and the test library into your system's temporary folder, then writes a wp-tests-config.php there. The bootstrap finds them through the WP_TESTS_DIR environment variable, or the temporary folder by default.
The bootstrap
The scaffolded bootstrap loads the plugin on muplugins_loaded, before normal plugins, so it is active in every test. Adapt it to load Composer's autoloader and create the bookings table:
<?php
// myplugin/tests/bootstrap.php
$_tests_dir = getenv( 'WP_TESTS_DIR' );
if ( ! $_tests_dir ) {
$_tests_dir = rtrim( sys_get_temp_dir(), '/\\' ) . '/wordpress-tests-lib';
}
// Composer: loads the PHPUnit polyfills the test library needs, plus the test classes.
require_once dirname( __DIR__ ) . '/vendor/autoload.php';
// Gives access to tests_add_filter().
require_once $_tests_dir . '/includes/functions.php';
tests_add_filter(
'muplugins_loaded',
static function () {
require dirname( __DIR__ ) . '/myplugin.php';
}
);
// Start up the WordPress testing environment.
require $_tests_dir . '/includes/bootstrap.php';
// Activation hooks do not run in tests, so install the schema explicitly.
MyPlugin\Installer::install();
Activation hooks never fire in the test suite, which mirrors an important real-world fact covered in lesson 20: your plugin must not rely on activation alone to create its tables.
Run the suite:
vendor/bin/phpunit
How WordPress tests isolate data
Test classes extend WP_UnitTestCase. Before each test it starts a database transaction, and afterwards it rolls it back, so rows created in one test never leak into the next. That needs a transactional storage engine, so create custom tables as InnoDB, which dbDelta() does by default on current MySQL and MariaDB servers.
WP_UnitTestCase also provides factories that create realistic WordPress objects in one line:
$event_id = self::factory()->post->create( array( 'post_type' => 'myplugin_event' ) );
$user_id = self::factory()->user->create( array( 'role' => 'subscriber' ) );
$term_id = self::factory()->term->create( array( 'taxonomy' => 'myplugin_event_type' ) );
Factories fill in required fields, so a test only states what matters to it. Use set_up() and tear_down() (with underscores) for per-test setup, as the polyfills expect, and always call the parent method.
An integration test for the repository
<?php
// myplugin/tests/Bookings/RepositoryTest.php
namespace MyPlugin\Tests\Bookings;
use MyPlugin\Bookings\Repository;
use WP_UnitTestCase;
class RepositoryTest extends WP_UnitTestCase {
private Repository $repository;
public function set_up(): void {
parent::set_up();
global $wpdb;
$this->repository = new Repository( $wpdb );
}
public function test_create_stores_a_booking_that_find_returns(): void {
$event_id = self::factory()->post->create( array( 'post_type' => 'myplugin_event' ) );
$user_id = self::factory()->user->create();
$booking_id = $this->repository->create( $event_id, $user_id, 3 );
$this->assertGreaterThan( 0, $booking_id );
$booking = $this->repository->find( $booking_id );
$this->assertNotNull( $booking );
$this->assertSame( $event_id, (int) $booking['event_id'] );
$this->assertSame( $user_id, (int) $booking['user_id'] );
$this->assertSame( 3, (int) $booking['guests'] );
$this->assertSame( 'confirmed', $booking['status'] );
}
public function test_for_user_returns_only_that_users_bookings(): void {
$event_id = self::factory()->post->create( array( 'post_type' => 'myplugin_event' ) );
$alice = self::factory()->user->create();
$bob = self::factory()->user->create();
$this->repository->create( $event_id, $alice, 1 );
$this->repository->create( $event_id, $alice, 2 );
$this->repository->create( $event_id, $bob, 1 );
$this->assertCount( 2, $this->repository->for_user( $alice ) );
$this->assertCount( 1, $this->repository->for_user( $bob ) );
}
}
$wpdb returns every column as a string, hence the (int) casts before assertSame(), which compares types strictly. Each test name describes the behaviour it proves, so a failure reads like a bug report.
Make PHPUnit find tests in subfolders and files named *Test.php. In phpunit.xml.dist, the test suite section becomes:
<testsuites>
<testsuite name="myplugin">
<directory suffix="Test.php">./tests/</directory>
</testsuite>
</testsuites>
The test class is autoloaded through the autoload-dev mapping from lesson 17, MyPlugin\Tests\ to tests/, once you run composer dump-autoload.
Testing a REST route
REST routes can be tested without HTTP. Build a WP_REST_Request, dispatch it with rest_do_request(), and inspect the WP_REST_Response. Permission callbacks, argument validation and your callback all run exactly as they would for a real request.
<?php
// myplugin/tests/Bookings/RestControllerTest.php
namespace MyPlugin\Tests\Bookings;
use WP_REST_Request;
use WP_UnitTestCase;
class RestControllerTest extends WP_UnitTestCase {
private int $event_id;
public function set_up(): void {
parent::set_up();
$this->event_id = self::factory()->post->create(
array(
'post_type' => 'myplugin_event',
'post_status' => 'publish',
)
);
}
private function booking_request( int $guests ): WP_REST_Request {
$request = new WP_REST_Request( 'POST', '/myplugin/v1/events/' . $this->event_id . '/bookings' );
$request->set_param( 'guests', $guests );
return $request;
}
public function test_logged_out_users_cannot_book(): void {
wp_set_current_user( 0 );
$response = rest_do_request( $this->booking_request( 2 ) );
$this->assertSame( 401, $response->get_status() );
}
public function test_logged_in_user_creates_a_booking(): void {
$user_id = self::factory()->user->create( array( 'role' => 'subscriber' ) );
wp_set_current_user( $user_id );
$response = rest_do_request( $this->booking_request( 2 ) );
$data = $response->get_data();
$this->assertSame( 201, $response->get_status() );
$this->assertSame( $user_id, (int) $data['user_id'] );
$this->assertSame( 2, (int) $data['guests'] );
}
public function test_guest_count_above_the_limit_is_rejected(): void {
wp_set_current_user( self::factory()->user->create() );
$response = rest_do_request( $this->booking_request( 50 ) );
$this->assertSame( 400, $response->get_status() );
}
}
wp_set_current_user() switches the user for the rest of the test, and the test suite resets it afterwards. Internal requests do not need a nonce: nonces protect cookie-authenticated browser requests, and the test talks to the server directly. The third test proves the maximum in the route's schema is enforced, the kind of guarantee that quietly disappears during a refactor.
What to test first
You do not need full coverage on day one. Start where a bug would hurt most:
- Permissions: every route and form handler refuses the users it should.
- Data integrity: repository methods store and return exactly what they should.
- Edge cases you have already fixed: when you fix a bug, add a test that fails without the fix.
- Upgrade routines: lesson 20's migrations are run once on real data, so test them before release.
Use @group annotations, for example @group rest, to run subsets with vendor/bin/phpunit --group rest.
Running tests in CI
Run the same commands on every push and pull request: start a MySQL service, run composer install, run bin/install-wp-tests.sh against that service, then vendor/bin/phpunit, ideally across your lowest supported and the latest WordPress and PHP versions.
Summary
- Integration tests run your plugin inside a real WordPress with a dedicated test database; they suit plugin code better than heavily mocked unit tests.
wp scaffold plugin-testscreates the PHPUnit configuration, bootstrap, install script and a CI file;bin/install-wp-tests.shprepares a throwaway test database.- Load the plugin on
muplugins_loadedin the bootstrap and create custom tables explicitly, because activation hooks do not run in tests. - Extend
WP_UnitTestCase, create data withself::factory(), and rely on its per-test transaction rollback. - Test REST routes with
WP_REST_Requestandrest_do_request(), switching users withwp_set_current_user().