Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]
### Fixed
- Allow multiple `expectActionAdded` / `expectFilterAdded` expectations using `Functions::type()` with the same method name ([#268](https://github.com/10up/wp_mock/issues/268))

## [1.1.1](https://github.com/10up/wp_mock/compare/1.1.0...1.1.1) - 2025-12-03
### Fixed
- Address PHP deprecation warnings about implicitly nullable parameters
Expand Down
10 changes: 10 additions & 0 deletions docs/usage/mocking-wp-action-and-filter-hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,16 @@ If the actual instance of an expected class cannot be passed, `AnyInstance` can
WP_Mock::expectFilterAdded('the_content', [new \WP_Mock\Matcher\AnyInstance(Special::class), 'the_content']);
```

You can also match object method callbacks by class type with `Functions::type()`. This is useful when multiple classes register the same method name on the same hook:

```php
WP_Mock::expectActionAdded('init', [WP_Mock\Functions::type(SampleClass::class), 'action']);
WP_Mock::expectActionAdded('init', [WP_Mock\Functions::type(SampleSubClass::class), 'action']);

add_action('init', [new SampleClass(), 'action']);
add_action('init', [new SampleSubClass(), 'action']);
```

## Asserting that closures have been added as hook callbacks

Sometimes it's handy to add a [Closure](https://secure.php.net/manual/en/class.closure.php) as a WordPress hook instead of defining a function in the global namespace. To assert that such a hook has been added, you can perform assertions referencing the Closure class or a `callable` type:
Expand Down
13 changes: 7 additions & 6 deletions php/WP_Mock.php
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ public static function tearDown(): void
{
self::$event_manager->flush();
self::$functionsManager->flush();
\WP_Mock\Hook::$objects = [];

Mockery::close();
Handler::cleanup();
Expand Down Expand Up @@ -288,7 +289,7 @@ public static function assertFiltersCalled() : void
* Adds an expectation that an action hook should be added.
*
* @param string $action the action hook name
* @param string|callable-string|callable|Type $callback the callback that should be registered
* @param string|callable-string|callable|Type|array{0: mixed, 1: string} $callback the callback that should be registered
* @param int $priority the priority it should be registered at
* @param int $args the number of arguments that should be allowed
* @return void
Expand All @@ -302,7 +303,7 @@ public static function expectActionAdded(string $action, $callback, int $priorit
* Adds an expectation that an action hook should not be added.
*
* @param string $action the action hook name
* @param string|callable-string|callable|Type $callback the callback that should be registered
* @param string|callable-string|callable|Type|array{0: mixed, 1: string} $callback the callback that should be registered
* @param int $priority the priority it should be registered at
* @param int $args the number of arguments that should be allowed
* @return void
Expand All @@ -316,7 +317,7 @@ public static function expectActionNotAdded(string $action, $callback, int $prio
* Add an expectation that a filter hook should be added.
*
* @param string $filter the filter hook name
* @param string|callable-string|callable|Type $callback the callback that should be registered
* @param string|callable-string|callable|Type|array{0: mixed, 1: string} $callback the callback that should be registered
* @param int $priority the priority it should be registered at
* @param int $args the number of arguments that should be allowed
* @return void
Expand All @@ -330,7 +331,7 @@ public static function expectFilterAdded(string $filter, $callback, int $priorit
* Adds an expectation that a filter hook should not be added.
*
* @param string $filter the filter hook name
* @param string|callable-string|callable|Type $callback the callback that should be registered
* @param string|callable-string|callable|Type|array{0: mixed, 1: string} $callback the callback that should be registered
* @param int $priority the priority it should be registered at
* @param int $args the number of arguments that should be allowed
* @return void
Expand All @@ -347,7 +348,7 @@ public static function expectFilterNotAdded(string $filter, $callback, int $prio
*
* @param string $type the type of hook being added ('action' or 'filter')
* @param string $hook the hook name
* @param string|callable-string|callable|Type $callback the callback that should be registered
* @param string|callable-string|callable|Type|array{0: mixed, 1: string} $callback the callback that should be registered
* @param int $priority the priority it should be registered at
* @param int $args the number of arguments that should be allowed
* @return void
Expand All @@ -370,7 +371,7 @@ public static function expectHookAdded(string $type, string $hook, $callback, in
*
* @param string $type the type of hook being added ('action' or 'filter')
* @param string $hook the hook name
* @param string|callable-string|callable|Type $callback the callback that should be registered
* @param string|callable-string|callable|Type|array{0: mixed, 1: string} $callback the callback that should be registered
* @param int $priority the priority it should be registered at
* @param int $args the number of arguments that should be allowed
* @return void
Expand Down
4 changes: 3 additions & 1 deletion php/WP_Mock/Functions.php
Original file line number Diff line number Diff line change
Expand Up @@ -414,7 +414,9 @@ public static function anyOf(): AnyOf
public static function type(string $expected): Type
{
$type = Mockery::type($expected);
Filter::$objects[ $expected ] = spl_object_hash($type);
// Stable key (not spl_object_hash): Type is discarded after expect* registration,
// so GC can recycle the hash and collide when the same method is expected twice.
Filter::$objects[ $expected ] = (string) $type;

return $type;
}
Expand Down
16 changes: 10 additions & 6 deletions php/WP_Mock/Hook.php
Original file line number Diff line number Diff line change
Expand Up @@ -64,13 +64,17 @@ protected function safe_offset($value): string
return (string) $value;
}

if (is_object($value)){
if (! $value instanceof Type) {
$class = get_class($value);
if (is_object($value)) {
// Type matchers use a stable string key so multiple Functions::type() expectations
// do not collide when PHP reuses spl_object_hash after GC.
if ($value instanceof Type) {
return (string) $value;
}

if (isset(static::$objects[$class]) && is_string(static::$objects[$class])) {
return static::$objects[$class];
}
$class = get_class($value);

if (isset(static::$objects[$class]) && is_string(static::$objects[$class])) {
return static::$objects[$class];
}

return spl_object_hash($value);
Expand Down
4 changes: 4 additions & 0 deletions tests/Mocks/SampleSubClass.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,8 @@ class SampleSubClass extends SampleClass
public function action(): void
{
}

public function action2(): void
{
}
}
42 changes: 42 additions & 0 deletions tests/Unit/WP_Mock/HookTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@
use PHPUnit\Framework\TestCase;
use ReflectionException;
use stdClass;
use WP_Mock\Functions;
use WP_Mock\Hook;
use WP_Mock\Tests\Mocks\SampleClass;
use WP_Mock\Tests\Mocks\SampleSubClass;
use WP_Mock\Traits\AccessInaccessibleClassMembersTrait;

/**
Expand Down Expand Up @@ -63,5 +66,44 @@ public function callback(): bool
yield 'scalar (false)' => [false, ''];
yield 'object' => [$objectInstance, spl_object_hash($objectInstance)];
yield 'array (callback)' => [[$callbackInstance, 'callback'], spl_object_hash($callbackInstance).'callback'];
yield 'type matcher (class)' => [Mockery::type(SampleClass::class), (string) Mockery::type(SampleClass::class)];
}

/**
* @covers \WP_Mock\Hook::safe_offset()
* @covers \WP_Mock\Functions::type()
*
* @return void
* @throws ReflectionException|Exception
*/
public function testTypeSafeOffsetIsStableAcrossMultipleTypeCalls(): void
{
Hook::$objects = [];

$instance = $this->getMockForAbstractClass(Hook::class, [], '', false);
$method = $this->getInaccessibleMethod($instance, 'safe_offset');

$typeKey1 = $method->invokeArgs($instance, [Functions::type(SampleClass::class)]);
unset($typeKey1);
gc_collect_cycles();

$typeSampleClass = Functions::type(SampleClass::class);
$typeSampleSubClass = Functions::type(SampleSubClass::class);

$keyClass = $method->invokeArgs($instance, [$typeSampleClass]);
$keySubClass = $method->invokeArgs($instance, [$typeSampleSubClass]);
$keyInstance = $method->invokeArgs($instance, [new SampleClass()]);
$keySubInstance = $method->invokeArgs($instance, [new SampleSubClass()]);
$keyCallbackClass = $method->invokeArgs($instance, [[$typeSampleClass, 'action']]);
$keyCallbackSubClass = $method->invokeArgs($instance, [[$typeSampleSubClass, 'action']]);

$this->assertNotSame($keyClass, $keySubClass);
$this->assertSame($keyClass, $keyInstance);
$this->assertSame($keySubClass, $keySubInstance);
$this->assertNotSame($keyCallbackClass, $keyCallbackSubClass);
$this->assertSame((string) $typeSampleClass, $keyClass);
$this->assertSame((string) $typeSampleSubClass, $keySubClass);

Hook::$objects = [];
}
}
132 changes: 132 additions & 0 deletions tests/Unit/WP_MockTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
use Mockery\ExpectationInterface;
use WP_Mock\Tests\WP_MockTestCase;
use WP_Mock\Tests\Mocks\SampleClass;
use WP_Mock\Tests\Mocks\SampleSubClass;
use WP_Mock\Matcher\AnyInstance;
use WP_Mock\DeprecatedMethodListener;
use WP_Mock\Tests\Unit\WP_Mock\TestClass;
use Mockery\Exception\InvalidCountException;
Expand Down Expand Up @@ -401,4 +403,134 @@ public function testMultipleOnFilterPassesWithAnyArgs(): void

Mockery::close();
}

/**
* @covers \WP_Mock::expectActionAdded()
* @covers \WP_Mock::expectHookAdded()
* @covers \WP_Mock\Functions::type()
* @covers \WP_Mock\Hook::safe_offset()
*
* @runInSeparateProcess
* @preserveGlobalState disabled
*
* @return void
* @throws ExpectationFailedException|Exception|\Exception
*/
public function testMultipleActionsTypeSameMethod(): void
{
WP_Mock::activateStrictMode();
WP_Mock::bootstrap();

WP_Mock::expectActionAdded(
'init',
array(WP_Mock\Functions::type(SampleClass::class), 'action')
);

WP_Mock::expectActionAdded(
'init',
array(WP_Mock\Functions::type(SampleSubClass::class), 'action')
);

add_action('init', array(new SampleClass(), 'action'));
add_action('init', array(new SampleSubClass(), 'action'));

$this->assertConditionsMet();
}

/**
* @covers \WP_Mock::expectActionAdded()
* @covers \WP_Mock::expectHookAdded()
* @covers \WP_Mock\Functions::type()
* @covers \WP_Mock\Hook::safe_offset()
*
* @runInSeparateProcess
* @preserveGlobalState disabled
*
* @return void
* @throws ExpectationFailedException|Exception|\Exception
*/
public function testMultipleActionsTypeDistinctMethod(): void
{
WP_Mock::activateStrictMode();
WP_Mock::bootstrap();

WP_Mock::expectActionAdded(
'init',
array(WP_Mock\Functions::type(SampleClass::class), 'action')
);

WP_Mock::expectActionAdded(
'init',
array(WP_Mock\Functions::type(SampleSubClass::class), 'action2')
);

add_action('init', array(new SampleClass(), 'action'));
add_action('init', array(new SampleSubClass(), 'action2'));

$this->assertConditionsMet();
}

/**
* @covers \WP_Mock::expectActionAdded()
* @covers \WP_Mock::expectHookAdded()
*
* @runInSeparateProcess
* @preserveGlobalState disabled
*
* @return void
* @throws ExpectationFailedException|Exception|\Exception
*/
public function testMultipleActionsAnyInstanceSameMethod(): void
{
WP_Mock::activateStrictMode();
WP_Mock::bootstrap();

WP_Mock::expectActionAdded(
'init',
array(new AnyInstance(SampleClass::class), 'action')
);

WP_Mock::expectActionAdded(
'init',
array(new AnyInstance(SampleSubClass::class), 'action')
);

add_action('init', array(new SampleClass(), 'action'));
add_action('init', array(new SampleSubClass(), 'action'));

$this->assertConditionsMet();
}

/**
* @covers \WP_Mock::expectFilterAdded()
* @covers \WP_Mock::expectHookAdded()
* @covers \WP_Mock\Functions::type()
* @covers \WP_Mock\Hook::safe_offset()
*
* @runInSeparateProcess
* @preserveGlobalState disabled
*
* @return void
* @throws ExpectationFailedException|Exception|\Exception
*/
public function testMultipleFiltersTypeSameMethod(): void
{
WP_Mock::activateStrictMode();
WP_Mock::bootstrap();

WP_Mock::expectFilterAdded(
'the_content',
array(WP_Mock\Functions::type(SampleClass::class), 'action')
);

WP_Mock::expectFilterAdded(
'the_content',
array(WP_Mock\Functions::type(SampleSubClass::class), 'action')
);

add_filter('the_content', array(new SampleClass(), 'action'));
add_filter('the_content', array(new SampleSubClass(), 'action'));

$this->assertConditionsMet();
}
}