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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@ 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]
### Added
- Support for `Closure` as a dynamic return value on filter hook expectations. A closure passed to `onFilter(...)->with(...)->reply()` or as the second argument of `expectFilter()` is now invoked with the runtime filter arguments and its return value is used as the filter reply.

### Removed
- Typo method `iExcpectWhenIRun()` in the Behat `FunctionsContext` (it duplicated a step definition and prevented the Behat suite from running at all due to a parse error).

## [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
30 changes: 29 additions & 1 deletion docs/usage/mocking-wp-action-and-filter-hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,12 +201,40 @@ use MyPlugin\NewClass;

final class MyClassTest extends TestCase
{
public function testAnonymousObject() : void
public function testAnonymousObject() : void
{
WP_Mock::expectFilter('custom_content_filter', WP_Mock\Functions::type(NewClass::class));

$this->assertInstanceOf(NewClass::class, (new MyClass())->filterContent());
$this->assertConditionsMet();
}
}
```

## Dynamic return values with a Closure

When a single static return value is not enough, pass a `Closure` to `reply()`. The closure is invoked with the runtime arguments passed to `apply_filters()` and its return value is used as the filter reply. This is useful for loop or iteration scenarios where the expected value changes per call.

```php
WP_Mock::onFilter('custom_content_filter')
->withAnyArgs()
->reply(function ( $value ) {
return strtoupper( $value );
});

apply_filters( 'custom_content_filter', 'hello' ); // 'HELLO'
apply_filters( 'custom_content_filter', 'world' ); // 'WORLD'
```

The same works through `WP_Mock::expectFilter()`. Pass the `Closure` as the second argument and the runtime filter value(s) are forwarded into it on each call:

```php
WP_Mock::expectFilter('custom_content_filter', function ( $value ) {
return strtoupper( $value );
});

apply_filters( 'custom_content_filter', 'hello' ); // 'HELLO'
```

The closure can take as many arguments as the filter is invoked with. Returning a value from the closure is required; actions are not affected.
```
9 changes: 0 additions & 9 deletions features/bootstrap/FunctionsContext.php
Original file line number Diff line number Diff line change
Expand Up @@ -87,15 +87,6 @@ public function iExpectWhenIRunWithArgs( $return, $function, TableNode $args ) {
\PHPUnit\Framework\Assert::assertEquals( $return, call_user_func_array( $function, $args->getRow( 0 ) ) );
}

/**
* @Then I expect :return when I run :function
*
* @deprected use static::iExpectWhenIRun instead
*/
public function iExcpectWhenIRun( $return, $function ) {
static::iExpectWhenIRun( $return, $function )
}

/**
* @Then I expect :return when I run :function
*/
Expand Down
23 changes: 23 additions & 0 deletions features/bootstrap/HooksContext.php
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,29 @@ public function iExpectFilterToRespondWith( $filter, $response ) {
$this->iExpectFilterToRespondToWith( $filter, null, $response );
}

/**
* @Given I expect filter :filter to reply dynamically with a :operation closure
*/
public function iExpectFilterToReplyDynamicallyWithClosure( $filter, $operation ) {
$callback = $this->makeDynamicReplyCallback( $operation );
WP_Mock::onFilter( $filter )->with( \Closure::class )->reply( $callback );
}

private function makeDynamicReplyCallback( $operation ) {
switch ( $operation ) {
case 'uppercase':
return function ( $value ) {
return strtoupper( (string) $value );
};
case 'concat':
return function ( $a, $b ) {
return $a . $b;
};
}

throw new \InvalidArgumentException( sprintf( 'Unknown dynamic-reply operation "%s"', $operation ) );
}

/**
* @When I apply the filter :filter with :with
*/
Expand Down
11 changes: 11 additions & 0 deletions features/hooks.feature
Original file line number Diff line number Diff line change
Expand Up @@ -226,3 +226,14 @@ Feature: Hook mocking
| foobar | bazbat |
When I do nothing
Then tearDown should not fail

Scenario: filter reply can be a closure invoked with runtime args
Given I expect filter "the_content" to reply dynamically with a uppercase closure
When I apply the filter "the_content" with "hello"
Then The filter "the_content" should return "HELLO"

