Search by

doiftrue / unitest-wp-copy

doiftrue

Collection of WordPress core functions and classes that can be used in unit tests to simulate WordPress environment.

Package info

github.com/doiftrue/unitest-wp-copy

pkg:composer/doiftrue/unitest-wp-copy

Statistics

Installs: 4 147

Dependents: 0

Suggesters: 0

Stars: 6

Open Issues: 0


README

Helper library for PHPUnit tests. It provides selected WordPress core functions and classes that can run without full WordPress bootstrap (database or external services).

Use it with WP_Mock. The runtime keeps real WordPress pure-PHP behavior, while WP_Mock lets tests replace functions marked as mockable when that is needed — which is almost always the case in unit tests.

Quick Start

  1. Install the package line matching your WordPress version, plus WP_Mock:

    composer require --dev doiftrue/unitest-wp-copy:6.9.* 
    composer require --dev 10up/wp_mock
  2. Initialize both in the PHPUnit bootstrap. Unitest_WP_Copy must initialize first:

    File: tests/bootstrap.php

    require_once __DIR__ . '/../vendor/autoload.php';
    
    \Unitest_WP_Copy\Bootstrap::init();
    \WP_Mock::bootstrap();

Quick Example

Suppose your code turns a raw, user-submitted comment into safe HTML:

function render_comment( string $raw ): string {
	// wp_kses_post() strips disallowed tags.
	// make_clickable() linkifies URLs.
	// wpautop() adds paragraphs — all real WordPress logic.
	return wpautop( make_clickable( wp_kses_post( $raw ) ) );
}

The test uses real WordPress sanitizing and formatting behavior, but can still mock a supported boundary when necessary:

class RenderCommentTest extends \PHPUnit\Framework\TestCase {

	protected function setUp(): void {
		parent::setUp();
		WP_Mock::setUp();
	}

	protected function tearDown(): void {
		WP_Mock::tearDown();
		parent::tearDown();
	}

	public function test__renders_safe_html(): void {
		$html = render_comment(
			'Great post! <script>alert(1)</script> visit https://example.com <b>thanks</b>'
		);

		$this->assertStringNotContainsString( '<script>', $html );                   // kses removed it
		$this->assertStringContainsString( '<a href="https://example.com"', $html ); // linkified
		$this->assertStringContainsString( '<b>thanks</b>', $html );                 // allowed tag kept
	}

	public function test__mocks_a_supported_function(): void {
		WP_Mock::userFunction( 'is_multisite' )->andReturn( true );
		$this->assertTrue( is_multisite() );
	}
}

Without WP_Mock

You may initialize only \Unitest_WP_Copy\Bootstrap::init() and use the real runtime. However, you will not be able to conveniently mock functions that the runtime has already loaded.

Available Symbols

For the full list of available classes/functions, see: SYMBOLS-INFO.md. It separately lists symbols that are mockable via WP_Mock.

For differences between runtime releases, see CHANGELOG.md.

Runtime-Adapted Classes

Some WordPress classes cannot be copied as a whole, so the runtime provides a partial adapter instead. Such classes are listed in the first section of SYMBOLS-INFO.md together with their public methods and properties, where [wp] marks an unchanged copied WordPress method and [adapted] marks a runtime-specific implementation.

They are regular PHP classes, not WP_Mock symbols: use an instance directly, or extend it to build your own mock.

Currently available:

  • \Unitest_WP_Copy\wpdb__Runtime — a non-querying wpdb adapter for SQL-building code. Bootstrap assigns an instance to the $wpdb global.
  • \Unitest_WP_Copy\WP_REST_Server__Runtime — an in-memory REST route registry and dispatcher, also available through the WordPress-compatible WP_REST_Server alias.
global $wpdb;

$query = $wpdb->prepare( "SELECT * FROM {$wpdb->posts} WHERE post_title = %s", "O'Reilly" );
$this->assertSame(
	"SELECT * FROM wp_posts WHERE post_title = 'O\\'Reilly'",
	$wpdb->remove_placeholder_escape( $query )
);

Extend it when your code needs querying methods:

class My_WPDB extends \Unitest_WP_Copy\wpdb__Runtime {

