From 88a6da8296ee862332483e119cc34b119a4a5ca1 Mon Sep 17 00:00:00 2001 From: Faisal Ahammad Date: Sun, 19 Jul 2026 22:57:58 +0600 Subject: [PATCH] fix(hook): use stable Type string key in safe_offset Functions::type() stored spl_object_hash of the Mockery Type as the processor map key. The Type is dropped after expect* registration, so PHP can recycle the hash after GC. Two expectActionAdded or expectFilterAdded calls with the same method name then collapsed to the same processor key, the second expectation overwriting the first and producing a 'should be called at least 1 times but called 0 times' Mockery failure. Switch the key to Mockery's Type::__toString() output (e.g. ) in both Functions::type() and Hook::safe_offset(). Clear Hook::$objects in tearDown so type mappings do not leak across tests. Fixes #268 --- CHANGELOG.md | 4 + .../mocking-wp-action-and-filter-hooks.md | 10 ++ php/WP_Mock.php | 13 +- php/WP_Mock/Functions.php | 4 +- php/WP_Mock/Hook.php | 16 ++- tests/Mocks/SampleSubClass.php | 4 + tests/Unit/WP_Mock/HookTest.php | 42 ++++++ tests/Unit/WP_MockTest.php | 132 ++++++++++++++++++ 8 files changed, 212 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e05343cc..c0f147b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/usage/mocking-wp-action-and-filter-hooks.md b/docs/usage/mocking-wp-action-and-filter-hooks.md index c79e52e9..ce314fc9 100644 --- a/docs/usage/mocking-wp-action-and-filter-hooks.md +++ b/docs/usage/mocking-wp-action-and-filter-hooks.md @@ -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: diff --git a/php/WP_Mock.php b/php/WP_Mock.php index 49ef1f3e..0f60220b 100644 --- a/php/WP_Mock.php +++ b/php/WP_Mock.php @@ -130,6 +130,7 @@ public static function tearDown(): void { self::$event_manager->flush(); self::$functionsManager->flush(); + \WP_Mock\Hook::$objects = []; Mockery::close(); Handler::cleanup(); @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 diff --git a/php/WP_Mock/Functions.php b/php/WP_Mock/Functions.php index ddf5cf6c..f1d68401 100644 --- a/php/WP_Mock/Functions.php +++ b/php/WP_Mock/Functions.php @@ -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; } diff --git a/php/WP_Mock/Hook.php b/php/WP_Mock/Hook.php index 7ca3a2e6..049c4bd1 100644 --- a/php/WP_Mock/Hook.php +++ b/php/WP_Mock/Hook.php @@ -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); diff --git a/tests/Mocks/SampleSubClass.php b/tests/Mocks/SampleSubClass.php index 32106fea..9c394377 100644 --- a/tests/Mocks/SampleSubClass.php +++ b/tests/Mocks/SampleSubClass.php @@ -7,4 +7,8 @@ class SampleSubClass extends SampleClass public function action(): void { } + + public function action2(): void + { + } } diff --git a/tests/Unit/WP_Mock/HookTest.php b/tests/Unit/WP_Mock/HookTest.php index fcfecf3b..8d6a0ef3 100644 --- a/tests/Unit/WP_Mock/HookTest.php +++ b/tests/Unit/WP_Mock/HookTest.php @@ -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; /** @@ -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 = []; } } diff --git a/tests/Unit/WP_MockTest.php b/tests/Unit/WP_MockTest.php index 82f34c09..642dfb03 100644 --- a/tests/Unit/WP_MockTest.php +++ b/tests/Unit/WP_MockTest.php @@ -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; @@ -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(); + } }