Unit tests for WordPress with unitest-wp-copy
To check a small plugin class, you don’t always need to install WordPress and set up a test database. If the code sanitizes strings, formats HTML, parses blocks, or processes arrays, it can be tested separately from the site.
The problem occurs when such a class uses WordPress functions: regular PHPUnit doesn’t know anything about sanitize_text_field(), wpautop(), or esc_html().
The doiftrue/unitest-wp-copy package solves this task: it loads the selected WordPress functions and classes without fully running the CMS. Together with it, you can use WP_Mock to define the required behavior in individual test cases.
In this article, we’ll set up a test environment from scratch and test a class that builds an HTML card.
What we will test
A unit test checks a small piece of code: it passes it data, calls a function or method, and compares the result to the expected one.
For example, a method receives a title with HTML markup:
<b>Weekly</b> update
And the test checks that after cleaning and processing, the result should look like this:
<h2>Weekly update</h2>
For this scenario, no database queries are needed, no active theme, plugins, or a request to the site.
For this, we’ll use three tools:
- PHPUnit - The core of tests.
- unitest-wp-copy - Provides a large set of WordPress functions and classes in their original form.
- WP_Mock - Makes it easy to mock PHP functions.
Read the documentation for unitest-wp-copy
Project preparation
As in the original guide, we’ll use the package line for WordPress 7.1 and PHPUnit 9.6.
To run locally, you’ll need PHP and Composer. Below there is also an option to run via Docker and Make.
Let’s create a plugin in the content-card folder. It will contain the following files:
content-card/ ├── content-card.php # Main plugin file ├── src/ │ └── ContentCard.php # The class we will test ├── composer.json # Dependencies and autoloading ├── phpunit.xml # PHPUnit settings ├── tests/ │ ├── bootstrap.php # Test environment preparation │ └── ContentCardTest.php # Tests for the class ├── .gitignore # Exclusions for Git └── Makefile # Optional commands for Docker
All commands below are executed from the content-card plugin directory.
Code we will check
src/ContentCard.php:
<?php
namespace Example\ContentCard;
final class ContentCard {
public function render( string $title, string $content ): string {
$title = sanitize_text_field( $title );
$body = wpautop( make_clickable( wp_kses_post( $content ) ) );
$class = is_multisite()
? 'content-card content-card--network'
: 'content-card';
return strtr(
'<article class="{class}">
<h2>{title}</h2>
<div class="content-card__body">{body}</div>
</article>',
[
'{class}' => esc_attr( $class ),
'{title}' => esc_html( $title ),
'{body}' => $body,
]
);
}
}
The render() method:
- Cleans the title via sanitize_text_field().
- Removes invalid HTML tags from the content via wp_kses_post().
- Turns text URLs into links via make_clickable().
- Adds paragraphs via wpautop().
- Adds the CSS class
content-card--networkin Multisite mode. - Escapes the title and the
classattribute value when assembling the HTML.
This is exactly the behavior we will test.
All used functions exist in the list of functions and classes in unitest-wp-copy.
The main file content-card.php:
<?php /** * Plugin Name: Content Card */ namespace Example\ContentCard; defined( 'ABSPATH' ) || exit; require_once __DIR__ . '/vendor/autoload.php';
This file is only needed for the full picture. It is not required to activate the plugin on the site in order to test the class.
Installing dependencies and autoloading
composer.json:
{
"name": "example/content-card",
"description": "Example WordPress plugin with isolated unit tests.",
"type": "wordpress-plugin",
"require": {
"php": ">=8.1"
},
"require-dev": {
"doiftrue/unitest-wp-copy": "7.1.*",
"phpunit/phpunit": "^9.6",
"10up/wp_mock": "*"
},
"autoload": {
"psr-4": {
"Example\\ContentCard\\": "src/"
}
},
"scripts": {
"phpunit": "phpunit"
}
}
Let’s install:
composer install
Composer will create the vendor directory, the vendor/autoload.php file, and composer.lock (on the first installation).
Notes for composer.json
In the require-dev section are dependencies for development and tests. You don’t need to connect unitest-wp-copy to a production site.
The autoload section links the namespace Example\ContentCard\ with the src/ directory. Thanks to this, the plugin will be able to automatically load the class Example\ContentCard\ContentCard from src/ContentCard.php when the class is referenced in code.
How do doiftrue/unitest-wp-copy versions work
The package has separate version tracks for different WordPress versions. For example, the restriction 7.1.* selects the track for WordPress 7.1, and 6.9.* - for WordPress 6.9.
Version format is described in the package documentation.
Also read how versioning works in composer.json.
PHPUnit setup
phpunit.xml:
<?xml version="1.0" encoding="UTF-8"?> <phpunit bootstrap="tests/bootstrap.php" colors="true" cacheResultFile=".phpunit.cache/test-results" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.6/phpunit.xsd" > <testsuites> <testsuite name="Content Card"> <directory suffix="Test.php">tests</directory> </testsuite> </testsuites> </phpunit>
Here it is specified that PHPUnit should:
tests/bootstrap.phpwill be run before the test environment starts.- Search for tests in the
testsdirectory, in files ending withTest.php. - Save the results cache to
.phpunit.cache.
Add .gitignore:
/vendor/ /.phpunit.cache/ /tmp/
Test environment
tests/bootstrap.php:
<?php require_once dirname( __DIR__ ) . '/vendor/autoload.php'; define( 'WP_ENVIRONMENT_TYPE', 'development' ); define( 'WP_DEBUG', true ); \Unitest_WP_Copy\WP_Runtime::boot(); \WP_Mock::bootstrap();
First, the Composer autoloader. Then constants are set, the unitest-wp-copy environment is loaded, and WP_Mock is started.
The order matters:
WP_Runtime::boot() must be called before WP_Mock::bootstrap(). This is needed so that WP_Mock doesn’t declare in advance functions that are declared by unitest-wp-copy. Read about the possibility of replacement.
Set constants that affect environment preparation before WP_Runtime::boot(). More details are provided in the Runtime and Constants sections.
You don’t need to load wp-load.php here. We also do not connect the main plugin file: Composer will load the class being tested on first access.
The first two tests
Create tests/ContentCardTest.php:
<?php
namespace Example\ContentCard\Tests;
use Example\ContentCard\ContentCard;
use WP_Mock\Tools\TestCase;
final class ContentCardTest extends TestCase {
public function test__render__content_with_real_wordpress_formatting(): void {
$html = ( new ContentCard() )->render(
' <b>Weekly</b> update ',
'Visit https://example.com <script>alert(1)</script> <strong>today</strong>'
);
self::assertStringContainsString( '<h2>Weekly update</h2>', $html );
self::assertStringContainsString( '<a href="https://example.com"', $html );
self::assertStringContainsString( '<strong>today</strong>', $html );
self::assertStringNotContainsString( '<script>', $html );
self::assertStringContainsString( 'class="content-card"', $html );
}
public function test__render__adds_network_class_on_multisite(): void {
\WP_Mock::userFunction( 'is_multisite' )->andReturn( true );
$html = ( new ContentCard() )->render( 'Network news', 'Shared content' );
self::assertStringContainsString(
'class="content-card content-card--network"',
$html
);
}
}
Description of what happens:
-
The class extends
WP_Mock\Tools\TestCase. This base class performs preparation and cleanup of WP_Mock between tests. -
Test methods start with
test_, so PHPUnit recognizes them as tests. -
Test case 1: Checking real formatting
The first test calls
render()and checks several properties of the result:- The title has no
<b>tag and no extra spaces. - The text URL turned into a link.
- The allowed
<strong>tag was preserved. - The
<script>tag was removed. - The regular CSS class for the card is used.
assertStringContainsString()checks that a fragment is present, andassertStringNotContainsString()checks that it is absent. Such a check is suitable when we care about a specific property of the result, not an exact match of the entire HTML string.Here, the formatting implementations from unitest-wp-copy are used. We don’t mock the behavior of
wp_kses_post()ormake_clickable()(as is usually done in unit tests), so the test runs exactly the same way as WP does and is able to notice an error in how our method uses these functions. - The title has no
-
Test case 2: Checking the Multisite branch
In the second test case, before calling the method, we provide a replacement (mock):
\WP_Mock::userFunction( 'is_multisite' )->andReturn( true );
For this test, the call to
is_multisite()will returntrue. This way, we verify the branch with an additional CSS class without installing a real network of sites.This is exactly the role of the mock in this example: set the environment condition on which our class behavior depends. All other content processing remains unchanged.
In unitest-wp-copy, via WP_Mock you can only replace functions marked as mockable.
General rules for overrides are described in the documentation.
Running tests
Run in the terminal in the plugin (project) folder:
composer run phpunit // or vendor/bin/phpunit
If PHPUnit runs successfully, it will output OK and the number of tests and assertions.
To run only the Multisite branch tests:
composer run phpunit -- --filter test_adds_network_class_on_multisite vendor/bin/phpunit --filter test_adds_network_class_on_multisite
Running via Docker and Make
In the unitest-wp-copy original guide, the run commands are executed inside the Composer container. For our example, it’s enough to use this Makefile:
define php_run @mkdir -p "$(CURDIR)/tmp/composer-cache" docker run --rm --user "$$(id -u):$$(id -g)" \ -v "$(CURDIR):/app" -w /app \ -v "$(CURDIR)/tmp/composer-cache:/tmp/composer-cache" \ -e COMPOSER_CACHE_DIR=/tmp/composer-cache \ composer:2 sh -c "$(1)" endef composer.install: $(call php_run,composer install) phpunit: $(call php_run,composer run phpunit -- --colors=always)
Now we can use commands:
make composer.install make phpunit
This option is convenient because everything will run inside the Docker container, and you don’t need PHP installed on your system. It’s suitable only for Linux, macOS, or WSL (Windows).
Notes on extending tests
unitest-wp-copy has limitations. Not all WordPress functions are available in the test environment.
Before using:
- Find a function or class in SYMBOLS-INFO.md.
- Use
WP_Mockwhen you need to control a function’s behavior (it must be mockable). - Restore the modified state between tests.
- Use a different approach if the check requires a real database or a full WordPress startup.
AI instruction
Add the following instruction to the AGENTS.md file so the AI knows that there is a separate WP environment for unit tests:
### Tests Runtime This project uses `doiftrue/unitest-wp-copy` with `WP_Mock` for PHPUnit tests. Before writing or changing tests: 1. Read `vendor/doiftrue/unitest-wp-copy/README.md` to understand the test runtime. 2. Check `vendor/doiftrue/unitest-wp-copy/SYMBOLS-INFO.md` for the WordPress functions and classes available in the runtime. Its first section lists runtime-adapted classes (like `\Unitest_WP_Copy\wpdb__Runtime`) with their public methods — use or extend them instead of WP_Mock. 3. Use `WP_Mock` when a runtime function listed as mockable needs to be mocked.
Options and general state
In unitest-wp-copy, options are stored in memory. To set a value, use WP_Options::set():
\Unitest_WP_Copy\WP_Options::set( 'my_plugin_title', 'Test title' ); self::assertSame( 'Test title', get_option( 'my_plugin_title' ) );
This snippet can be used inside a test method after the environment is prepared.
If tests change options, save the state via WP_Options::save_state() in setUp() and restore it via WP_Options::restore_state() in tearDown(). When overriding these methods, keep calls to the parent methods; when inheriting from WP_Mock\Tools\TestCase, use the public visibility of methods as in the parent.
The options storage takes priority over WP_Mock overrides: if an option already exists in storage, you should change it via WP_Options::set(). The order of retrieving values is described in the Options section.
Saving options does not automatically save constants, hooks, and all global variables. If a test changes them, you need to handle cleanup or restoration separately. Calling WP_Runtime::boot() again also does not create a new environment: it is initialized once per PHP process.
When full WordPress is needed
If you need to ensure that a post is actually saved to the database, the plugin is activated correctly, or the code interacts with the active theme, you’ll need integration testing in a full WordPress environment.
A function override can check your class’s reaction to a given result. It does not confirm that the real database, file system, or another plugin will behave in exactly the same way.
This boundary is described in detail in unitest-wp-copy limitations.