	public array $results = [];

	public function get_results( $query = null, $output = OBJECT ) {
		return $this->results;
	}
}

$GLOBALS['wpdb'] = new My_WPDB();

Restore $GLOBALS['wpdb'] in tearDown() if a test replaces it.

REST API route tests

The runtime supports route registration, in-memory dispatch, request validation/sanitization, response links, OPTIONS handling, batch requests, and custom controllers. Live HTTP serving and WordPress core endpoint controllers remain out of scope. rest_do_request() follows WordPress core and returns the direct dispatch result; post-dispatch serving filters such as _fields trimming and automatic Allow headers are available as functions but are not applied automatically.

protected function tearDown(): void {
	unset( $GLOBALS['wp_rest_server'] );
	parent::tearDown();
}

public function test__item_route(): void {
	$server = rest_get_server();

	$server->register_route(
		'my/v1',
		'/my/v1/items/(?P<id>\d+)',
		[
			[
				'methods'             => 'GET',
				'callback'            => static fn( WP_REST_Request $request ) => [
					'id' => $request['id'],
				],
				'permission_callback' => '__return_true',
				'args'                => [
					'id' => [ 'type' => 'integer' ],
				],
			],
		]
	);

	$response = rest_do_request( new WP_REST_Request( 'GET', '/my/v1/items/42' ) );

	$this->assertSame( 200, $response->get_status() );
	$this->assertSame( [ 'id' => 42 ], $response->get_data() );
}

register_rest_route() is also available and retains its WordPress rest_api_init timing check. The check is observable through doing_it_wrong_run; PHP notices remain silent unless the test enables the corresponding WordPress debug behavior. Use direct WP_REST_Server::register_route() when lifecycle timing is not part of the test.

The root index intentionally omits active-theme, site-logo, site-icon, and client-side media enrichment because those paths require the live theme, post, attachment, and capability runtimes. It still exposes site metadata, namespaces, registered routes, and the standard help link.

Supported WordPress Lines

Use the package line that matches your WP version:

WordPress line Composer constraint
7.1 doiftrue/unitest-wp-copy:7.1.*
7.0 doiftrue/unitest-wp-copy:7.0.*
6.9 doiftrue/unitest-wp-copy:6.9.*
6.8 doiftrue/unitest-wp-copy:6.8.*
6.7 doiftrue/unitest-wp-copy:6.7.*
6.6 doiftrue/unitest-wp-copy:6.6.*
6.5 doiftrue/unitest-wp-copy:6.5.*

Real release tags use 4 numbers, for example 7.0.2.8:

  • 7.0 is the target WordPress version line;
  • 2.8 is this repository's version for that line.

Usage examples in your composer.json:

  • 7.0.2.8 - pin one exact release.
  • ~7.0.2.8 - allow conservative updates starting from this build (usually small runtime fixes).
  • 7.0.* - allow any update in the WP 7.0 line (new copied functions/classes may appear and affect existing tests).

Bootstrap Overrides and Shared State

Define overrides before \Unitest_WP_Copy\Bootstrap::init().

// tests/bootstrap.php
define( 'ABSPATH', '/srv/wp/' );
define( 'WP_CONTENT_DIR', '/srv/wp/wp-content' );
define( 'WP_CONTENT_URL', 'https://wp.test/wp-content' );
define( 'WP_ENVIRONMENT_TYPE', 'development' );
define( 'WP_DEBUG', true );

// Used by get_option()
$GLOBALS['stub_wp_options'] = (object) [
	'home'                => 'https://wp.test',
	'siteurl'             => 'https://wp.test',
	'gmt_offset'          => 0,
	'timezone_string'     => 'UTC',
	'language'            => 'en-US',
	'blogdescription'     => 'unitest-wp-copy runtime',
	'admin_email'         => 'admin@wp.test',
	'stylesheet'          => 'unitest-wp-copy',
	'use_smilies'         => true,
	'use_balanceTags'     => true,
	'WPLANG'              => '',
	'blog_charset'        => 'UTF-8',
	'html_type'           => 'text/html',
	'thumbnail_size_w'    => 150,
	'thumbnail_size_h'    => 150,
	'thumbnail_crop'      => true,
	'medium_size_w'       => 300,
	'medium_size_h'       => 300,
	'medium_large_size_w' => 768,
	'medium_large_size_h' => 0,
	'large_size_w'        => 1024,
	'large_size_h'        => 1024,
];

