Skip to content

Commit

Permalink
Allow mocking closures
Browse files Browse the repository at this point in the history
Mocking closures is doable today by mocking __invoke on an invokable class. However this is troublesome, as it requires writing an invokable class, and mocking a class method.

This new helper makes things easier via a new `$this->createClosureMock()` method.
  • Loading branch information
mnapoli committed Mar 20, 2024
1 parent 3ad902d commit 512dd4b
Show file tree
Hide file tree
Showing 3 changed files with 124 additions and 0 deletions.
24 changes: 24 additions & 0 deletions src/Framework/MockObject/Runtime/Stub/ClosureMock.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

namespace PHPUnit\Framework\MockObject\Stub;

use PHPUnit\Framework\MockObject\Builder\InvocationMocker;
use PHPUnit\Framework\MockObject\Rule\InvocationOrder;

class ClosureMock
{
public function __invoke()

Check warning on line 10 in src/Framework/MockObject/Runtime/Stub/ClosureMock.php

View check run for this annotation

Codecov / codecov/patch

src/Framework/MockObject/Runtime/Stub/ClosureMock.php#L10

Added line #L10 was not covered by tests
{
}

public function expectsClosure(InvocationOrder $invocationRule): InvocationMocker
{
return $this->expects($invocationRule)

Check failure on line 16 in src/Framework/MockObject/Runtime/Stub/ClosureMock.php

View workflow job for this annotation

GitHub Actions / Type Checker

UndefinedMethod

src/Framework/MockObject/Runtime/Stub/ClosureMock.php:16:23: UndefinedMethod: Method PHPUnit\Framework\MockObject\Stub\ClosureMock::expects does not exist (see https://psalm.dev/022)
->method('__invoke');
}

public function closure(): InvocationMocker
{
return $this->method('__invoke');

Check failure on line 22 in src/Framework/MockObject/Runtime/Stub/ClosureMock.php

View workflow job for this annotation

GitHub Actions / Type Checker

UndefinedMethod

src/Framework/MockObject/Runtime/Stub/ClosureMock.php:22:23: UndefinedMethod: Method PHPUnit\Framework\MockObject\Stub\ClosureMock::method does not exist (see https://psalm.dev/022)
}
}
12 changes: 12 additions & 0 deletions src/Framework/TestCase.php
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@
use PHPUnit\Framework\MockObject\Rule\InvokedAtMostCount as InvokedAtMostCountMatcher;
use PHPUnit\Framework\MockObject\Rule\InvokedCount as InvokedCountMatcher;
use PHPUnit\Framework\MockObject\Stub;
use PHPUnit\Framework\MockObject\Stub\ClosureMock;
use PHPUnit\Framework\MockObject\Stub\ConsecutiveCalls as ConsecutiveCallsStub;
use PHPUnit\Framework\MockObject\Stub\Exception as ExceptionStub;
use PHPUnit\Framework\MockObject\Stub\ReturnArgument as ReturnArgumentStub;
Expand Down Expand Up @@ -1387,6 +1388,17 @@ final protected function createPartialMock(string $originalClassName, array $met
return $partialMock;
}

/**
* Creates mock of a closure.
*
* @throws InvalidArgumentException
* @throws MockObjectException
*/
final protected function createClosureMock(): MockObject|ClosureMock
{
return $this->createPartialMock(ClosureMock::class, ['__invoke']);
}

/**
* Creates a test proxy for the specified class.
*
Expand Down
88 changes: 88 additions & 0 deletions tests/unit/Framework/MockObject/Creation/CreateClosureMockTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
<?php declare(strict_types=1);
/*
* This file is part of PHPUnit.
*
* (c) Sebastian Bergmann <sebastian@phpunit.de>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace PHPUnit\Framework\MockObject;

use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\Attributes\Medium;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\ExpectationFailedException;
use PHPUnit\Framework\MockObject\Generator\ClassIsFinalException;
use PHPUnit\Framework\MockObject\Stub\ClosureMock;
use PHPUnit\Framework\TestCase;
use PHPUnit\TestFixture\MockObject\ExtendableClass;
use PHPUnit\TestFixture\MockObject\InterfaceWithReturnTypeDeclaration;
use ReflectionProperty;

#[Group('test-doubles')]
#[Group('test-doubles/creation')]
#[Group('test-doubles/mock-object')]
#[Medium]
#[TestDox('createClosureMock()')]
final class CreateClosureMockTest extends TestCase
{
public function testCreateClosureMock(): void
{
$mock = $this->createClosureMock();

$this->assertInstanceOf(ClosureMock::class, $mock);
$this->assertInstanceOf(Stub::class, $mock);
}

public function testCreateClosureMockWithReturnValue(): void
{
$mock = $this->createClosureMock();

$mock->closure()->willReturn(123);

$this->assertSame(123, $mock());
}

public function testCreateClosureMockWithExpectation(): void
{
$mock = $this->createClosureMock();

$mock->expectsClosure($this->once())
->willReturn(123);

$this->assertSame(123, $mock());
}

public function testClosureMockAppliesExpects(): void
{
$mock = $this->createClosureMock();

$mock->expectsClosure($this->once());

$this->assertThatMockObjectExpectationFails(
"Expectation failed for method name is \"__invoke\" when invoked 1 time.\nMethod was expected to be called 1 time, actually called 0 times.\n",
$mock,
);
}

private function assertThatMockObjectExpectationFails(string $expectationFailureMessage, MockObject $mock, string $methodName = '__phpunit_verify', array $arguments = []): void
{
try {
call_user_func_array([$mock, $methodName], $arguments);
} catch (ExpectationFailedException|MatchBuilderNotFoundException $e) {
$this->assertSame($expectationFailureMessage, $e->getMessage());

return;
} finally {
$this->resetMockObjects();
}

$this->fail();
}

private function resetMockObjects(): void
{
(new ReflectionProperty(TestCase::class, 'mockObjects'))->setValue($this, []);
}
}

0 comments on commit 512dd4b

Please sign in to comment.