Skip to main content
Adzbyte
TutorialsWordPress

WordPress Plugin Testing with PHPUnit: Setup and Assertions

Adrian Saycon
Adrian Saycon
September 28, 20264 min read
WordPress Plugin Testing with PHPUnit: Setup and Assertions

WordPress plugin tests become useful when they run against a bootstrapped WordPress installation, create fixtures through core factories, isolate database changes, and assert behavior rather than implementation details. This tutorial sets up a PHPUnit integration suite for a small plugin function that calculates reading time and stores it in post metadata. The result is a local command developers can run before every commit and a foundation that CI can execute across supported environments. It also establishes a clear division between quick logic tests and WordPress integration tests.

Separate pure logic from WordPress integration

The calculation can be a pure function, while metadata storage uses WordPress APIs. This split gives fast unit coverage without pretending WordPress hooks do not exist.

function adz_estimated_minutes( $content, $words_per_minute = 225 ) { $words_per_minute = max( 1, absint( $words_per_minute ) ); $text = wp_strip_all_tags( strip_shortcodes( (string) $content ) ); $words = str_word_count( $text ); return max( 1, (int) ceil( $words / $words_per_minute ) ); } function adz_update_reading_time( $post_id ) { if ( wp_is_post_revision( $post_id ) ) { return; } $post = get_post( $post_id ); if ( ! $post || 'post' !== $post->post_type ) { return; } update_post_meta( $post_id, '_adz_reading_minutes', adz_estimated_minutes( $post->post_content ) ); }

Tests should cover the function directly and the save behavior through the relevant hook or public function.

Install development dependencies

Add PHPUnit and the WordPress PHPUnit test library in require-dev, matching versions to the PHP range your plugin supports. Use Composer scripts so contributors do not need to memorize paths.

{ "scripts": { "test": "phpunit --configuration phpunit.xml.dist", "test:unit": "phpunit --testsuite unit", "test:integration": "phpunit --testsuite integration" } }

Commit composer.lock for an application-like private plugin when reproducibility matters; for a broadly distributed library-style plugin, document the dependency policy explicitly.

Create a stable PHPUnit configuration

Define separate suites, fail on risky tests, display deprecations, and use a test bootstrap that loads WordPress then activates the plugin.

<phpunit bootstrap="tests/bootstrap.php" colors="true" failOnRisky="true"><testsuites><testsuite name="unit"><directory>tests/unit</directory></testsuite><testsuite name="integration"><directory>tests/integration</directory></testsuite></testsuites><php><env name="WP_ENVIRONMENT_TYPE" value="local"/></php></phpunit>

Never point the suite at a development or production database containing valuable data. The WordPress test installer creates and resets tables.

Bootstrap WordPress and the plugin

The exact bootstrap path depends on the selected test library, but the responsibilities are constant: locate the WordPress test suite, register a hook that loads the plugin, then start WordPress.

$_tests_dir = getenv( 'WP_TESTS_DIR' ) ?: '/tmp/wordpress-tests-lib'; require_once $_tests_dir . '/includes/functions.php'; tests_add_filter( 'muplugins_loaded', function () { require dirname( __DIR__ ) . '/adz-reading-time.php'; } ); require $_tests_dir . '/includes/bootstrap.php';

Fail with a clear setup message if the directory is missing. Silent fallback paths waste debugging time on new machines.

Test pure calculations with data providers

Use multiple inputs without duplicating test methods: empty content, HTML, shortcodes, exactly one page of words, and a custom reading rate. Assert the behavior promised to users, such as a minimum of one minute.

Keep pure tests independent of database fixtures where possible. They will remain fast and make edge-case coverage inexpensive.

Use factories for integration fixtures

class Reading_Time_Integration_Test extends WP_UnitTestCase { public function test_updates_meta_for_posts() { $post_id = self::factory()->post->create( array( 'post_type' => 'post', 'post_content' => str_repeat( 'word ', 450 ) ) ); adz_update_reading_time( $post_id ); $this->assertSame( '2', get_post_meta( $post_id, '_adz_reading_minutes', true ) ); } public function test_ignores_pages() { $page_id = self::factory()->post->create( array( 'post_type' => 'page', 'post_content' => str_repeat( 'word ', 450 ) ) ); adz_update_reading_time( $page_id ); $this->assertSame( '', get_post_meta( $page_id, '_adz_reading_minutes', true ) ); } }

WordPress factories produce valid objects and the test case resets database state between tests. Avoid fixtures that depend on IDs from previous methods.

Assert hooks without overfitting

One test can confirm the callback is attached at the documented priority. Most tests should invoke the public behavior and inspect outcomes. Assertions against private helper call order make refactoring expensive without protecting users.

When a hook is the behavior—such as a filter contract—call apply_filters() or do_action() in the test. That catches registration mistakes as well as callback mistakes.

Control global state and time

Tests involving the current user, locale, timezone, cache, or global query must set and restore that state. Use factories for roles and posts, wp_set_current_user() for capabilities, and teardown methods for custom filters. Do not let one test’s global state decide whether another passes.

Build a practical local loop

  1. Run the focused file while developing.
  2. Run the full unit and integration suite before committing.
  3. Run static analysis and coding standards separately so failures remain understandable.
  4. Keep the suite fast enough that developers do not avoid it.
  5. Move slow browser and multisite scenarios into clearly named suites.

Measure test duration periodically. Repeated full WordPress bootstraps and oversized fixtures are common sources of avoidable slowness.

Keep failures readable by giving tests behavior-oriented names and limiting each one to a coherent contract. A test can contain several assertions when they describe one outcome, such as response plus stored metadata. Split tests when setup or failure meaning diverges. Developers fix suites they can understand quickly; overly broad tests become ignored alarms and discourage the frequent local runs that make regression coverage effective.

Use tests as compatibility evidence

A passing suite should show that the plugin behaves correctly with WordPress, not merely that isolated PHP methods return expected values. Pure tests cover calculations; integration tests cover hooks, metadata, permissions, and core behavior. WordPress’s PHPUnit handbook explains the core test environment. Keep fixtures local, assertions user-focused, and setup reproducible so the suite can become the evidence behind upgrades and releases.

Photo by Jakub Zerdzicki on Pexels.

Adrian Saycon

Written by

Adrian Saycon

A developer with a passion for emerging technologies, Adrian Saycon focuses on transforming the latest tech trends into great, functional products.

Discussion (0)

Sign in to join the discussion

No comments yet. Be the first to share your thoughts.

Latest Articles

From the Blog

View all articles