// Used by get_site_option()
$GLOBALS['stub_wp_site_options'] = (object) [
	'site_name' => 'Test network',
];

require_once __DIR__ . '/vendor/autoload.php';
\Unitest_WP_Copy\Bootstrap::init();
\WP_Mock::bootstrap();

Redefine Runtime Globals

Runtime globals initialized or updated by bootstrap (shared in one PHP process):

$GLOBALS['stub_wp_options']
$GLOBALS['stub_wp_site_options']
$GLOBALS['timestart']
$_SERVER['HTTP_HOST']
$blog_id
$wp_plugin_paths
$shortcode_tags
$wp_locale
$wp_post_types
$wp_taxonomies
$wp_filter
$wp_actions
$wp_filters
$wp_current_filter
$allowedposttags
$allowedtags
$allowedentitynames
$allowedxmlentitynames
$wpsmiliestrans
$wp_smiliessearch

If a test mutates these globals/options, restore them in setUp() / tearDown().

How get_option() Works

get_option() uses $GLOBALS['stub_wp_options'] instead of a database. Configured options have priority over WP_Mock handlers so that a broad mock cannot accidentally change options used by nested runtime calls.

The lookup order is:

  1. pre_option_{$option} and pre_option filters;
  2. the value in $GLOBALS['stub_wp_options'] and the option_{$option} filter;
  3. a WP_Mock::userFunction( 'get_option', ... ) handler for an option not present in the store;
  4. the default_option_{$option} filter and the default value.

Override a configured option by changing the store:

$GLOBALS['stub_wp_options']->medium_size_w = 640;

Use WP_Mock to mock an option that does not exist in $GLOBALS['stub_wp_options']:

WP_Mock::userFunction( 'get_option', [
	'args'   => [ 'my_plugin_option', false ],
	'return' => 'test-value',
] );

IMPORTANT: WP_Mock cannot override an option when it exists in $GLOBALS['stub_wp_options'].

Redefine Constants

Constants you can predefine before bootstrap:

ABSPATH
WPINC
WP_CONTENT_DIR
WP_CONTENT_URL
WP_ENVIRONMENT_TYPE
WP_START_TIMESTAMP
WP_MEMORY_LIMIT
WP_MAX_MEMORY_LIMIT
WP_DEVELOPMENT_MODE
WP_DEBUG
WP_DEBUG_DISPLAY
WP_DEBUG_LOG
WP_CACHE
SCRIPT_DEBUG
MEDIA_TRASH
SHORTINIT
WP_PLUGIN_DIR
WP_PLUGIN_URL
PLUGINDIR
WPMU_PLUGIN_DIR
WPMU_PLUGIN_URL
MUPLUGINDIR
COOKIEHASH
USER_COOKIE
PASS_COOKIE
AUTH_COOKIE
SECURE_AUTH_COOKIE
LOGGED_IN_COOKIE
TEST_COOKIE
COOKIEPATH
SITECOOKIEPATH
ADMIN_COOKIE_PATH
PLUGINS_COOKIE_PATH
COOKIE_DOMAIN
RECOVERY_MODE_COOKIE
FORCE_SSL_ADMIN
AUTOSAVE_INTERVAL
EMPTY_TRASH_DAYS
WP_POST_REVISIONS
WP_CRON_LOCK_TIMEOUT
CUSTOM_TAGS

Redefine Functions

Copied functions are wrapped with if ( ! function_exists( '...' ) ), so you can override specific functions by defining them before bootstrap init.

When to Use It

Use it when:

  • you need real behavior of selected WP functions/classes in plain PHPUnit;
  • your tested code mostly depends on WP pure-PHP logic.

Do not use it when:

  • you need a full WordPress runtime and bootstrap;
  • your test mostly depends on real DB/network/filesystem-heavy WP behavior.

Instructions for AI Agents

Add the following to the testing section of your project's AGENTS.md:

### 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.