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(); + } }