From edf25b13eee840d0716fc02c987d348639cd544b Mon Sep 17 00:00:00 2001 From: pkly Date: Mon, 10 Aug 2026 13:12:14 +0200 Subject: [PATCH] Rework how mocks are created so phpunit stops complaining as much --- .github/workflows/php.yml | 45 ++- CLAUDE.md | 17 +- README.md | 67 +++- composer.json | 3 +- composer.lock | 2 +- phpunit.xml.dist | 3 + src/ServiceMockHelperTrait.php | 526 ++++++++++++++++++++++--- tests/Service/DuplicateTypeService.php | 30 ++ tests/Service/EnumService.php | 18 + tests/Service/InterfaceService.php | 32 ++ tests/Service/IntervalService.php | 18 + tests/Service/MessengerInterface.php | 12 + tests/Service/Mode.php | 11 + tests/Service/NoConstructorService.php | 15 + tests/Service/NullableService.php | 18 + tests/Service/ReadonlyService.php | 22 ++ tests/Service/RequiredService.php | 45 +++ tests/Service/ScalarService.php | 27 ++ tests/Unit/MockResolutionTest.php | 168 ++++++++ tests/Unit/PartialServiceTest.php | 75 ++++ tests/Unit/RequiredServiceTest.php | 94 +++++ tests/Unit/StubByDefaultTest.php | 115 ++++++ 22 files changed, 1266 insertions(+), 97 deletions(-) create mode 100644 tests/Service/DuplicateTypeService.php create mode 100644 tests/Service/EnumService.php create mode 100644 tests/Service/InterfaceService.php create mode 100644 tests/Service/IntervalService.php create mode 100644 tests/Service/MessengerInterface.php create mode 100644 tests/Service/Mode.php create mode 100644 tests/Service/NoConstructorService.php create mode 100644 tests/Service/NullableService.php create mode 100644 tests/Service/ReadonlyService.php create mode 100644 tests/Service/RequiredService.php create mode 100644 tests/Service/ScalarService.php create mode 100644 tests/Unit/MockResolutionTest.php create mode 100644 tests/Unit/PartialServiceTest.php create mode 100644 tests/Unit/RequiredServiceTest.php create mode 100644 tests/Unit/StubByDefaultTest.php diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index bd43f0e..d1aec23 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -4,10 +4,10 @@ on: [pull_request] jobs: install: strategy: + fail-fast: false matrix: - include: - - php-version: 8.4 - - php-version: 8.5 + php-version: ['8.4', '8.5'] + phpunit-version: ['12', '13'] runs-on: ubuntu-latest steps: @@ -17,9 +17,9 @@ jobs: uses: actions/cache@v4 with: path: vendor/ - key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}-${{ matrix.php-version }} + key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}-${{ matrix.php-version }}-phpunit${{ matrix.phpunit-version }} restore-keys: | - ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}-${{ matrix.php-version }} + ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}-${{ matrix.php-version }}-phpunit${{ matrix.phpunit-version }} - name: PHP Setup uses: shivammathur/setup-php@v2 @@ -34,17 +34,15 @@ jobs: composer global require --no-progress --no-scripts --no-plugins symfony/flex && composer global config --no-plugins allow-plugins.symfony/flex true - name: Install - env: - SYMFONY_REQUIRE: ${{ matrix.symfony-version }} run: | - COMPOSER_NO_SECURITY_BLOCKING=1 composer update --no-interaction --no-scripts --ansi --no-progress --prefer-dist + COMPOSER_NO_SECURITY_BLOCKING=1 composer update --no-interaction --no-scripts --ansi --no-progress --prefer-dist --with "phpunit/phpunit:^${{ matrix.phpunit-version }}" phpstan: strategy: + fail-fast: false matrix: - include: - - php-version: 8.4 - - php-version: 8.5 + php-version: ['8.4', '8.5'] + phpunit-version: ['12', '13'] runs-on: ubuntu-latest needs: install @@ -56,9 +54,9 @@ jobs: uses: actions/cache@v4 with: path: vendor/ - key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}-${{ matrix.php-version }} + key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}-${{ matrix.php-version }}-phpunit${{ matrix.phpunit-version }} restore-keys: | - ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}-${{ matrix.php-version }} + ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}-${{ matrix.php-version }}-phpunit${{ matrix.phpunit-version }} - name: PHP Setup uses: shivammathur/setup-php@v2 @@ -76,7 +74,8 @@ jobs: strategy: matrix: include: - - php-version: 8.5 + - php-version: '8.5' + phpunit-version: '13' runs-on: ubuntu-latest needs: install @@ -88,9 +87,9 @@ jobs: uses: actions/cache@v4 with: path: vendor/ - key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}-${{ matrix.php-version }} + key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}-${{ matrix.php-version }}-phpunit${{ matrix.phpunit-version }} restore-keys: | - ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}-${{ matrix.php-version }} + ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}-${{ matrix.php-version }}-phpunit${{ matrix.phpunit-version }} - name: PHP Setup uses: shivammathur/setup-php@v2 @@ -106,12 +105,12 @@ jobs: test: concurrency: - group: ${{ github.head_ref || github.ref }} + group: ${{ github.head_ref || github.ref }}-php${{ matrix.php-version }}-phpunit${{ matrix.phpunit-version }} strategy: + fail-fast: false matrix: - include: - - php-version: 8.4 - - php-version: 8.5 + php-version: ['8.4', '8.5'] + phpunit-version: ['12', '13'] runs-on: ubuntu-latest needs: @@ -125,9 +124,9 @@ jobs: uses: actions/cache@v4 with: path: vendor/ - key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}-${{ matrix.php-version }} + key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}-${{ matrix.php-version }}-phpunit${{ matrix.phpunit-version }} restore-keys: | - ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}-${{ matrix.php-version }} + ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}-${{ matrix.php-version }}-phpunit${{ matrix.phpunit-version }} - name: PHP Setup uses: shivammathur/setup-php@v2 @@ -139,4 +138,4 @@ jobs: - name: PHPUnit run: | - vendor/bin/phpunit --coverage-cobertura artifacts/code-coverage-cobertura.xml \ No newline at end of file + vendor/bin/phpunit --coverage-cobertura artifacts/code-coverage-cobertura.xml diff --git a/CLAUDE.md b/CLAUDE.md index 5c1b925..ed91dbb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,16 +22,21 @@ vendor/bin/phpunit This is a single-file PHP library: `src/ServiceMockHelperTrait.php` (namespace `Pkly`). -The trait is designed to be used inside PHPUnit `TestCase` subclasses. It auto-wires constructor dependencies and Symfony `#[Required]`-attributed setter methods with `createMock()` instances, so tests don't need to manually maintain constructor argument lists. +The trait is designed to be used inside PHPUnit `TestCase` subclasses. It auto-wires constructor dependencies and Symfony `#[Required]`-attributed setter methods with test doubles, so tests don't need to manually maintain constructor argument lists. -**Internal state:** `$this->mocks[$serviceClass][$dependencyClass]` stores every auto-created mock, keyed first by the service being tested, then by the dependency's class name. Custom-provided parameters (via `$constructor`/`$required` arrays) are not stored in `$this->mocks` and cannot be retrieved via `getMockedService()`. +**Stubs by default, mocks on demand:** PHPUnit emits a notice for every `MockObject` without expectations, so dependencies are `createStub()` instances unless the test asks for them via `getMockedService()`, which returns a `createMock()` instance. Mock-vs-stub is baked into the generated double class and cannot be changed afterwards, so `createRealMockedServiceInstance()` returns a **lazy ghost** (`ReflectionClass::newLazyGhost()`): the service is constructed the first time it is touched, by which point the test has declared which dependencies it wants as mocks. `#[Required]` setters run inside the initializer, since calling them from the outside would write a property and initialize the ghost early. + +**Internal state:** `$this->serviceStates` is an `SplObjectStorage` keyed by the created service instance, holding its class, its mockable parameters (`name` + `type`), the mocks and stubs created for it, and whether it has been initialized. `$this->serviceInstances` maps a class to its most recently created instance and `$this->currentServiceInstance` points at the last one created overall. Doubles are keyed by dependency type, or by `Type$parameterName` when a specific parameter is targeted; parameters supplied via `$constructor`/`$required` are not doubled and cannot be retrieved. **Key methods:** -- `createRealMockedServiceInstance(class, constructor[], required[])` — instantiates a real object, injecting mocks for all constructor params and `#[Required]` methods. -- `createRealPartialMockedServiceInstance(class, methods[], constructor[], required[])` — same but returns a `MockObject&T` with specified methods overridden (uses `MockBuilder`). -- `getMockedService(DependencyClass::class, ?serviceClass)` — retrieves a previously auto-created mock; defaults to the first stored service if `$service` is omitted. +- `createRealMockedServiceInstance(class, constructor[], required[])` — returns a lazy ghost of a real object; on first use it injects doubles for all constructor params and `#[Required]` methods. +- `createRealPartialMockedServiceInstance(class, methods[], constructor[], required[])` — returns a `MockObject&T` with specified methods overridden (uses `MockBuilder`). A generated class cannot be a ghost, so this stays eager and its dependencies are all mocks. +- `getMockedService(DependencyClass::class, ?parameterName, ?serviceClass)` — declares (or returns) a mock for a dependency; throws once the service has been initialized. Defaults to the most recently created service. +- `getStubbedService(DependencyClass::class, ?parameterName, ?serviceClass)` — same for the stub a dependency would get anyway, for configuring return values without expectations. + +**Constraints:** Only supports single-type parameters (union types throw). Built-in typed parameters must have a default value or be supplied explicitly via `$constructor`/`$required`, as must internal classes and enums. Nullable class parameters (`?Foo`) still receive a double. -**Constraints:** Only supports single-type parameters (union types throw). Built-in typed parameters must have a default value or be supplied explicitly via `$constructor`/`$required`. +**Tests:** `tests/Service` holds the fixtures, `tests/Unit` the test cases. `phpunit.xml.dist` sets `failOnPhpunitNotice`, so a dependency that is needlessly mocked fails the suite. ## Code style notes diff --git a/README.md b/README.md index 38d7a01..091a436 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Simply run composer require --dev pkly/phpunit-service-create-trait ``` -Currently compatible only with PHPUnit 12+ +Compatible with PHPUnit 12 and 13, requires PHP 8.4+ ## Usage @@ -41,19 +41,82 @@ class MyTestCase extends \PHPUnit\Framework\TestCase { } ``` -Any dependencies in the constructor as well as methods marked with Symfony's `#[Required]` attribute will be automatically plugged in with mocks. +Any dependencies in the constructor as well as methods marked with Symfony's `#[Required]` attribute will be automatically plugged in with test doubles. This allows you to write complex tests without wasting time updating your construct calls each time you modify something. +### Stubs by default, mocks on demand + +PHPUnit is separating mocks from stubs and complains about every mock object that has no expectations +configured (`No expectations were configured for the mock object for X ...`). Creating a mock for every +single dependency would drown your test run in those notices, so the trait only creates what you ask for: + +- a dependency you fetch with `getMockedService()` is a `MockObject` +- everything else is a plain `Stub` + +To make that possible the returned service is a **lazy ghost** - it is only really constructed the first +time you use it. That means expectations have to be declared *before* you touch the service: + +```php +public function testSomething(): void +{ + $service = $this->createRealMockedServiceInstance(AnyClass::class); + + // declare first ... + $this->getMockedService(EntityManagerInterface::class) + ->expects($this->once()) + ->method('flush'); + + // ... then use the service, this is where it gets constructed + $service->doSomething(); +} +``` + +Asking for a mock after the service has been used throws a `LogicException`, because such a mock could +never end up inside the already constructed service. + +If you only need to configure return values without setting any expectation, use `getStubbedService()`, +which returns the very same stub the service will receive. + +### Several dependencies of the same type + +Doubles are addressed by type, and a service depending on the same type twice simply receives the same +double for both parameters. Pass a parameter name as the second argument when you need them apart: + +```php +$first = $this->getMockedService(BasicService::class, 'first'); +$second = $this->getMockedService(BasicService::class, 'second'); +``` + +### Several services in one test + +`getMockedService()` and `getStubbedService()` use the most recently created service by default. Pass the +service class as the third argument to address any other one: + +```php +$this->getMockedService(BasicService::class, service: OtherService::class); +``` + ### Okay, but what if I need to use something custom? Simply assign the proper parameter name in either `$constructor` or `$required` in the appropriate methods. That will use your object instead of creating one for you, keep in mind you cannot retrieve it via `$this->getMockedService()`. +Some parameters always have to be provided that way: + +- scalar parameters without a default value +- internal classes (`\DateInterval` and friends) and enums, doubling those tends to break in confusing ways + +A nullable dependency (`?Foo`) still receives a double, pass `null` explicitly if that is what your test needs. + ### Partial objects? Sure, works the same, just use `createRealPartialMockedServiceInstance` instead of `createRealMockedServiceInstance`, in that case you must also specify the methods to override in your mock. Returned instance is `T&MockObject`. +A partial mock is a generated class and cannot be created lazily, so it is built immediately and all of its +dependencies are mocks, exactly like they used to be. Provide them explicitly or configure them if you want +to keep your test run notice free. + ### Feature requests? Sure, hit me up with an issue if you wish. diff --git a/composer.json b/composer.json index 21c38c5..bcaa1d3 100644 --- a/composer.json +++ b/composer.json @@ -10,9 +10,10 @@ ], "require": { "php": ">=8.4", - "phpunit/phpunit": ">=12" + "phpunit/phpunit": "^12 || ^13" }, "require-dev": { + "symfony/service-contracts": "^3", "phpstan/phpstan": "^2", "phpstan/phpstan-strict-rules": "^2", "phpstan/phpstan-phpunit": "^2", diff --git a/composer.lock b/composer.lock index b10705c..b63efce 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "223b48f05badd214721bc34b6099b5f6", + "content-hash": "ad21a992059275fc0716cee3fe77f14e", "packages": [ { "name": "myclabs/deep-copy", diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 0ca9670..44755a0 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -6,6 +6,9 @@ backupGlobals="false" colors="true" bootstrap="tests/bootstrap.php" + displayDetailsOnAllIssues="true" + failOnPhpunitNotice="true" + failOnPhpunitDeprecation="true" > diff --git a/src/ServiceMockHelperTrait.php b/src/ServiceMockHelperTrait.php index 3d7f6f0..0145b68 100644 --- a/src/ServiceMockHelperTrait.php +++ b/src/ServiceMockHelperTrait.php @@ -6,25 +6,174 @@ use PHPUnit\Framework\MockObject\MockBuilder; use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\MockObject\Stub; use PHPUnit\Framework\TestCase; +/** + * @phpstan-type ServiceState array{ + * class: class-string, + * parameters: list, + * mocks: array, + * stubs: array, + * initialized: bool + * } + */ trait ServiceMockHelperTrait { /** - * @var array> + * State of every service instance created by the trait, keyed by the instance itself. + * + * @var \SplObjectStorage|null */ - private array $mocks = []; + private \SplObjectStorage|null $serviceStates = null; /** - * @param class-string $class + * Last created instance of any given service class. * - * @return array{0: MockObject, 1: class-string}|array{0: mixed, 1: false} + * @var array + */ + private array $serviceInstances = []; + + private object|null $currentServiceInstance = null; + + /** + * @return \SplObjectStorage + */ + private function __serviceStates(): \SplObjectStorage + { + /** @var \SplObjectStorage $states */ + $states = $this->serviceStates ??= new \SplObjectStorage(); + + return $states; + } + + /** + * @param class-string $class + * @param list $parameters + * @param array $mocks + * @param array $stubs */ - private function __createMockedServiceParameter( + private function __registerService( + object $service, string $class, - \ReflectionParameter $parameter, - \ReflectionMethod $method + array $parameters, + array $mocks = [], + array $stubs = [], + bool $initialized = false + ): void { + $this->__serviceStates()->offsetSet($service, [ + 'class' => $class, + 'parameters' => $parameters, + 'mocks' => $mocks, + 'stubs' => $stubs, + 'initialized' => $initialized, + ]); + + $this->serviceInstances[$class] = $service; + $this->currentServiceInstance = $service; + } + + /** + * @return ServiceState + */ + private function __getServiceState( + object $service + ): array { + $states = $this->__serviceStates(); + + if (!$states->offsetExists($service)) { + throw new \LogicException('The given instance has not been created by the trait'); + } + + return $states->offsetGet($service); + } + + /** + * @param ServiceState $state + */ + private function __setServiceState( + object $service, + array $state + ): void { + $this->__serviceStates()->offsetSet($service, $state); + } + + /** + * @param class-string|null $service + * + * @return array{0: object, 1: ServiceState} + */ + private function __resolveServiceInstance( + string|null $service ): array { + if (null !== $service) { + if (!isset($this->serviceInstances[$service])) { + throw new \LogicException( + sprintf( + 'Service %s has not been created by the trait yet', + $service + ) + ); + } + + $instance = $this->serviceInstances[$service]; + } else { + $instance = $this->currentServiceInstance + ?? throw new \LogicException('No services have been mocked yet by the trait'); + } + + return [$instance, $this->__getServiceState($instance)]; + } + + /** + * Doubles are addressed by their type, or by their type and parameter name when a specific + * parameter of an ambiguous (repeated) type has to be targeted. + */ + private function __doubleKey( + string $type, + string|null $parameter + ): string { + return null === $parameter ? $type : $type.'$'.$parameter; + } + + /** + * @param ServiceState $state + */ + private function __assertParameterExists( + array $state, + string $type, + string|null $parameter + ): void { + foreach ($state['parameters'] as $definition) { + if ($definition['type'] !== $type) { + continue; + } + + if (null === $parameter || $definition['name'] === $parameter) { + return; + } + } + + throw new \LogicException( + sprintf( + null === $parameter + ? 'Mocked class %s not found in %s, it is either not a dependency of that service or has been provided explicitly' + : 'Mocked class %1$s is not the type of parameter $%3$s in %2$s, it is either not a dependency of that service or has been provided explicitly', + $type, + $state['class'], + $parameter ?? '' + ) + ); + } + + /** + * @param class-string $class + */ + private function __parameterType( + string $class, + \ReflectionMethod $method, + \ReflectionParameter $parameter + ): \ReflectionNamedType { if (null === ($type = $parameter->getType())) { throw new \LogicException( sprintf( @@ -51,13 +200,54 @@ private function __createMockedServiceParameter( $type = $type->getTypes()[0]; } - $defaultValue = false; + assert($type instanceof \ReflectionNamedType); + + return $type; + } + + /** + * @param class-string $class + * @param array $definedParameters + * + * @return list + */ + private function __indexMethodParameters( + string $class, + \ReflectionMethod $method, + array $definedParameters + ): array { + $parameters = []; + + foreach ($method->getParameters() as $parameter) { + if (array_key_exists($parameter->getName(), $definedParameters)) { + continue; + } + + $type = $this->__parameterType($class, $method, $parameter); + + if ($type->isBuiltin()) { + if (!$parameter->isDefaultValueAvailable()) { + throw new \LogicException( + sprintf( + 'Specify parameter $%s in %s::%s', + $parameter->getName(), + $class, + $method->getName() + ) + ); + } + + continue; + } + + /** @var class-string $typeName */ + $typeName = $type->getName(); - if ($type->isBuiltin()) { - if (!$parameter->isDefaultValueAvailable()) { + if (!class_exists($typeName) && !interface_exists($typeName)) { throw new \LogicException( sprintf( - 'Specify parameter $%s in %s::%s', + 'Cannot create a test double for unknown type %s of parameter $%s in %s::%s', + $typeName, $parameter->getName(), $class, $method->getName() @@ -65,28 +255,70 @@ private function __createMockedServiceParameter( ); } - $defaultValue = $parameter->isDefaultValueAvailable(); + $typeReflection = new \ReflectionClass($typeName); + + if ($typeReflection->isInternal() || $typeReflection->isEnum()) { + throw new \LogicException( + sprintf( + 'Specify parameter $%s in %s::%s explicitly, %s is %s and cannot be doubled safely', + $parameter->getName(), + $class, + $method->getName(), + $typeName, + $typeReflection->isEnum() ? 'an enum' : 'an internal class' + ) + ); + } + + $parameters[] = [ + 'name' => $parameter->getName(), + 'type' => $typeName, + ]; } - /** @var class-string $typeName */ - $typeName = $type->getName(); + return $parameters; + } - return [ - $defaultValue ? $parameter->getDefaultValue() : $this->createMock($typeName), - $defaultValue ? false : $typeName, - ]; + /** + * @param ServiceState $state + * @param class-string $type + */ + private function __resolveParameterDouble( + array &$state, + string $name, + string $type, + bool $asMock + ): object { + assert($this instanceof TestCase); + + foreach ([$this->__doubleKey($type, $name), $type] as $key) { + if (isset($state['mocks'][$key])) { + return $state['mocks'][$key]; + } + + if (isset($state['stubs'][$key])) { + return $state['stubs'][$key]; + } + } + + if ($asMock) { + return $state['mocks'][$type] = $this->createMock($type); + } + + return $state['stubs'][$type] = static::createStub($type); } /** - * @param class-string $class + * @param ServiceState $state * @param array $definedParameters * * @return list */ - private function __createAndGetMethodParams( - string $class, + private function __resolveMethodParameters( + array &$state, \ReflectionMethod $method, - array $definedParameters + array $definedParameters, + bool $asMock ): array { /** @var list $params */ $params = []; @@ -97,54 +329,165 @@ private function __createAndGetMethodParams( continue; } - [$mocked, $type] = $this->__createMockedServiceParameter($class, $parameter, $method); - $params[] = $mocked; + $type = $this->__parameterType($state['class'], $method, $parameter); - if (false === $type) { + if ($type->isBuiltin()) { + // builtin parameters always have a default value at this point, see __indexMethodParameters() + $params[] = $parameter->getDefaultValue(); continue; } - assert($mocked instanceof MockObject); + /** @var class-string $typeName */ + $typeName = $type->getName(); - $this->mocks[$class][$type] = $mocked; + $params[] = $this->__resolveParameterDouble($state, $parameter->getName(), $typeName, $asMock); } return $params; } /** + * @param \ReflectionClass $reflection + * + * @return list<\ReflectionMethod> + */ + private function __getRequiredMethods( + \ReflectionClass $reflection + ): array { + $methods = []; + + foreach ($reflection->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) { + if ([] === $method->getAttributes(\Symfony\Contracts\Service\Attribute\Required::class)) { + continue; + } + + $methods[] = $method; + } + + return $methods; + } + + /** + * Fetch (or declare) a mock for one of the dependencies of a service created by the trait. + * + * Declaring a mock must happen before the service is used for the first time, as the service + * is only built once it is actually touched. + * * @template TMockFetchTarget of object * * @param class-string $class + * @param string|null $parameter name of the parameter to target, required only when a service + * depends on the same type more than once + * @param class-string|null $service service to fetch the mock from, defaults to the last one created * * @return MockObject&TMockFetchTarget */ protected function getMockedService( - string $class + string $class, + string|null $parameter = null, + string|null $service = null ): mixed { - reset($this->mocks); - $service = key($this->mocks); + assert($this instanceof TestCase); + + [$instance, $state] = $this->__resolveServiceInstance($service); + $key = $this->__doubleKey($class, $parameter); - if (null === $service) { - throw new \LogicException('No services have been mocked yet by the trait'); + if (isset($state['mocks'][$key])) { + /** @var MockObject&TMockFetchTarget $mock */ + $mock = $state['mocks'][$key]; + + return $mock; } - if (null === ($object = ($this->mocks[$service][$class] ?? null))) { + $this->__assertParameterExists($state, $class, $parameter); + + if ($state['initialized']) { throw new \LogicException( sprintf( - 'Mocked class %s not found in %s', - $class, - $service + 'Service %s has already been created, mock %s cannot be used by it anymore. ' + .'Call getMockedService() before using the service for the first time.', + $state['class'], + $class + ) + ); + } + + $mock = $this->createMock($class); + $state['mocks'][$key] = $mock; + $this->__setServiceState($instance, $state); + + /** @var MockObject&TMockFetchTarget $mock */ + return $mock; + } + + /** + * Fetch (or declare) a stub for one of the dependencies of a service created by the trait. + * + * Dependencies nobody asks for are stubs anyway, this only hands the stub back so return + * values can be configured on it without turning it into a mock. + * + * @template TStubFetchTarget of object + * + * @param class-string $class + * @param string|null $parameter name of the parameter to target, required only when a service + * depends on the same type more than once + * @param class-string|null $service service to fetch the stub from, defaults to the last one created + * + * @return Stub&TStubFetchTarget + */ + protected function getStubbedService( + string $class, + string|null $parameter = null, + string|null $service = null + ): mixed { + assert($this instanceof TestCase); + + [$instance, $state] = $this->__resolveServiceInstance($service); + + foreach (array_unique([$this->__doubleKey($class, $parameter), $class]) as $key) { + if (isset($state['mocks'][$key])) { + /** @var MockObject&TStubFetchTarget $mock */ + $mock = $state['mocks'][$key]; + + return $mock; + } + + if (isset($state['stubs'][$key])) { + /** @var Stub&TStubFetchTarget $stub */ + $stub = $state['stubs'][$key]; + + return $stub; + } + } + + $this->__assertParameterExists($state, $class, $parameter); + + if ($state['initialized']) { + throw new \LogicException( + sprintf( + 'Service %s has already been created and no stub for %s has been used by it', + $state['class'], + $class ) ); } - /** @var MockObject&TMockFetchTarget $object */ - return $object; + $stub = static::createStub($class); + $state['stubs'][$this->__doubleKey($class, $parameter)] = $stub; + $this->__setServiceState($instance, $state); + + /** @var Stub&TStubFetchTarget $stub */ + return $stub; } /** - * @template TMockCreationTarget + * Create a real instance of a service with all of its dependencies doubled. + * + * The instance is a lazy ghost, its dependencies are resolved the first time the service is + * actually used. Dependencies asked for via getMockedService() become mocks, everything else + * becomes a stub, which keeps PHPUnit from complaining about mocks without expectations. + * + * @template TMockCreationTarget of object * * @param class-string $class * @param array $constructor @@ -165,29 +508,60 @@ protected function createRealMockedServiceInstance( throw new \LogicException('Failed to read class reflection, specify proper FQCN', previous: $e); } - // reset instance in memory - $this->mocks[$class] = []; - $params = []; - - if (null !== ($construct = $reflection->getConstructor())) { - $params = $this->__createAndGetMethodParams($class, $construct, $constructor); - } - - $service = new $class(...$params); + $construct = $reflection->getConstructor(); + $requiredMethods = $this->__getRequiredMethods($reflection); + $parameters = null !== $construct + ? $this->__indexMethodParameters($class, $construct, $constructor) + : []; - foreach ($reflection->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) { - if (empty($method->getAttributes(\Symfony\Contracts\Service\Attribute\Required::class))) { - continue; + foreach ($requiredMethods as $method) { + foreach ($this->__indexMethodParameters($class, $method, $required) as $parameter) { + $parameters[] = $parameter; } + } - $service->{$method->getName()}(...$this->__createAndGetMethodParams($class, $method, $required)); + try { + $service = $reflection->newLazyGhost( + function (object $instance) use ($construct, $requiredMethods, $constructor, $required): void { + $state = $this->__getServiceState($instance); + $state['initialized'] = true; + + if (null !== $construct) { + $params = $this->__resolveMethodParameters($state, $construct, $constructor, false); + $this->__setServiceState($instance, $state); + + $construct->invoke($instance, ...$params); + } else { + $this->__setServiceState($instance, $state); + } + + foreach ($requiredMethods as $method) { + $params = $this->__resolveMethodParameters($state, $method, $required, false); + $this->__setServiceState($instance, $state); + + $method->invoke($instance, ...$params); + } + } + ); + } catch (\ReflectionException $e) { + throw new \LogicException( + sprintf('Cannot create a lazy instance of %s', $class), + previous: $e + ); } + $this->__registerService($service, $class, $parameters); + return $service; } /** - * @template TMockCreationPartialTarget + * Create a partial mock of a service with all of its dependencies doubled. + * + * A partial mock is a generated class and has to be built eagerly, so its dependencies cannot + * be deferred either - they are all created as mocks, exactly like they used to be. + * + * @template TMockCreationPartialTarget of object * * @param class-string $class * @param list $methods @@ -210,28 +584,52 @@ protected function createRealPartialMockedServiceInstance( throw new \LogicException('Failed to read class reflection, specify proper FQCN', previous: $e); } - // reset instance in memory - $this->mocks[$class] = []; - $params = []; + $construct = $reflection->getConstructor(); + $requiredMethods = $this->__getRequiredMethods($reflection); + + /** @var ServiceState $state */ + $state = [ + 'class' => $class, + 'parameters' => null !== $construct + ? $this->__indexMethodParameters($class, $construct, $constructor) + : [], + 'mocks' => [], + 'stubs' => [], + 'initialized' => true, + ]; - if (null !== ($construct = $reflection->getConstructor())) { - $params = $this->__createAndGetMethodParams($class, $construct, $constructor); + foreach ($requiredMethods as $method) { + foreach ($this->__indexMethodParameters($class, $method, $required) as $parameter) { + $state['parameters'][] = $parameter; + } } + $params = null !== $construct + ? $this->__resolveMethodParameters($state, $construct, $constructor, true) + : []; + $service = new MockBuilder($this, $class) ->setConstructorArgs($params) ->disableOriginalClone() ->onlyMethods($methods) ->getMock(); - foreach ($reflection->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) { - if (empty($method->getAttributes(\Symfony\Contracts\Service\Attribute\Required::class))) { - continue; - } - - $service->{$method->getName()}(...$this->__createAndGetMethodParams($class, $method, $required)); + foreach ($requiredMethods as $method) { + $service->{$method->getName()}( + ...$this->__resolveMethodParameters($state, $method, $required, true) + ); } + $this->__registerService( + $service, + $class, + $state['parameters'], + $state['mocks'], + $state['stubs'], + true + ); + + // MockBuilder does not emit an event of its own, unlike createMock()/createStub() \PHPUnit\Event\Facade::emitter()->testCreatedPartialMockObject( $class, ...$methods, diff --git a/tests/Service/DuplicateTypeService.php b/tests/Service/DuplicateTypeService.php new file mode 100644 index 0000000..945ec85 --- /dev/null +++ b/tests/Service/DuplicateTypeService.php @@ -0,0 +1,30 @@ +first; + } + + public function getSecond(): BasicService + { + return $this->second; + } + + public function process( + string $input + ): string|null { + return $this->second->process($this->first->process($input) ?? $input); + } +} diff --git a/tests/Service/EnumService.php b/tests/Service/EnumService.php new file mode 100644 index 0000000..2e63794 --- /dev/null +++ b/tests/Service/EnumService.php @@ -0,0 +1,18 @@ +mode; + } +} diff --git a/tests/Service/InterfaceService.php b/tests/Service/InterfaceService.php new file mode 100644 index 0000000..e2ad052 --- /dev/null +++ b/tests/Service/InterfaceService.php @@ -0,0 +1,32 @@ +messenger; + } + + public function getService(): BasicService + { + return $this->service; + } + + public function notify( + string $input + ): bool { + $result = $this->service->process($input); + + return null !== $result && $this->messenger->send($result); + } +} diff --git a/tests/Service/IntervalService.php b/tests/Service/IntervalService.php new file mode 100644 index 0000000..bf11f4b --- /dev/null +++ b/tests/Service/IntervalService.php @@ -0,0 +1,18 @@ +interval; + } +} diff --git a/tests/Service/MessengerInterface.php b/tests/Service/MessengerInterface.php new file mode 100644 index 0000000..79e8e8a --- /dev/null +++ b/tests/Service/MessengerInterface.php @@ -0,0 +1,12 @@ +value; + } +} diff --git a/tests/Service/NullableService.php b/tests/Service/NullableService.php new file mode 100644 index 0000000..0378144 --- /dev/null +++ b/tests/Service/NullableService.php @@ -0,0 +1,18 @@ +service; + } +} diff --git a/tests/Service/ReadonlyService.php b/tests/Service/ReadonlyService.php new file mode 100644 index 0000000..9ef20cb --- /dev/null +++ b/tests/Service/ReadonlyService.php @@ -0,0 +1,22 @@ +service->process($input); + + return null !== $result ? $this->prefix.'-'.$result : null; + } +} diff --git a/tests/Service/RequiredService.php b/tests/Service/RequiredService.php new file mode 100644 index 0000000..cd4dda8 --- /dev/null +++ b/tests/Service/RequiredService.php @@ -0,0 +1,45 @@ +messenger = $messenger; + } + + public function getMessenger(): MessengerInterface + { + return $this->messenger; + } + + /** + * Deliberately touches no properties, so calling it does not initialize a lazy instance. + */ + public function describe(): string + { + return 'required-service'; + } + + public function notify( + string $input + ): bool { + $result = $this->service->process($input); + + return null !== $result && $this->messenger->send($result); + } +} diff --git a/tests/Service/ScalarService.php b/tests/Service/ScalarService.php new file mode 100644 index 0000000..e04c2a4 --- /dev/null +++ b/tests/Service/ScalarService.php @@ -0,0 +1,27 @@ +name; + } + + public function process( + string $input + ): string|null { + $result = $this->service->process($input); + + return null !== $result ? $this->name.':'.$result : null; + } +} diff --git a/tests/Unit/MockResolutionTest.php b/tests/Unit/MockResolutionTest.php new file mode 100644 index 0000000..22ac772 --- /dev/null +++ b/tests/Unit/MockResolutionTest.php @@ -0,0 +1,168 @@ +createRealMockedServiceInstance(DuplicateTypeService::class); + + $mock = $this->getMockedService(BasicService::class); + $mock->expects(static::exactly(2)) + ->method('process') + ->willReturn('SAME'); + + static::assertSame('SAME', $service->process('hello')); + static::assertSame($mock, $service->getFirst()); + static::assertSame($mock, $service->getSecond()); + } + + public function testSameTypeParametersCanBeTargetedByName(): void + { + $service = $this->createRealMockedServiceInstance(DuplicateTypeService::class); + + $first = $this->getMockedService(BasicService::class, 'first'); + $second = $this->getMockedService(BasicService::class, 'second'); + + static::assertNotSame($first, $second); + + $first->expects(static::once()) + ->method('process') + ->with('hello') + ->willReturn('FIRST'); + + $second->expects(static::once()) + ->method('process') + ->with('FIRST') + ->willReturn('SECOND'); + + static::assertSame('SECOND', $service->process('hello')); + static::assertSame($first, $service->getFirst()); + static::assertSame($second, $service->getSecond()); + } + + public function testMostRecentlyCreatedServiceIsUsedByDefault(): void + { + $extended = $this->createRealMockedServiceInstance(ExtendedService::class); + $interface = $this->createRealMockedServiceInstance(InterfaceService::class); + + $mock = $this->getMockedService(BasicService::class); + $mock->expects(static::never()) + ->method('process'); + + static::assertSame($mock, $interface->getService()); + + $other = $this->getMockedService(BasicService::class, service: ExtendedService::class); + $other->expects(static::once()) + ->method('process') + ->with('hello') + ->willReturn('NEW-hello'); + + static::assertNotSame($mock, $other); + static::assertSame('NEW-hello', $extended->work('hello')); + } + + public function testMockingAnUnknownDependencyThrows(): void + { + $this->createRealMockedServiceInstance(ExtendedService::class); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage(MessengerInterface::class); + + $this->getMockedService(MessengerInterface::class); + } + + public function testMockingBeforeAnyServiceIsCreatedThrows(): void + { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('No services have been mocked yet by the trait'); + + $this->getMockedService(BasicService::class); + } + + public function testMockingAProvidedParameterThrows(): void + { + $this->createRealMockedServiceInstance(ExtendedService::class, ['service' => new BasicService()]); + + $this->expectException(\LogicException::class); + + $this->getMockedService(BasicService::class); + } + + public function testMockingAfterTheServiceHasBeenUsedThrows(): void + { + $service = $this->createRealMockedServiceInstance(ExtendedService::class); + + static::assertNull($service->work('hello')); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('has already been created'); + + $this->getMockedService(BasicService::class); + } + + public function testInternalDependencyThrows(): void + { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('is an internal class'); + + $this->createRealMockedServiceInstance(IntervalService::class); + } + + public function testInternalDependencyCanBeProvided(): void + { + $interval = new \DateInterval('P1D'); + $service = $this->createRealMockedServiceInstance(IntervalService::class, ['interval' => $interval]); + + static::assertSame($interval, $service->getInterval()); + } + + public function testEnumDependencyThrows(): void + { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('is an enum'); + + $this->createRealMockedServiceInstance(EnumService::class); + } + + public function testEnumDependencyCanBeProvided(): void + { + $service = $this->createRealMockedServiceInstance(EnumService::class, ['mode' => Mode::Fast]); + + static::assertSame(Mode::Fast, $service->getMode()); + } + + public function testScalarWithoutDefaultValueThrows(): void + { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Specify parameter $name'); + + $this->createRealMockedServiceInstance(ScalarService::class); + } + + public function testUnknownClassThrows(): void + { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Failed to read class reflection'); + + // @phpstan-ignore-next-line + $this->createRealMockedServiceInstance('Pkly\Tests\Service\ThisDoesNotExist'); + } +} diff --git a/tests/Unit/PartialServiceTest.php b/tests/Unit/PartialServiceTest.php new file mode 100644 index 0000000..c147a98 --- /dev/null +++ b/tests/Unit/PartialServiceTest.php @@ -0,0 +1,75 @@ +createRealPartialMockedServiceInstance(ExtendedService::class, ['work']); + + $this->getMockedService(BasicService::class) + ->expects(static::never()) + ->method('process'); + + $service->expects(static::once()) + ->method('work') + ->with('hello') + ->willReturn('OVERRIDDEN'); + + static::assertSame('OVERRIDDEN', $service->work('hello')); + } + + public function testPartialMockKeepsUsingMocksForItsDependencies(): void + { + $service = $this->createRealPartialMockedServiceInstance(InterfaceService::class, ['notify']); + + $messenger = $this->getMockedService(MessengerInterface::class); + $messenger->expects(static::never()) + ->method('send'); + + // a partial mock is built eagerly, so all of its dependencies are mocks + $this->getMockedService(BasicService::class) + ->expects(static::never()) + ->method('process'); + + static::assertInstanceOf(MockObject::class, $messenger); + static::assertSame($messenger, $service->getMessenger()); + + $service->expects(static::once()) + ->method('notify') + ->with('hello') + ->willReturn(true); + + static::assertTrue($service->notify('hello')); + } + + public function testPartialMockAcceptsProvidedParameters(): void + { + $basic = new BasicService(); + $service = $this->createRealPartialMockedServiceInstance( + ExtendedService::class, + ['work'], + ['service' => $basic] + ); + + $service->expects(static::once()) + ->method('work') + ->with('hello') + ->willReturn('OVERRIDDEN'); + + static::assertSame('OVERRIDDEN', $service->work('hello')); + } +} diff --git a/tests/Unit/RequiredServiceTest.php b/tests/Unit/RequiredServiceTest.php new file mode 100644 index 0000000..c348c82 --- /dev/null +++ b/tests/Unit/RequiredServiceTest.php @@ -0,0 +1,94 @@ +createRealMockedServiceInstance(RequiredService::class); + + static::assertInstanceOf(Stub::class, $service->getMessenger()); + static::assertNotInstanceOf(MockObject::class, $service->getMessenger()); + } + + public function testRequiredSetterRunsOnlyOnceTheServiceIsUsed(): void + { + $reflection = new \ReflectionClass(RequiredService::class); + $service = $this->createRealMockedServiceInstance(RequiredService::class); + + static::assertTrue($reflection->isUninitializedLazyObject($service)); + + // a method that touches no properties must not initialize the service + static::assertSame('required-service', $service->describe()); + static::assertTrue($reflection->isUninitializedLazyObject($service)); + + $this->getMockedService(MessengerInterface::class) + ->expects(static::once()) + ->method('send') + ->with('NEW-hello') + ->willReturn(true); + + $this->getStubbedService(BasicService::class) + ->method('process') + ->willReturn('NEW-hello'); + + static::assertTrue($service->notify('hello')); + static::assertFalse($reflection->isUninitializedLazyObject($service)); + } + + public function testRequiredSetterAcceptsProvidedParameters(): void + { + $messenger = new class implements MessengerInterface { + public function send( + string $message + ): bool { + return 'NEW-hello' === $message; + } + }; + + $service = $this->createRealMockedServiceInstance( + RequiredService::class, + required: ['messenger' => $messenger] + ); + + $this->getStubbedService(BasicService::class) + ->method('process') + ->willReturn('NEW-hello'); + + static::assertTrue($service->notify('hello')); + static::assertSame($messenger, $service->getMessenger()); + } + + public function testProvidedRequiredParameterCannotBeMocked(): void + { + $messenger = new class implements MessengerInterface { + public function send( + string $message + ): bool { + return true; + } + }; + + $this->createRealMockedServiceInstance( + RequiredService::class, + required: ['messenger' => $messenger] + ); + + $this->expectException(\LogicException::class); + + $this->getMockedService(MessengerInterface::class); + } +} diff --git a/tests/Unit/StubByDefaultTest.php b/tests/Unit/StubByDefaultTest.php new file mode 100644 index 0000000..d2c8705 --- /dev/null +++ b/tests/Unit/StubByDefaultTest.php @@ -0,0 +1,115 @@ +createRealMockedServiceInstance(InterfaceService::class); + + static::assertInstanceOf(Stub::class, $service->getMessenger()); + static::assertNotInstanceOf(MockObject::class, $service->getMessenger()); + + static::assertInstanceOf(Stub::class, $service->getService()); + static::assertNotInstanceOf(MockObject::class, $service->getService()); + } + + public function testOnlyRequestedDependenciesBecomeMocks(): void + { + $service = $this->createRealMockedServiceInstance(InterfaceService::class); + + $this->getMockedService(MessengerInterface::class) + ->expects(static::once()) + ->method('send') + ->with('NEW-hello') + ->willReturn(true); + + $this->getStubbedService(BasicService::class) + ->method('process') + ->willReturn('NEW-hello'); + + static::assertTrue($service->notify('hello')); + + static::assertInstanceOf(MockObject::class, $service->getMessenger()); + static::assertNotInstanceOf(MockObject::class, $service->getService()); + } + + public function testInterfaceDependencyIsDoubled(): void + { + $service = $this->createRealMockedServiceInstance(InterfaceService::class); + + static::assertInstanceOf(MessengerInterface::class, $service->getMessenger()); + } + + public function testReadonlyServiceWithPromotedProperties(): void + { + $service = $this->createRealMockedServiceInstance(ReadonlyService::class); + + $this->getMockedService(BasicService::class) + ->expects(static::once()) + ->method('process') + ->with('hello') + ->willReturn('NEW-hello'); + + static::assertSame('READONLY-NEW-hello', $service->describe('hello')); + static::assertInstanceOf(BasicService::class, $service->service); + static::assertSame('READONLY', $service->prefix); + } + + public function testScalarParameterUsesItsDefaultValue(): void + { + $service = $this->createRealMockedServiceInstance(ReadonlyService::class); + + static::assertSame('READONLY', $service->prefix); + } + + public function testScalarParameterCanBeProvided(): void + { + $service = $this->createRealMockedServiceInstance(ScalarService::class, ['name' => 'CUSTOM']); + + $this->getStubbedService(BasicService::class) + ->method('process') + ->willReturn('NEW-hello'); + + static::assertSame('CUSTOM', $service->getName()); + static::assertSame('CUSTOM:NEW-hello', $service->process('hello')); + } + + public function testServiceWithoutConstructor(): void + { + $service = $this->createRealMockedServiceInstance(NoConstructorService::class); + + static::assertSame('no-constructor', $service->getValue()); + } + + public function testNullableDependencyIsStillDoubled(): void + { + $service = $this->createRealMockedServiceInstance(NullableService::class); + + static::assertInstanceOf(Stub::class, $service->getService()); + } + + public function testNullableDependencyCanBeSetToNull(): void + { + $service = $this->createRealMockedServiceInstance(NullableService::class, ['service' => null]); + + static::assertNull($service->getService()); + } +}