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.
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.zipReadme
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.6— not the latest PHPUnit major version on purpose. WordPress core's own test suite (WP_UnitTestCase, downloaded byinstall-wp-tests.sh) still calls an internal PHPUnit method that was removed in PHPUnit 10.0, so anything 10+ (including 12) currently fails withCall 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 withWP_UnitTestCase. Make sure Composer resolves it viacomposer install/composer updaterather than a standalonephpunit.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 shimWP_UnitTestCaserelies 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:
- Downloads a throwaway copy of WordPress core into
/tmp/wordpress - Downloads the WP PHPUnit test scaffolding into
/tmp/wordpress-tests-lib - Writes
/tmp/wordpress-tests-lib/wp-tests-config.phpwith your DB credentials - Creates (or resets) the
wordpress_testdatabase
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:
tests/WPUTD_Test_Pricing.php— plain PHPUnit basics:setUp(),assertEqualsvsassertSame,assertEqualsWithDeltafor floats,expectException, and@dataProviderfor running one test against many inputs.tests/WPUTD_Test_Validator.php— more@dataProviderexamples, plusassertTrue/assertFalse,assertEmpty,assertNotEquals.tests/WPUTD_Test_Book_CPT.php— your firstWP_UnitTestCase: checking things got registered (post_type_exists), usingself::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.tests/WPUTD_Test_Book_Repository.php— building a small realistic dataset insetUp()and asserting onWP_Queryresults:assertCount,assertContains/assertNotContains,assertGreaterThan,assertIsArray.tests/WPUTD_Test_Shortcodes.php— testing rendered HTML viado_shortcode():assertStringContainsString,assertStringNotContainsString.tests/WPUTD_Test_Users_Capabilities.php—self::factory()->user->create()with roles,wp_set_current_user(), andcurrent_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:
-
Copy
phpunit.xml.dist,composer.json,bin/install-wp-tests.sh, andtests/bootstrap.phpinto your plugin's root. -
In
tests/bootstrap.php, change therequireinside_wputd_manually_load_plugin()to point at your plugin's main file (the one with thePlugin Name:header). -
For any method that's pure logic (no
get_post,$wpdb,WP_Query, etc.), write a plainPHPUnit\Framework\TestCasetest — fast, no DB. -
For any method that touches WordPress data, write a
WP_UnitTestCasetest and useself::factory()to create whatever data the method needs (posts, users, terms, comments, attachments — factories exist for all of these). -
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.shyet, orWP_TESTS_DIRpoints somewhere else. Re-run step 5. svn: command not found—sudo 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_UnitTestCaserolls back the database after each test automatically, but globals like$_POST(used inWPUTD_Test_Book_CPT.php) need manual cleanup, which is why those testsunset()at the end.