WP Manifestindependent plugin directory
manifest / developer / wp-unit-test-demo

WP Unit Test Demo

Set up and write PHPUnit tests for WordPress plugins on ParrotOS. It's built around a small "book" feature (custom post type, taxonomy, meta fields, a shortcode, a query/repository class, and two plain PHP helper classes) so you can see the most common testing patterns in one place.

by You · github.com/alisajjad57/wp-unit-test-demo

0stars
0forks

Install

No release zip yet. The repository archive installs, but the folder name will carry the branch suffix and updates will not flow:

wp plugin install https://github.com/alisajjad57/wp-unit-test-demo/archive/refs/heads/main.zip

Readme

WP Unit Test Demo — Learning PHPUnit for WordPress Plugins

This is a sandbox plugin for learning how to set up and write PHPUnit tests for WordPress plugins on ParrotOS. It's built around a small "book" feature (custom post type, taxonomy, meta fields, a shortcode, a query/repository class, and two plain PHP helper classes) so you can see the most common testing patterns in one place.


1. How this all fits together

Two kinds of classes, two kinds of tests:

Class Touches WordPress? Test base class Speed
WPUTD_Pricing, WPUTD_Validator No PHPUnit\Framework\TestCase instant, no DB
WPUTD_Book_CPT, WPUTD_Book_Repository, WPUTD_Shortcodes Yes (register_post_type, WP_Query, get_post_meta, ...) WP_UnitTestCase slower, needs a real (temporary) WP + DB

This distinction matters a lot in practice: the more logic you can pull out of "WordPress-aware" classes into small pure classes (validation, pricing math, formatting...), the faster and simpler your tests stay. Reserve WP_UnitTestCase for the parts that genuinely need WordPress running.


2. One-time environment setup (Linux)

You already have PHP, Apache, MySQL, Composer, WP-CLI. You additionally need:

sudo apt update
sudo apt install subversion composer -y
wp scaffold plugin-tests plugin_name

subversion (svn) is required because install-wp-tests.sh pulls the official WordPress test scaffolding from develop.svn.wordpress.org — this is the standard, officially-documented way WordPress core itself recommends for setting up WP_UnitTestCase. It's the "easiest path" because you don't hand-build any of the WP test bootstrapping yourself.

Check your PHP version:

php -v

PHP 8.4 works fine here. This project pins:

  • phpunit/phpunit: ^9.6not the latest PHPUnit major version on purpose. WordPress core's own test suite (WP_UnitTestCase, downloaded by install-wp-tests.sh) still calls an internal PHPUnit method that was removed in PHPUnit 10.0, so anything 10+ (including 12) currently fails with Call to undefined method PHPUnit\Util\Test::parseTestMethodAnnotations(). This is tracked upstream in WordPress Trac ticket #62004 — until that lands, 9.6 is the version that actually works with WP_UnitTestCase. Make sure Composer resolves it via composer install/composer update rather than a standalone phpunit.phar, since recent 9.6.x point releases include the PHP 8.4 compatibility fixes and a phar can bundle an older, noisier build.
  • yoast/phpunit-polyfills: ^2.0 — the compatibility shim WP_UnitTestCase relies on. Install it and mostly forget about it.

