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--network in Multisite mode.
  • Escapes the title and the class attribute value when assembling the HTML.

This is exactly the behavior we will test.

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.php will be run before the test environment starts.
  • Search for tests in the tests directory, in files ending with Test.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, and assertStringNotContainsString() 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() or make_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.

  • 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 return true. 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:

  1. Find a function or class in SYMBOLS-INFO.md.
  2. Use WP_Mock when you need to control a function’s behavior (it must be mockable).
  3. Restore the modified state between tests.
  4. 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.