10up/wp_mock
wp_mock is a Mockery-powered library for unit testing WordPress plugins and themes. It lets you mock core functions, hooks, and globals so tests run fast and isolated, without a WordPress install.
Install and wire it up (10 minutes).
composer require --dev 10up/wp_mock mockery/mockery phpunit/phpunit
WP_Mock rides on Mockery for its assertion engine, so both come along. Then create a bootstrap file and point PHPUnit at it:
<?php
// tests/bootstrap.php
require_once __DIR__ . '/../vendor/autoload.php';
WP_Mock::bootstrap(); // defines the mocking layer + WP constants shim
<!-- phpunit.xml.dist -->
<phpunit bootstrap="tests/bootstrap.php" colors="true">
<testsuites>
<testsuite name="unit"><directory>tests/Unit</directory></testsuite>
</testsuites>
</phpunit>
Finally, give every test a common base class so setUp/tearDown are never forgotten — this is the single biggest source of "why is my mock not intercepting anything?" confusion:
abstract class WPTestCase extends \PHPUnit\Framework\TestCase
{
protected function setUp(): void
{
parent::setUp();
WP_Mock::setUp(); // MUST come before the code under test runs
}
protected function tearDown(): void
{
WP_Mock::tearDown(); // MUST run, even on failure — this is what verifies expectations
parent::tearDown();
}
}
Where to look first: the README's four API sections — Mocking WordPress Functions, Filters, Actions, Constants. Everything else in the package is plumbing. If you want to see the full public surface, read vendor/10up/wp_mock/php/WP_Mock.php top to bottom; it is one static facade and takes about five minutes.
Your first real use case. A plugin class that reads an option and registers a content filter — the classic 80% case:
class Banner_Test extends WPTestCase
{
public function test_registers_and_renders(): void
{
WP_Mock::userFunction('get_option', [
'args' => ['banner_text'],
'return' => 'Hello',
'times' => 1,
]);
WP_Mock::userFunction('esc_html', ['return' => 'Hello']);
$banner = new Banner();
$this->assertSame('<p>Hello</p>', $banner->render());
}
public function test_hooks_the_content_filter(): void
{
WP_Mock::expectFilterAdded('the_content', [new Banner(), 'append'], 10, 1);
(new Banner())->register();
}
}
Note the split: one test exercises behaviour, one asserts wiring. That separation is the whole point of the package.
Array config vs. chained Mockery expectations. userFunction() returns a Mockery expectation, so both styles are legal and they mix:
WP_Mock::userFunction('wp_remote_get', ['args' => ['https://api.test/x']])
->once()
->andReturn(['response' => ['code' => 200], 'body' => '{"ok":true}']);
The array keys are times, args, return, and strict. The default times is 1, which is important (see Gotchas).
passthruFunction() for the noise layer. Escaping and i18n functions usually shouldn't be asserted — they just need to not blow up. passthruFunction accepts any number of calls and returns the first argument, which is exactly the identity behaviour you want:
WP_Mock::passthruFunction('esc_html');
WP_Mock::passthruFunction('esc_attr');
WP_Mock::userFunction('__', ['return' => fn($s) => $s]); // callables are invoked
Put these in your base class setUp() and never think about them again.
Filters: two distinct jobs. Use onFilter() when your code consumes a filter and you need to control the value returned; use expectFilter()/expectFilterAdded() when your code publishes or registers one.
// Control the return value of apply_filters('price', 100, $product)
WP_Mock::onFilter('price')->with(100, Mockery::type(Product::class))->reply(120);
// Transform it dynamically
WP_Mock::onFilter('price')
->with(Mockery::any())
->replyUsing(fn($price) => $price * 2);
// Assert that your code applied a filter with a specific value
WP_Mock::expectFilter('my_plugin_settings', ['a' => 1]);
// Assert registration
WP_Mock::expectFilterAdded('the_content', [$plugin, 'filter_content'], 20, 1);
WP_Mock::expectFilterNotAdded('the_content', Mockery::any()); // negative case
Actions. Same split: expectAction() asserts do_action() was fired with args, expectActionAdded() asserts add_action() registration.
WP_Mock::expectAction('order_completed', 42);
WP_Mock::expectActionAdded('init', [$plugin, 'boot'], 10, 0);
Test the callback directly; assert registration separately. WP_Mock records hooks, it does not execute them. So don't try to drive your filter through apply_filters — instantiate the class and call the method:
public function test_append_transforms_content(): void
{
$banner = new Banner();
$this->assertSame("body<p>Hi</p>", $banner->append("body"));
}
That one decision removes more pain than any other.
Globals aren't provided — you wire them. $wpdb and $current_user are plain globals, so mock them yourself:
protected function setUp(): void
{
parent::setUp();
$GLOBALS['wpdb'] = Mockery::mock('wpdb');
$GLOBALS['wpdb']->prefix = 'wp_';
$GLOBALS['wpdb']->shouldReceive('get_var')->andReturn('3');
}
Class dependencies (WP_Query, WP_Error, WP_REST_Request). WordPress classes generally aren't loaded, so a bare Mockery::mock('WP_Query') fatals. Use the alias: prefix and define them in a shared helper:
$query = Mockery::mock('alias:WP_Query');
$query->shouldReceive('have_posts')->andReturn(true);
Reusable stub bundles. Any function that returns an array (get_option for a settings array, wp_parse_args, get_post_meta) will produce silent foreach-on-null errors if you forget it. Wrap them in helper methods on your base class (stubOptions([...]), stubPostMeta([...])) so intent is one line per test.
times defaults to 1. Forget it and a function called twice blows up with an InvalidCountException; a function called zero times also blows up. Use 'times' => 0 to assert not called, or chain ->zeroOrMoreTimes() when the count is irrelevant.['args' => ['my_option']] only matches a single-argument call with that literal. Real WP code passes defaults, so use Mockery::any(), Mockery::type('string'), or Mockery::on(fn($v) => ...) liberally. NoMatchingExpectationException shows expected vs. actual — read it, it names the culprit.onFilter()->with() includes the filtered value as the first parameter. If your code calls apply_filters('x', $value, $extra) but you only specify with($value), it won't match. Pass every argument, or with(Mockery::any(), Mockery::any()).expectActionAdded() inspects a static registry that WP_Mock::setUp() resets per test — so set expectations before the code registers, and never expect a registered callback to fire.function_exists() lies until you mock. The mocking layer defines functions lazily, so code that guards on function_exists('wp_remote_get') behaves differently from production. Be aware when testing feature-detection branches.WP_Mock::tearDown() must always run — it's what calls Mockery's verification. Skipping it on early failure hides unmet expectations; putting it after parent::tearDown() can also break things. Keep it first.composer why-not 10up/wp_mock before debugging phantom failures — it's usually a version conflict, not your test.wpFunction() / wpPassthruFunction() are deprecated aliases for userFunction() / passthruFunction(). New code should never use them.define() in the bootstrap, not from WP_Mock. Define ABSPATH, MY_PLUGIN_VERSION, etc. before WP_Mock::bootstrap() and any code with defined() guards will behave.->andReturnUsing(function (...$args) { fwrite(STDERR, print_r($args, true)); return null; }) to see the actual call signature. Faster than reading Mockery stack traces.WP_Mock:: static, so layer your own facade (PluginTestCase::stubSettings()) over it. That gives you one place to change when you upgrade and one place to audit which WP surfaces your suite depends on.How can I help you explore Laravel packages today?