Skip to content

Commit

Permalink
feat(testing): Properties mocks (Mockery; mockery/mockery#1142) (#156)
Browse files Browse the repository at this point in the history
Related to #154
  • Loading branch information
LastDragon-ru authored Apr 28, 2024
1 parent 55faaf4 commit 54330d4
Show file tree
Hide file tree
Showing 5 changed files with 459 additions and 0 deletions.
66 changes: 66 additions & 0 deletions packages/testing/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -806,6 +806,72 @@ class ExampleTest extends TestCase {

Enjoy 😸

# Mocking properties (Mockery) 🧪

> [!IMPORTANT]
>
> Working prototype for [How to mock protected properties? (#1142)](https://github.com/mockery/mockery/issues/1142). Please note that implementation relies on Reflection and internal Mockery methods/properties.
[include:docblock]: ./src/Mockery/MockProperties.php ({"summary": false})
[//]: # (start: 998fe7ccccc11e3c54b93f9d6ea507c288be425a1dc4eca1cf5abe09d77c572e)
[//]: # (warning: Generated automatically. Do not edit.)

Limitations/Notes:

* Readonly properties should be uninitialized.
* Private properties aren't supported.
* Property value must be an object.
* Property must be used while test.
* Property can be mocked only once.
* Objects without methods will be marked as unused.

[//]: # (end: 998fe7ccccc11e3c54b93f9d6ea507c288be425a1dc4eca1cf5abe09d77c572e)

[include:example]: ./docs/Examples/MockProperties.php
[//]: # (start: 412cdd988d467ebb6083e17127a01aae689692590c8bf6273d2f3073cbf068cd)
[//]: # (warning: Generated automatically. Do not edit.)

```php
<?php declare(strict_types = 1);

// phpcs:disable PSR1.Files.SideEffects
// phpcs:disable PSR1.Classes.ClassDeclaration

namespace LastDragon_ru\LaraASP\Testing\Docs\Examples\MockProperties;

use LastDragon_ru\LaraASP\Testing\Mockery\MockProperties;
use Mockery;

class A {
public function __construct(
protected readonly B $b,
) {
// empty
}

public function a(): void {
$this->b->b();
}
}

class B {
public function b(): void {
echo 1;
}
}

$mock = Mockery::mock(A::class, MockProperties::class);
$mock
->shouldUseProperty('b')
->value(
Mockery::mock(B::class), // or just `new B()`.
);

$mock->a();
```

[//]: # (end: 412cdd988d467ebb6083e17127a01aae689692590c8bf6273d2f3073cbf068cd)

# Custom Test Requirements

Unfortunately, PHPUnit doesn't allow to add/extend existing requirements and probably will not:
Expand Down
36 changes: 36 additions & 0 deletions packages/testing/docs/Examples/MockProperties.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php declare(strict_types = 1);

// phpcs:disable PSR1.Files.SideEffects
// phpcs:disable PSR1.Classes.ClassDeclaration

namespace LastDragon_ru\LaraASP\Testing\Docs\Examples\MockProperties;

use LastDragon_ru\LaraASP\Testing\Mockery\MockProperties;
use Mockery;

class A {
public function __construct(
protected readonly B $b,
) {
// empty
}

public function a(): void {
$this->b->b();
}
}

class B {
public function b(): void {
echo 1;
}
}

$mock = Mockery::mock(A::class, MockProperties::class);
$mock
->shouldUseProperty('b')
->value(
Mockery::mock(B::class), // or just `new B()`.
);

$mock->a();
93 changes: 93 additions & 0 deletions packages/testing/src/Mockery/MockProperties.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
<?php declare(strict_types = 1);

namespace LastDragon_ru\LaraASP\Testing\Mockery;

use BadMethodCallException;
use LogicException;
use Mockery;
use Mockery\ExpectationDirector;
use Mockery\Mock;
use Mockery\MockInterface;
use Mockery\ReceivedMethodCalls;
use Override;
use ReflectionClass;
use ReflectionProperty;

use function count;

/**
* Adds support to mocking object properties.
*
* Limitations/Notes:
* * Readonly properties should be uninitialized.
* * Private properties aren't supported.
* * Property value must be an object.
* * Property must be used while test.
* * Property can be mocked only once.
* * Objects without methods will be marked as unused.
*
* @see https://github.com/mockery/mockery/issues/1142
*
* @experimental
*
* @phpstan-require-extends Mock
*/
trait MockProperties {
public function shouldUseProperty(string $name): MockedProperty {
// Required to avoid "Error: Cannot initialize readonly property X::$name from scope Mockery_*"
$class = (new ReflectionProperty($this, $name))->getDeclaringClass();

return new MockedProperty(function (object $value) use ($class, $name): void {
// Wrap to be able to check usage
if (!($value instanceof MockInterface)) {
$value = Mockery::mock($value);
}

// Set value
// * property can be redefined in subclasses, we should update them
// too or may get "must not be accessed before initialization" error.
$defined = $class;

do {
$property = $defined->hasProperty($name) ? $defined->getProperty($name) : null;
$defined = $defined->getParentClass();

$property?->setValue($this, $value);
} while ($defined);

// Expectation
// * required to detect unused properties
// * todo(testing): is there a better way for this?
$name = "{$this->mockery_getName()}::\${$name}";
$method = "\${$name}";
$director = $this->mockery_getExpectationsFor($method);

if (!$director) {
$director = new class ($name, $value) extends ExpectationDirector {
#[Override]
public function verify(): void {
$count = 0;
$calls = (new ReflectionClass($this->_mock))
->getProperty('_mockery_receivedMethodCalls')
->getValue($this->_mock);

if ($calls instanceof ReceivedMethodCalls) {
$property = (new ReflectionClass($calls))->getProperty('methodCalls');
$count = count((array) $property->getValue($calls));
}

if ($count === 0) {
throw new LogicException("Mocked property `{$this->_name}` is not used.");
}
}
};

$this->mockery_setExpectationsFor($method, $director);
} else {
throw new BadMethodCallException(
"The property `{$name}` already mocked.",
);
}
});
}
}
Loading

0 comments on commit 54330d4

Please sign in to comment.