diff --git a/CLAUDE.md b/CLAUDE.md index ed91dbb..3f95dea 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,17 +24,17 @@ This is a single-file PHP library: `src/ServiceMockHelperTrait.php` (namespace ` 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. -**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. +**Unregistered doubles:** PHPUnit emits a notice for every `MockObject` without expectations, so every dependency is built through `MockGenerator::testDouble()` directly (`__createUnregisteredMock()`) instead of `createMock()`/`createStub()`. Such a double is a full `MockObject` — `expects()` works on it — but the `TestCase` never learns about it, so PHPUnit neither verifies it nor complains. `getMockedService()` is what registers it (`registerMockObject()` + a `testCreatedMockObject` event), handing ownership to the test; `getStubbedService()` returns the same object unregistered, narrowed to `Stub`. Services are constructed eagerly, so doubles can be configured before or after the service is used. -**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. +**Internal state:** `$this->serviceStates` is an `SplObjectStorage` keyed by the created service instance, holding its class, its mockable parameters (`name` + `type`), the doubles created for it, and which of them have been registered. `$this->serviceInstances` maps a class to its most recently created instance and `$this->currentServiceInstance` points at the last one created overall. Doubles are keyed `Type$parameterName` — one per parameter, so two parameters of the same type get two distinct doubles and must be addressed by name. Parameters supplied via `$constructor`/`$required` are not doubled and cannot be retrieved. **Key methods:** -- `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. +- `createRealMockedServiceInstance(class, constructor[], required[])` — returns a real instance with doubles injected into all constructor params and `#[Required]` methods. +- `createRealPartialMockedServiceInstance(class, methods[], constructor[], required[])` — returns a `MockObject&T` with specified methods overridden (uses `MockBuilder`, plus a manual `testCreatedPartialMockObject` event since `MockBuilder` emits none). +- `getMockedService(DependencyClass::class, ?parameterName, ?serviceClass)` — registers and returns the mock for a dependency. Defaults to the most recently created service, falling back to the other trait-created services when that one has no such dependency (throws when several match). +- `getStubbedService(DependencyClass::class, ?parameterName, ?serviceClass)` — same double, left unregistered, 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`, as must enums. Internal classes are passed to PHPUnit like anything else and mostly double fine. Nullable class parameters (`?Foo`) still receive a double. **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. diff --git a/README.md b/README.md index 091a436..e03d246 100644 --- a/README.md +++ b/README.md @@ -48,39 +48,37 @@ This allows you to write complex tests without wasting time updating your constr 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: +single dependency would drown your test run in those notices, so every dependency is created as a double +that PHPUnit does not know about - it is never verified and never complained about. Fetching one is what +hands it over to the test: -- a dependency you fetch with `getMockedService()` is a `MockObject` -- everything else is a plain `Stub` +- `getMockedService()` registers the double with the test case and returns it as a `MockObject`, so + `expects()` is verified for you (and PHPUnit does tell you off if you then configure nothing on it) +- `getStubbedService()` returns the very same object as a `Stub`, still unregistered, for when you only + need to configure return values +- everything you never fetch stays invisible to PHPUnit -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: +The service itself is constructed immediately, so the order does not matter - expectations can be declared +before or after you use it: ```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: +Every parameter gets its own double, so a service depending on the same type twice receives two distinct +ones. Fetching such a type without saying which parameter you mean throws a `LogicException` - pass the +parameter name as the second argument: ```php $first = $this->getMockedService(BasicService::class, 'first'); @@ -104,7 +102,10 @@ That will use your object instead of creating one for you, keep in mind you cann 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 +- enums, those cannot be doubled at all + +Internal classes (`\DateInterval` and friends) are handed to PHPUnit like any other type - most of them +double just fine, and the ones that do not produce a precise error from PHPUnit telling you to provide it. A nullable dependency (`?Foo`) still receives a double, pass `null` explicitly if that is what your test needs. @@ -113,9 +114,9 @@ A nullable dependency (`?Foo`) still receives a double, pass `null` explicitly i 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. +Its dependencies behave exactly like the ones of a normal service - unregistered until you fetch them. +The partial mock itself is a regular PHPUnit mock though, so the methods you override are subject to the +usual expectation rules. ### Feature requests? diff --git a/src/ServiceMockHelperTrait.php b/src/ServiceMockHelperTrait.php index 0145b68..4032b1f 100644 --- a/src/ServiceMockHelperTrait.php +++ b/src/ServiceMockHelperTrait.php @@ -4,18 +4,20 @@ namespace Pkly; +use PHPUnit\Event\Facade as EventFacade; +use PHPUnit\Framework\MockObject\Generator\Generator as MockGenerator; use PHPUnit\Framework\MockObject\MockBuilder; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\Stub; use PHPUnit\Framework\TestCase; +use PHPUnit\Metadata\Parser\Registry as MetadataRegistry; /** * @phpstan-type ServiceState array{ * class: class-string, * parameters: list, - * mocks: array, - * stubs: array, - * initialized: bool + * doubles: array, + * registered: array * } */ trait ServiceMockHelperTrait @@ -48,28 +50,15 @@ private function __serviceStates(): \SplObjectStorage } /** - * @param class-string $class - * @param list $parameters - * @param array $mocks - * @param array $stubs + * @param ServiceState $state */ private function __registerService( object $service, - string $class, - array $parameters, - array $mocks = [], - array $stubs = [], - bool $initialized = false + array $state ): void { - $this->__serviceStates()->offsetSet($service, [ - 'class' => $class, - 'parameters' => $parameters, - 'mocks' => $mocks, - 'stubs' => $stubs, - 'initialized' => $initialized, - ]); + $this->__serviceStates()->offsetSet($service, $state); - $this->serviceInstances[$class] = $service; + $this->serviceInstances[$state['class']] = $service; $this->currentServiceInstance = $service; } @@ -98,33 +87,6 @@ private function __setServiceState( $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. @@ -137,24 +99,135 @@ private function __doubleKey( } /** + * Locate the double for a type within one service's state. + * * @param ServiceState $state + * + * @return string|null the key it is stored under, or null when that service has no such dependency */ - private function __assertParameterExists( + private function __findDoubleKey( array $state, string $type, string|null $parameter - ): void { + ): string|null { + if (null !== $parameter) { + $key = $this->__doubleKey($type, $parameter); + + return isset($state['doubles'][$key]) ? $key : null; + } + + $found = null; + foreach ($state['parameters'] as $definition) { if ($definition['type'] !== $type) { continue; } - if (null === $parameter || $definition['name'] === $parameter) { - return; + $key = $this->__doubleKey($type, $definition['name']); + + if (!isset($state['doubles'][$key])) { + continue; + } + + if (null !== $found) { + throw new \LogicException( + sprintf( + 'Service %s depends on %s more than once, pass the parameter name to target one of them', + $state['class'], + $type + ) + ); + } + + $found = $key; + } + + return $found; + } + + /** + * Resolve which service instance a double should be taken from. + * + * Defaults to the most recently created service. When that service has no such dependency the + * other services created by the trait are searched, so that building a second object mid-test + * does not hide the dependencies of the one actually under test. + * + * @param class-string $type + * @param class-string|null $service + * + * @return array{0: object, 1: ServiceState, 2: string} + */ + private function __resolveDouble( + string $type, + string|null $parameter, + 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]; + $state = $this->__getServiceState($instance); + $key = $this->__findDoubleKey($state, $type, $parameter); + + if (null === $key) { + throw $this->__unknownDependency($state, $type, $parameter); + } + + return [$instance, $state, $key]; + } + + $instance = $this->currentServiceInstance + ?? throw new \LogicException('No services have been mocked yet by the trait'); + $state = $this->__getServiceState($instance); + + if (null !== ($key = $this->__findDoubleKey($state, $type, $parameter))) { + return [$instance, $state, $key]; + } + + // fall back to the other services created by the trait, but only when unambiguous + $matches = []; + + foreach ($this->serviceInstances as $candidate) { + if ($candidate === $instance) { + continue; + } + + $candidateState = $this->__getServiceState($candidate); + + if (null !== ($candidateKey = $this->__findDoubleKey($candidateState, $type, $parameter))) { + $matches[] = [$candidate, $candidateState, $candidateKey]; } } - throw new \LogicException( + if (1 === count($matches)) { + return $matches[0]; + } + + if ([] !== $matches) { + throw new \LogicException( + sprintf( + 'Multiple services created by the trait depend on %s, pass the service name to target one of them', + $type + ) + ); + } + + throw $this->__unknownDependency($state, $type, $parameter); + } + + /** + * @param ServiceState $state + */ + private function __unknownDependency( + array $state, + string $type, + string|null $parameter + ): \LogicException { + return 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' @@ -205,38 +278,90 @@ private function __parameterType( return $type; } + /** + * Whether return values should be generated for the doubles this trait creates. + * + * Mirrors TestCase::generateReturnValuesForTestDoubles(), which is private. + */ + private function __generateReturnValues(): bool + { + return MetadataRegistry::parser() + ->forClass(static::class) + ->isDisableReturnValueGenerationForTestDoubles() + ->isEmpty(); + } + + /** + * Create a test double that is deliberately NOT registered with the TestCase. + * + * It is a full MockObject, so expects() is available on it, but PHPUnit neither verifies it nor + * complains about it having no expectations. It is registered later, on the first + * getMockedService() call for it, which is the point at which the test takes ownership of it. + * + * @param class-string $type + */ + private function __createUnregisteredMock( + string $type + ): MockObject { + // arguments are passed positionally on purpose: PHPUnit marks its API as + // @no-named-arguments, so parameter names are not covered by its BC promise + $double = new MockGenerator()->testDouble( + $type, + true, // $mockObject + [], // $methods + [], // $arguments + '', // $mockClassName + false, // $callOriginalConstructor + false, // $callOriginalClone + $this->__generateReturnValues(), // $returnValueGeneration + ); + + assert($double instanceof MockObject); + + return $double; + } + /** * @param class-string $class * @param array $definedParameters + * @param ServiceState $state * - * @return list + * @return list */ - private function __indexMethodParameters( + private function __resolveMethodParameters( string $class, \ReflectionMethod $method, - array $definedParameters + array $definedParameters, + array &$state ): array { - $parameters = []; + /** @var list $params */ + $params = []; foreach ($method->getParameters() as $parameter) { - if (array_key_exists($parameter->getName(), $definedParameters)) { + $name = $parameter->getName(); + + if (array_key_exists($name, $definedParameters)) { + $params[] = $definedParameters[$name]; continue; } $type = $this->__parameterType($class, $method, $parameter); + // only builtin parameters fall back to their default; a class-typed parameter is + // doubled even when it is nullable with a default, because tests routinely mock those if ($type->isBuiltin()) { if (!$parameter->isDefaultValueAvailable()) { throw new \LogicException( sprintf( 'Specify parameter $%s in %s::%s', - $parameter->getName(), + $name, $class, $method->getName() ) ); } + $params[] = $parameter->getDefaultValue(); continue; } @@ -248,99 +373,41 @@ private function __indexMethodParameters( sprintf( 'Cannot create a test double for unknown type %s of parameter $%s in %s::%s', $typeName, - $parameter->getName(), + $name, $class, $method->getName() ) ); } - $typeReflection = new \ReflectionClass($typeName); + // enums cannot be doubled at all; internal classes generally can be, so they are left + // to PHPUnit, which raises a precise error for the ones it cannot handle + if (new \ReflectionClass($typeName)->isEnum()) { + if ($parameter->isDefaultValueAvailable()) { + $params[] = $parameter->getDefaultValue(); + continue; + } - 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(), + 'Specify parameter $%s in %s::%s explicitly, %s is an enum and cannot be doubled', + $name, $class, $method->getName(), - $typeName, - $typeReflection->isEnum() ? 'an enum' : 'an internal class' + $typeName ) ); } - $parameters[] = [ - 'name' => $parameter->getName(), + $key = $this->__doubleKey($typeName, $name); + $double = $state['doubles'][$key] ??= $this->__createUnregisteredMock($typeName); + + $state['parameters'][] = [ + 'name' => $name, 'type' => $typeName, ]; - } - - return $parameters; - } - - /** - * @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 ServiceState $state - * @param array $definedParameters - * - * @return list - */ - private function __resolveMethodParameters( - array &$state, - \ReflectionMethod $method, - array $definedParameters, - bool $asMock - ): array { - /** @var list $params */ - $params = []; - - foreach ($method->getParameters() as $parameter) { - if (array_key_exists($parameter->getName(), $definedParameters)) { - $params[] = $definedParameters[$parameter->getName()]; - continue; - } - - $type = $this->__parameterType($state['class'], $method, $parameter); - - if ($type->isBuiltin()) { - // builtin parameters always have a default value at this point, see __indexMethodParameters() - $params[] = $parameter->getDefaultValue(); - continue; - } - - /** @var class-string $typeName */ - $typeName = $type->getName(); - - $params[] = $this->__resolveParameterDouble($state, $parameter->getName(), $typeName, $asMock); + $params[] = $double; } return $params; @@ -368,10 +435,11 @@ private function __getRequiredMethods( } /** - * Fetch (or declare) a mock for one of the dependencies of a service created by the trait. + * Fetch 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. + * This is what hands ownership of the double to the test: from here on PHPUnit verifies it and + * will point out that it has no expectations configured. Dependencies nobody fetches stay + * unregistered and are silently left alone. * * @template TMockFetchTarget of object * @@ -389,42 +457,26 @@ protected function getMockedService( ): mixed { assert($this instanceof TestCase); - [$instance, $state] = $this->__resolveServiceInstance($service); - $key = $this->__doubleKey($class, $parameter); - - if (isset($state['mocks'][$key])) { - /** @var MockObject&TMockFetchTarget $mock */ - $mock = $state['mocks'][$key]; - - return $mock; - } + [$instance, $state, $key] = $this->__resolveDouble($class, $parameter, $service); + $double = $state['doubles'][$key]; - $this->__assertParameterExists($state, $class, $parameter); + if (!isset($state['registered'][$key])) { + $this->registerMockObject($class, $double); + EventFacade::emitter()->testCreatedMockObject($class); - if ($state['initialized']) { - throw new \LogicException( - sprintf( - '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 - ) - ); + $state['registered'][$key] = true; + $this->__setServiceState($instance, $state); } - $mock = $this->createMock($class); - $state['mocks'][$key] = $mock; - $this->__setServiceState($instance, $state); - - /** @var MockObject&TMockFetchTarget $mock */ - return $mock; + /** @var MockObject&TMockFetchTarget $double */ + return $double; } /** - * Fetch (or declare) a stub for one of the dependencies of a service created by the trait. + * Fetch a dependency of a service created by the trait without taking ownership of it. * - * 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. + * The double is returned unregistered, so return values can be configured on it while PHPUnit + * keeps ignoring it - no verification, and no complaint about missing expectations. * * @template TStubFetchTarget of object * @@ -442,50 +494,23 @@ protected function getStubbedService( ): 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]; + [, $state, $key] = $this->__resolveDouble($class, $parameter, $service); - return $mock; - } + // every double is a MockObject, which is a Stub; it is handed out as the narrower Stub + // so callers do not configure expectations on something PHPUnit never verifies + /** @var MockObject&TStubFetchTarget $double */ + $double = $state['doubles'][$key]; - 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 - ) - ); - } - - $stub = static::createStub($class); - $state['stubs'][$this->__doubleKey($class, $parameter)] = $stub; - $this->__setServiceState($instance, $state); - - /** @var Stub&TStubFetchTarget $stub */ - return $stub; + return $double; } /** * 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. + * Dependencies are created as unregistered mocks: fully configurable, but invisible to PHPUnit + * until the test asks for one with getMockedService(). That keeps PHPUnit from complaining + * about the dependencies a test never touches, without constraining when the test configures + * the ones it cares about. * * @template TMockCreationTarget of object * @@ -508,49 +533,27 @@ protected function createRealMockedServiceInstance( throw new \LogicException('Failed to read class reflection, specify proper FQCN', previous: $e); } - $construct = $reflection->getConstructor(); - $requiredMethods = $this->__getRequiredMethods($reflection); - $parameters = null !== $construct - ? $this->__indexMethodParameters($class, $construct, $constructor) + /** @var ServiceState $state */ + $state = [ + 'class' => $class, + 'parameters' => [], + 'doubles' => [], + 'registered' => [], + ]; + + $params = null !== ($construct = $reflection->getConstructor()) + ? $this->__resolveMethodParameters($class, $construct, $constructor, $state) : []; - foreach ($requiredMethods as $method) { - foreach ($this->__indexMethodParameters($class, $method, $required) as $parameter) { - $parameters[] = $parameter; - } - } + $service = new $class(...$params); - 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 + foreach ($this->__getRequiredMethods($reflection) as $method) { + $service->{$method->getName()}( + ...$this->__resolveMethodParameters($class, $method, $required, $state) ); } - $this->__registerService($service, $class, $parameters); + $this->__registerService($service, $state); return $service; } @@ -558,9 +561,6 @@ function (object $instance) use ($construct, $requiredMethods, $constructor, $re /** * 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 @@ -584,28 +584,16 @@ protected function createRealPartialMockedServiceInstance( throw new \LogicException('Failed to read class reflection, specify proper FQCN', previous: $e); } - $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, + 'parameters' => [], + 'doubles' => [], + 'registered' => [], ]; - foreach ($requiredMethods as $method) { - foreach ($this->__indexMethodParameters($class, $method, $required) as $parameter) { - $state['parameters'][] = $parameter; - } - } - - $params = null !== $construct - ? $this->__resolveMethodParameters($state, $construct, $constructor, true) + $params = null !== ($construct = $reflection->getConstructor()) + ? $this->__resolveMethodParameters($class, $construct, $constructor, $state) : []; $service = new MockBuilder($this, $class) @@ -614,23 +602,16 @@ protected function createRealPartialMockedServiceInstance( ->onlyMethods($methods) ->getMock(); - foreach ($requiredMethods as $method) { + foreach ($this->__getRequiredMethods($reflection) as $method) { $service->{$method->getName()}( - ...$this->__resolveMethodParameters($state, $method, $required, true) + ...$this->__resolveMethodParameters($class, $method, $required, $state) ); } - $this->__registerService( - $service, - $class, - $state['parameters'], - $state['mocks'], - $state['stubs'], - true - ); + $this->__registerService($service, $state); // MockBuilder does not emit an event of its own, unlike createMock()/createStub() - \PHPUnit\Event\Facade::emitter()->testCreatedPartialMockObject( + EventFacade::emitter()->testCreatedPartialMockObject( $class, ...$methods, ); diff --git a/tests/Service/RequiredService.php b/tests/Service/RequiredService.php index cd4dda8..e05e897 100644 --- a/tests/Service/RequiredService.php +++ b/tests/Service/RequiredService.php @@ -27,9 +27,6 @@ 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'; diff --git a/tests/Unit/MockResolutionTest.php b/tests/Unit/MockResolutionTest.php index 22ac772..30a860b 100644 --- a/tests/Unit/MockResolutionTest.php +++ b/tests/Unit/MockResolutionTest.php @@ -20,18 +20,21 @@ class MockResolutionTest extends TestCase { use ServiceMockHelperTrait; - public function testSameTypeParametersShareOneDouble(): void + public function testSameTypeParametersGetSeparateDoubles(): void { $service = $this->createRealMockedServiceInstance(DuplicateTypeService::class); - $mock = $this->getMockedService(BasicService::class); - $mock->expects(static::exactly(2)) - ->method('process') - ->willReturn('SAME'); + static::assertNotSame($service->getFirst(), $service->getSecond()); + } + + public function testSameTypeParametersCannotBeTargetedWithoutAName(): void + { + $this->createRealMockedServiceInstance(DuplicateTypeService::class); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage(sprintf('depends on %s more than once', BasicService::class)); - static::assertSame('SAME', $service->process('hello')); - static::assertSame($mock, $service->getFirst()); - static::assertSame($mock, $service->getSecond()); + $this->getMockedService(BasicService::class); } public function testSameTypeParametersCanBeTargetedByName(): void @@ -106,24 +109,29 @@ public function testMockingAProvidedParameterThrows(): void $this->getMockedService(BasicService::class); } - public function testMockingAfterTheServiceHasBeenUsedThrows(): void + public function testMockingAfterTheServiceHasBeenUsedIsAllowed(): void { $service = $this->createRealMockedServiceInstance(ExtendedService::class); static::assertNull($service->work('hello')); - $this->expectException(\LogicException::class); - $this->expectExceptionMessage('has already been created'); + // the double the service already holds is the one handed out here, so expectations + // declared after the fact still apply to it + $this->getMockedService(BasicService::class) + ->expects(static::once()) + ->method('process') + ->with('hello') + ->willReturn('NEW-hello'); - $this->getMockedService(BasicService::class); + static::assertSame('NEW-hello', $service->work('hello')); } - public function testInternalDependencyThrows(): void + public function testInternalDependencyIsDoubled(): void { - $this->expectException(\LogicException::class); - $this->expectExceptionMessage('is an internal class'); + $service = $this->createRealMockedServiceInstance(IntervalService::class); - $this->createRealMockedServiceInstance(IntervalService::class); + static::assertInstanceOf(\DateInterval::class, $service->getInterval()); + static::assertSame($this->getStubbedService(\DateInterval::class), $service->getInterval()); } public function testInternalDependencyCanBeProvided(): void diff --git a/tests/Unit/RequiredServiceTest.php b/tests/Unit/RequiredServiceTest.php index c348c82..bfe8dfd 100644 --- a/tests/Unit/RequiredServiceTest.php +++ b/tests/Unit/RequiredServiceTest.php @@ -4,7 +4,6 @@ namespace Pkly\Tests\Unit; -use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\Stub; use PHPUnit\Framework\TestCase; use Pkly\ServiceMockHelperTrait; @@ -20,20 +19,15 @@ public function testRequiredSetterIsCalledWithADouble(): void { $service = $this->createRealMockedServiceInstance(RequiredService::class); + static::assertInstanceOf(MessengerInterface::class, $service->getMessenger()); static::assertInstanceOf(Stub::class, $service->getMessenger()); - static::assertNotInstanceOf(MockObject::class, $service->getMessenger()); } - public function testRequiredSetterRunsOnlyOnceTheServiceIsUsed(): void + public function testRequiredSetterDoublesCanBeConfiguredAfterCreation(): 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()) @@ -46,7 +40,6 @@ public function testRequiredSetterRunsOnlyOnceTheServiceIsUsed(): void ->willReturn('NEW-hello'); static::assertTrue($service->notify('hello')); - static::assertFalse($reflection->isUninitializedLazyObject($service)); } public function testRequiredSetterAcceptsProvidedParameters(): void diff --git a/tests/Unit/StubByDefaultTest.php b/tests/Unit/StubByDefaultTest.php index d2c8705..0055694 100644 --- a/tests/Unit/StubByDefaultTest.php +++ b/tests/Unit/StubByDefaultTest.php @@ -20,18 +20,22 @@ class StubByDefaultTest extends TestCase { use ServiceMockHelperTrait; - public function testDependenciesNobodyConfiguresAreStubs(): void + /** + * Nothing is configured here on purpose: dependencies the test never asks for stay + * unregistered, so failOnPhpunitNotice does not trip over their missing expectations. + */ + public function testDependenciesNobodyConfiguresAreLeftAlone(): void { $service = $this->createRealMockedServiceInstance(InterfaceService::class); + static::assertInstanceOf(MessengerInterface::class, $service->getMessenger()); static::assertInstanceOf(Stub::class, $service->getMessenger()); - static::assertNotInstanceOf(MockObject::class, $service->getMessenger()); + static::assertInstanceOf(BasicService::class, $service->getService()); static::assertInstanceOf(Stub::class, $service->getService()); - static::assertNotInstanceOf(MockObject::class, $service->getService()); } - public function testOnlyRequestedDependenciesBecomeMocks(): void + public function testRequestedDependenciesCarryExpectations(): void { $service = $this->createRealMockedServiceInstance(InterfaceService::class); @@ -48,7 +52,6 @@ public function testOnlyRequestedDependenciesBecomeMocks(): void static::assertTrue($service->notify('hello')); static::assertInstanceOf(MockObject::class, $service->getMessenger()); - static::assertNotInstanceOf(MockObject::class, $service->getService()); } public function testInterfaceDependencyIsDoubled(): void