Scenario: filter reply closure receives all runtime args
Given I expect filter "the_content" to reply dynamically with a concat closure
When I apply the filter "the_content" with:
| foo | bar |
Then The filter "the_content" should return "foobar"
26 changes: 22 additions & 4 deletions php/WP_Mock.php
Original file line number Diff line number Diff line change
Expand Up @@ -244,19 +244,37 @@ public static function expectAction(string $action) : void
/**
* Adds an expectation that a filter will be applied during the test.
*
* A `\Closure` may be passed as a variadic argument; it is invoked with the runtime filter arguments and its return value is used as the filter reply. Non-Closure callables are not supported in this slot and will fall through to the standard pass-through behavior.
*
* @param string $filter expected filter
* @return void
*/
public static function expectFilter(string $filter) : void
{
$args = func_num_args() > 1 ? array_slice(func_get_args(), 1) : array( null );

$callback = null;
foreach ($args as $arg) {
if ($arg instanceof \Closure) {
$callback = $arg;
break;
}
}

$mocked_filter = self::onFilter($filter);

if ($callback !== null) {
/** @var \WP_Mock\Filter_Responder $responder */
$responder = $mocked_filter->with(\Closure::class);
$responder->reply($callback);
return;
}

$intercept = Mockery::mock('intercept');
$intercept->shouldReceive('intercepted')->atLeast()->once()->andReturnUsing(function ($value) {
return $value;
});
$args = func_num_args() > 1 ? array_slice(func_get_args(), 1) : array( null );

$mocked_filter = self::onFilter($filter);
$responder = call_user_func_array(array( $mocked_filter, 'with' ), $args);
$responder = call_user_func_array(array( $mocked_filter, 'with' ), $args);
$responder->reply(new WP_Mock\InvokedFilterValue(array( $intercept, 'intercepted' )));
}

Expand Down
15 changes: 14 additions & 1 deletion php/WP_Mock/Filter.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,13 @@ public function apply($args)

if ($args[0] === null && count($args) === 1) {
if (isset($this->processors['argsnull'])) {
return $this->processors['argsnull']->send();
return call_user_func_array(array($this->processors['argsnull'], 'send'), $args);
}

if (isset($this->processors['__CLOSURE__'])) {
return call_user_func_array(array($this->processors['__CLOSURE__'], 'send'), $args);
}

$this->strict_check();

return null;
Expand All @@ -38,6 +43,10 @@ public function apply($args)
foreach ($args as $arg) {
$key = $this->safe_offset($arg);
if (! is_array($processors) || ! isset($processors[ $key ])) {
if (isset($this->processors['__CLOSURE__'])) {
return call_user_func_array(array($this->processors['__CLOSURE__'], 'send'), $args);
}

$this->strict_check();

return $arg;
Expand Down Expand Up @@ -91,6 +100,10 @@ public function reply($value)

public function send()
{
if ($this->value instanceof \Closure) {
return ($this->value)(...func_get_args());
}

if ($this->value instanceof InvokedFilterValue) {
return call_user_func_array($this->value, func_get_args());
}
Expand Down
2 changes: 1 addition & 1 deletion phpstan-baseline.neon
Original file line number Diff line number Diff line change
Expand Up @@ -287,7 +287,7 @@ parameters:

-
message: "#^Parameter \\#1 \\$function of function call_user_func_array expects callable\\(\\)\\: mixed, array\\{mixed, 'send'\\} given\\.$#"
count: 1
count: 4
path: php/WP_Mock/Filter.php

-
Expand Down
77 changes: 77 additions & 0 deletions tests/Unit/WP_MockTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -401,4 +401,81 @@ public function testMultipleOnFilterPassesWithAnyArgs(): void

Mockery::close();
}

/**
* @covers \WP_Mock::expectFilter()
*
* @runInSeparateProcess
* @preserveGlobalState disabled
*
* @return void
* @throws Exception|InvalidArgumentException
*/
public function testExpectFilterWithClosureRepliesDynamically(): void
{
WP_Mock::bootstrap();

WP_Mock::expectFilter('testFilter', function ($value) {
return strtoupper($value);
});

$this->assertSame('HELLO', apply_filters('testFilter', 'hello'));
$this->assertSame('WORLD', apply_filters('testFilter', 'world'));

WP_Mock::assertFiltersCalled();

Mockery::close();
}

/**
* @covers \WP_Mock::onFilter()
*
* @runInSeparateProcess
* @preserveGlobalState disabled
*
* @return void
* @throws Exception|InvalidArgumentException
*/
public function testOnFilterWithClosureReplyIsInvokedWithRuntimeArgs(): void
{
WP_Mock::bootstrap();

/** @phpstan-ignore-next-line */
WP_Mock::onFilter('testFilter')
->with(\Closure::class)
->reply(function ($a, $b) {
return $a . $b;
});

/** @phpstan-ignore-next-line */
$this->assertSame('ab', apply_filters('testFilter', 'a', 'b'));

Mockery::close();
}

/**
* @covers \WP_Mock::onFilter()
*
* @runInSeparateProcess
* @preserveGlobalState disabled
*
* @return void
* @throws Exception|InvalidArgumentException
*/
public function testOnFilterWithClosureMatcherAndClosureReply(): void
{
WP_Mock::bootstrap();

/** @phpstan-ignore-next-line */
WP_Mock::onFilter('testFilter')
->with(\Closure::class)
->reply(function ($a, $b) {
return $a . '-' . $b;
});

/** @phpstan-ignore-next-line */
$this->assertSame('x-y', apply_filters('testFilter', 'x', 'y'));

Mockery::close();
}
}