On data providers: because this project targets PHPUnit 9.6, the test files use the classic /** @dataProvider foo */ docblock form (PHPUnit 9 doesn't read the newer #[DataProvider('foo')] PHP-attribute syntax at all — that was only added in PHPUnit 10). If you later upgrade this setup once WordPress core supports PHPUnit 10+, that's the one thing you'd need to switch over.


3. Where to put this plugin

Copy this whole wp-unit-test-demo folder into one of your sites' plugin directories, e.g.:

cp -r wp-unit-test-demo /var/www/html/website1/wp-content/plugins/
cd /var/www/html/website1/wp-content/plugins/wp-unit-test-demo

You can activate it in wp-admin if you want to click around it in a browser (the CPT/shortcode become real), but that's optional — the test suite below runs against its own separate, temporary WordPress copy in /tmp, not your real site. Tests never touch website1's live database or files.


4. Install PHP dependencies

composer install

This pulls in PHPUnit and the Yoast polyfills into vendor/.


5. Set up the WordPress test database

Use a dedicated test database — never your live site's database. The WP test suite drops and rebuilds tables in this database on every run, so pointing it at a real site's DB would destroy your content.

If you want the test suite to reuse the same MySQL user/password/host as one of your real sites (just a different database name), grab those credentials from that site's wp-config.php:

grep -E "DB_USER|DB_PASSWORD|DB_HOST" /var/www/html/website1/wp-config.php

Then run the install script with a new, separate database name:

bash bin/install-wp-tests.sh wordpress_test <db_user> <db_password> localhost latest

For example, with a typical local dev setup (root, no password):

bash bin/install-wp-tests.sh wordpress_test root '' localhost latest

This script:

  1. Downloads a throwaway copy of WordPress core into /tmp/wordpress
  2. Downloads the WP PHPUnit test scaffolding into /tmp/wordpress-tests-lib
  3. Writes /tmp/wordpress-tests-lib/wp-tests-config.php with your DB credentials
  4. Creates (or resets) the wordpress_test database

You only need to re-run it if you delete /tmp/wordpress-tests-lib or want a different WP version.


6. Run the tests

From inside the plugin folder:

vendor/bin/phpunit

You should see all tests pass, grouped by file. Useful variations:

# Run just one file
vendor/bin/phpunit tests/WPUTD_Test_Pricing.php

# Run just one test method
vendor/bin/phpunit --filter test_calculate_discount_returns_correct_value

# Verbose output, see each test name as it runs
vendor/bin/phpunit --testdox

7. What to read, in order

To learn the patterns, read the test files in this order — each one introduces new concepts:

  1. tests/WPUTD_Test_Pricing.php — plain PHPUnit basics: setUp(), assertEquals vs assertSame, assertEqualsWithDelta for floats, expectException, and @dataProvider for running one test against many inputs.
  2. tests/WPUTD_Test_Validator.php — more @dataProvider examples, plus assertTrue/assertFalse, assertEmpty, assertNotEquals.
  3. tests/WPUTD_Test_Book_CPT.php — your first WP_UnitTestCase: checking things got registered (post_type_exists), using self::factory()->post->create() to make test posts, self::factory()->term->create() for taxonomy terms, and testing a nonce-guarded save method with and without the nonce present.
  4. tests/WPUTD_Test_Book_Repository.php — building a small realistic dataset in setUp() and asserting on WP_Query results: assertCount, assertContains/assertNotContains, assertGreaterThan, assertIsArray.
  5. tests/WPUTD_Test_Shortcodes.php — testing rendered HTML via do_shortcode(): assertStringContainsString, assertStringNotContainsString.
  6. tests/WPUTD_Test_Users_Capabilities.phpself::factory()->user->create() with roles, wp_set_current_user(), and current_user_can() — the standard pattern for testing anything permission-gated.

Note the filenames: each test file's name must exactly match the class name declared inside it (WPUTD_Test_Pricing.php contains class WPUTD_Test_Pricing). phpunit.xml.dist's <directory prefix="WPUTD_Test_" suffix=".php"> discovery rule depends on this, and it's also required if you ever pass a file directly on the CLI (vendor/bin/phpunit tests/WPUTD_Test_Pricing.php works; a mismatched name would not).


8. Applying this to your own plugins (the wppb.me-style ones)

wppb.me-generated plugins are class-based, typically with a main plugin class plus -admin and -public classes, using $this->method_name() inside — exactly the shape used here. To adapt this setup to one of those:

  1. Copy phpunit.xml.dist, composer.json, bin/install-wp-tests.sh, and tests/bootstrap.php into your plugin's root.

  2. In tests/bootstrap.php, change the require inside _wputd_manually_load_plugin() to point at your plugin's main file (the one with the Plugin Name: header).

  3. For any method that's pure logic (no get_post, $wpdb, WP_Query, etc.), write a plain PHPUnit\Framework\TestCase test — fast, no DB.

  4. For any method that touches WordPress data, write a WP_UnitTestCase test and use self::factory() to create whatever data the method needs (posts, users, terms, comments, attachments — factories exist for all of these).

  5. To call a private/protected method directly in a test (wppb.me templates sometimes make helper methods private), use PHP's Reflection API:

    $method = new ReflectionMethod( $my_object, 'my_private_method' );
    $method->setAccessible( true );
    $result = $method->invoke( $my_object, $arg1, $arg2 );

9. Common assertions cheat-sheet (used throughout these tests)

Assertion Use for
assertEquals($a, $b) loose value equality
assertSame($a, $b) strict equality, type included
assertEqualsWithDelta($a, $b, $delta) floats/prices — avoids rounding false-failures
assertTrue() / assertFalse() booleans, current_user_can(), etc.
assertNull() / assertNotNull() optional return values
assertEmpty() / assertNotEmpty() arrays, strings
assertCount($n, $array) exact array/collection size
assertContains($needle, $array) array membership
assertInstanceOf(Class::class, $obj) object type, e.g. WP_Post, WP_User
assertStringContainsString($needle, $haystack) substrings in rendered HTML
expectException(Class::class) code that should throw
@dataProvider run one test body against many input sets

10. Troubleshooting

  • "Could not find .../wordpress-tests-lib/includes/functions.php" — you haven't run bin/install-wp-tests.sh yet, or WP_TESTS_DIR points somewhere else. Re-run step 5.
  • svn: command not foundsudo apt install subversion.
  • MySQL access denied — double check the user/password you passed match a MySQL account that can create databases (GRANT ALL PRIVILEGES ON *.* TO 'user'@'localhost'; if needed, for a local dev-only account).
  • Tests pass individually but fail together — usually leftover state. WP_UnitTestCase rolls back the database after each test automatically, but globals like $_POST (used in WPUTD_Test_Book_CPT.php) need manual cleanup, which is why those tests unset() at the end.

Read the full README on GitHub →