From cb811b4d3b45261e428756b16719b6b9dd9752d6 Mon Sep 17 00:00:00 2001 From: pkly Date: Thu, 17 Sep 2026 10:08:18 +0200 Subject: [PATCH 1/2] Add partial stub method --- src/ServiceMockHelperTrait.php | 84 ++++++++++ tests/Unit/PartialStubServiceTest.php | 222 ++++++++++++++++++++++++++ 2 files changed, 306 insertions(+) create mode 100644 tests/Unit/PartialStubServiceTest.php diff --git a/src/ServiceMockHelperTrait.php b/src/ServiceMockHelperTrait.php index 4032b1f..0a2a8b0 100644 --- a/src/ServiceMockHelperTrait.php +++ b/src/ServiceMockHelperTrait.php @@ -618,4 +618,88 @@ protected function createRealPartialMockedServiceInstance( return $service; } + + /** + * Create a partial stub of a service with all of its dependencies doubled. + * + * The counterpart to createRealPartialMockedServiceInstance(): the listed methods are + * replaced and configurable with method()->willReturn(), every other method runs its real + * code, and PHPUnit neither verifies the double nor reports it as a mock object without + * expectations. Use it when the test replaces methods to set up a scenario rather than to + * assert how they are called - which is what "No expectations were configured for the mock + * object for X" is pointing at. + * + * Use createRealPartialMockedServiceInstance() as soon as the test wants expects() on the + * instance itself; a stub has no expects() at all, so the mistake is a hard error rather + * than an assertion that quietly stops being checked. + * + * @template TStubCreationPartialTarget of object + * + * @param class-string $class + * @param list $methods + * @param array $constructor + * @param array $required + * + * @return TStubCreationPartialTarget&Stub + */ + protected function createRealPartialStubbedServiceInstance( + string $class, + array $methods, + array $constructor = [], + array $required = [] + ): mixed { + assert($this instanceof TestCase); + + try { + $reflection = new \ReflectionClass($class); // @phpstan-ignore-line + } catch (\ReflectionException $e) { // @phpstan-ignore-line + throw new \LogicException('Failed to read class reflection, specify proper FQCN', previous: $e); + } + + /** @var ServiceState $state */ + $state = [ + 'class' => $class, + 'parameters' => [], + 'doubles' => [], + 'registered' => [], + ]; + + $params = null !== ($construct = $reflection->getConstructor()) + ? $this->__resolveMethodParameters($class, $construct, $constructor, $state) + : []; + + // an empty list must reach the generator as null, exactly as MockBuilder::onlyMethods() + // normalises it: for a class, Generator sees [] as "double every method" and null as + // "double none", so passing [] straight through would silently turn a partial double + // into a full one + $explicitMethods = [] === $methods ? null : $methods; + + // arguments are passed positionally on purpose: PHPUnit marks its API as + // @no-named-arguments, so parameter names are not covered by its BC promise + $service = new MockGenerator()->testDouble( + $class, + false, // $mockObject - a stub: nothing to verify, nothing to complain about + $explicitMethods, + $params, // $arguments + '', // $mockClassName + true, // $callOriginalConstructor + false, // $callOriginalClone + $this->__generateReturnValues(), // $returnValueGeneration + ); + + assert($service instanceof Stub); + + foreach ($this->__getRequiredMethods($reflection) as $method) { + $service->{$method->getName()}( + ...$this->__resolveMethodParameters($class, $method, $required, $state) + ); + } + + $this->__registerService($service, $state); + + // the generator emits no event of its own, unlike createStub() + EventFacade::emitter()->testCreatedStub($class); + + return $service; + } } diff --git a/tests/Unit/PartialStubServiceTest.php b/tests/Unit/PartialStubServiceTest.php new file mode 100644 index 0000000..3997b5c --- /dev/null +++ b/tests/Unit/PartialStubServiceTest.php @@ -0,0 +1,222 @@ +createRealPartialStubbedServiceInstance(ExtendedService::class, ['work']); + + // a stub has no with(), so the arguments are checked inside the replacement itself + $service->method('work') + ->willReturnCallback(static fn (string $input): string => 'OVERRIDDEN-'.$input); + + static::assertSame('OVERRIDDEN-hello', $service->work('hello')); + } + + public function testPartialStubIsAStubAndNotAMockObject(): void + { + $service = $this->createRealPartialStubbedServiceInstance(ExtendedService::class, ['work']); + + static::assertInstanceOf(ExtendedService::class, $service); + static::assertInstanceOf(Stub::class, $service); + // expects() is what separates the two, and a stub must not offer it + static::assertNotInstanceOf(MockObject::class, $service); + } + + /** + * Nothing is configured on the stub on purpose: the TestCase never learns about it, so + * failOnPhpunitNotice cannot trip over a double without expectations. + */ + public function testPartialStubNeedsNoConfigurationAtAll(): void + { + $service = $this->createRealPartialStubbedServiceInstance(ExtendedService::class, ['work']); + + // the doubled method answers with a generated return value instead of the real code + static::assertNull($service->work('hello')); + } + + public function testPartialStubKeepsRunningRealCodeForUntouchedMethods(): void + { + $service = $this->createRealPartialStubbedServiceInstance(RequiredService::class, ['describe']); + + $service->method('describe') + ->willReturn('STUBBED'); + + $this->getStubbedService(BasicService::class) + ->method('process') + ->willReturn('NEW-hello'); + + $this->getStubbedService(MessengerInterface::class) + ->method('send') + ->willReturn(true); + + static::assertSame('STUBBED', $service->describe()); + // notify() was not doubled, so it still runs and reaches the doubled dependencies + static::assertTrue($service->notify('hello')); + } + + /** + * An empty list means "double nothing", the same way MockBuilder::onlyMethods([]) does; were + * it passed straight through to the generator, every method would be doubled instead. + */ + public function testPartialStubWithAnEmptyMethodListDoublesNothing(): void + { + $service = $this->createRealPartialStubbedServiceInstance(ExtendedService::class, []); + + $this->getStubbedService(BasicService::class) + ->method('process') + ->willReturn('NEW-hello'); + + static::assertSame('NEW-hello', $service->work('hello')); + } + + public function testPartialStubKeepsUsingDoublesForItsDependencies(): void + { + $service = $this->createRealPartialStubbedServiceInstance(InterfaceService::class, ['getMessenger']); + + $messenger = $this->getStubbedService(MessengerInterface::class); + $basic = $this->getStubbedService(BasicService::class); + + static::assertInstanceOf(Stub::class, $messenger); + static::assertInstanceOf(Stub::class, $basic); + + // getMessenger() is doubled, the untouched getService() still returns the injected double + static::assertSame($basic, $service->getService()); + } + + public function testPartialStubDependenciesCanStillBeRegisteredAsMocks(): void + { + $service = $this->createRealPartialStubbedServiceInstance(InterfaceService::class, ['getMessenger']); + + $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')); + } + + public function testPartialStubAcceptsProvidedConstructorParameters(): void + { + $service = $this->createRealPartialStubbedServiceInstance( + ScalarService::class, + ['getName'], + ['name' => 'CUSTOM'] + ); + + $service->method('getName') + ->willReturn('STUBBED'); + + $this->getStubbedService(BasicService::class) + ->method('process') + ->willReturn('NEW-hello'); + + static::assertSame('STUBBED', $service->getName()); + // process() is untouched, so it reads the value the constructor actually received + static::assertSame('CUSTOM:NEW-hello', $service->process('hello')); + } + + public function testPartialStubProvidedConstructorParameterCannotBeMocked(): void + { + $basic = new BasicService(); + + $this->createRealPartialStubbedServiceInstance( + ExtendedService::class, + ['work'], + ['service' => $basic] + ); + + $this->expectException(\LogicException::class); + + $this->getStubbedService(BasicService::class); + } + + public function testPartialStubCallsRequiredSettersWithDoubles(): void + { + $service = $this->createRealPartialStubbedServiceInstance(RequiredService::class, ['describe']); + + static::assertInstanceOf(MessengerInterface::class, $service->getMessenger()); + static::assertSame($this->getStubbedService(MessengerInterface::class), $service->getMessenger()); + } + + public function testPartialStubAcceptsProvidedRequiredParameters(): void + { + $messenger = new class implements MessengerInterface { + public function send( + string $message + ): bool { + return 'NEW-hello' === $message; + } + }; + + $service = $this->createRealPartialStubbedServiceInstance( + RequiredService::class, + ['describe'], + required: ['messenger' => $messenger] + ); + + $this->getStubbedService(BasicService::class) + ->method('process') + ->willReturn('NEW-hello'); + + static::assertSame($messenger, $service->getMessenger()); + static::assertTrue($service->notify('hello')); + } + + public function testPartialStubSameTypeDependenciesCanBeTargetedByName(): void + { + $service = $this->createRealPartialStubbedServiceInstance(DuplicateTypeService::class, ['getFirst']); + + $first = $this->getStubbedService(BasicService::class, 'first'); + $second = $this->getStubbedService(BasicService::class, 'second'); + + static::assertNotSame($first, $second); + + $first->method('process') + ->willReturn('FIRST'); + + $second->method('process') + ->willReturn('SECOND'); + + static::assertSame('SECOND', $service->process('hello')); + } + + public function testPartialStubBecomesTheServiceDoublesResolveAgainst(): void + { + $extended = $this->createRealMockedServiceInstance(ExtendedService::class); + $interface = $this->createRealPartialStubbedServiceInstance(InterfaceService::class, ['notify']); + + // the partial stub was created last, so an unqualified lookup targets its dependency + static::assertSame($this->getStubbedService(BasicService::class), $interface->getService()); + + // the earlier service is still reachable by naming it explicitly + $this->getStubbedService(BasicService::class, service: ExtendedService::class) + ->method('process') + ->willReturn('NEW-hello'); + + static::assertSame('NEW-hello', $extended->work('hello')); + } +} From 12ac1cf921d3481ab2ee353489d9fd7cc5f53f42 Mon Sep 17 00:00:00 2001 From: pkly Date: Thu, 17 Sep 2026 10:12:30 +0200 Subject: [PATCH 2/2] Add partial stub method --- src/ServiceMockHelperTrait.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/ServiceMockHelperTrait.php b/src/ServiceMockHelperTrait.php index 0a2a8b0..7ee6339 100644 --- a/src/ServiceMockHelperTrait.php +++ b/src/ServiceMockHelperTrait.php @@ -687,7 +687,10 @@ protected function createRealPartialStubbedServiceInstance( $this->__generateReturnValues(), // $returnValueGeneration ); - assert($service instanceof Stub); + // asserting against the class narrows the generator's MockObject|Stub return to the + // doubled type, exactly as TestCase::createStub() does; asserting against Stub alone + // says nothing, since MockObject already extends it + assert($service instanceof $class); foreach ($this->__getRequiredMethods($reflection) as $method) { $service->{$method->getName()}(