diff --git a/.psalm/baseline.xml b/.psalm/baseline.xml index c39b22fc015..c8ad440f9da 100644 --- a/.psalm/baseline.xml +++ b/.psalm/baseline.xml @@ -201,6 +201,9 @@ null + + $this->type + @@ -217,7 +220,8 @@ - + + $class_name $this->expectedException diff --git a/src/Framework/MockObject/MockBuilder.php b/src/Framework/MockObject/MockBuilder.php index 5b2c6fcd69c..96dd1a662b4 100644 --- a/src/Framework/MockObject/MockBuilder.php +++ b/src/Framework/MockObject/MockBuilder.php @@ -86,6 +86,11 @@ final class MockBuilder */ private $generator; + /** + * @var bool + */ + private $alreadyUsedMockMethodConfiguration = false; + /** * @param string|string[] $type * @@ -181,11 +186,93 @@ public function getMockForTrait(): MockObject /** * Specifies the subset of methods to mock. Default is to mock none of them. + * + * @deprecated https://github.com/sebastianbergmann/phpunit/pull/3687 */ public function setMethods(array $methods = null): self { $this->methods = $methods; + $this->alreadyUsedMockMethodConfiguration = true; + + return $this; + } + + /** + * Specifies the subset of methods to mock, requiring each to exist in the class + * + * @param string[] $methods + * + * @throws RuntimeException + */ + public function onlyMethods(array $methods): self + { + if ($this->alreadyUsedMockMethodConfiguration) { + throw new RuntimeException( + \sprintf( + 'Can\'t use onlyMethods on "%s" mock because mocked methods were already configured.', + $this->type + ) + ); + } + + $this->alreadyUsedMockMethodConfiguration = true; + + $reflection = new \ReflectionClass($this->type); + + foreach ($methods as $method) { + if (!$reflection->hasMethod($method)) { + throw new RuntimeException( + \sprintf( + 'Trying to set mock method "%s" with onlyMethods, but it does not exist in class "%s". Use addMethods() for methods that don\'t exist in the class.', + $method, + $this->type + ) + ); + } + } + + $this->methods = $methods; + + return $this; + } + + /** + * Specifies methods that don't exist in the class which you want to mock + * + * @param string[] $methods + * + * @throws RuntimeException + */ + public function addMethods(array $methods): self + { + if ($this->alreadyUsedMockMethodConfiguration) { + throw new RuntimeException( + \sprintf( + 'Can\'t use addMethods on "%s" mock because mocked methods were already configured.', + $this->type + ) + ); + } + + $this->alreadyUsedMockMethodConfiguration = true; + + $reflection = new \ReflectionClass($this->type); + + foreach ($methods as $method) { + if ($reflection->hasMethod($method)) { + throw new RuntimeException( + \sprintf( + 'Trying to set mock method "%s" with addMethod, but it exists in class "%s". Use onlyMethods() for methods that exist in the class.', + $method, + $this->type + ) + ); + } + } + + $this->methods = $methods; + return $this; } diff --git a/src/Framework/TestCase.php b/src/Framework/TestCase.php index b583fcc9570..eb252f5c658 100644 --- a/src/Framework/TestCase.php +++ b/src/Framework/TestCase.php @@ -1521,6 +1521,26 @@ protected function createConfiguredMock($originalClassName, array $configuration */ protected function createPartialMock($originalClassName, array $methods): MockObject { + $class_names = \is_array($originalClassName) ? $originalClassName : [$originalClassName]; + + foreach ($class_names as $class_name) { + $reflection = new \ReflectionClass($class_name); + + $mockedMethodsThatDontExist = \array_filter($methods, function (string $method) use ($reflection) { + return !$reflection->hasMethod($method); + }); + + if ($mockedMethodsThatDontExist) { + $this->addWarning( + \sprintf( + 'createPartialMock called with method(s) %s that do not exist in %s. This will not be allowed in future versions of PHPUnit.', + \implode(', ', $mockedMethodsThatDontExist), + $class_name + ) + ); + } + } + return $this->getMockBuilder($originalClassName) ->disableOriginalConstructor() ->disableOriginalClone() diff --git a/tests/_files/TestWithDifferentStatuses.php b/tests/_files/TestWithDifferentStatuses.php index e680f6ce2d3..da170332349 100644 --- a/tests/_files/TestWithDifferentStatuses.php +++ b/tests/_files/TestWithDifferentStatuses.php @@ -45,4 +45,15 @@ public function testThatAddsAWarning(): void { $this->addWarning('Sorry, Dave!'); } + + public function testWithCreatePartialMockWarning(): void + { + $this->createPartialMock(\Mockable::class, ['mockableMethod', 'fakeMethod1', 'fakeMethod2']); + } + + public function testWithCreatePartialMockPassesNoWarning(): void + { + $mock = $this->createPartialMock(\Mockable::class, ['mockableMethod']); + $this->assertNull($mock->mockableMethod()); + } } diff --git a/tests/unit/Framework/MockObject/MockBuilderTest.php b/tests/unit/Framework/MockObject/MockBuilderTest.php index 3d22478c2ba..f7034ac8981 100644 --- a/tests/unit/Framework/MockObject/MockBuilderTest.php +++ b/tests/unit/Framework/MockObject/MockBuilderTest.php @@ -51,6 +51,53 @@ public function testMethodExceptionsToMockCanBeSpecified(): void $this->assertNull($mock->anotherMockableMethod()); } + public function testSetMethodsAllowsNonExistentMethodNames(): void + { + $mock = $this->getMockBuilder(Mockable::class) + ->setMethods(['mockableMethodWithCrazyName']) + ->getMock(); + + $this->assertNull($mock->mockableMethodWithCrazyName()); + } + + public function testOnlyMethodsWithNonExistentMethodNames(): void + { + $this->expectException(RuntimeException::class); + + $this->getMockBuilder(Mockable::class) + ->onlyMethods(['mockableMethodWithCrazyName']) + ->getMock(); + } + + public function testOnlyMethodsWithExistingMethodNames(): void + { + $mock = $this->getMockBuilder(Mockable::class) + ->onlyMethods(['mockableMethod']) + ->getMock(); + + $this->assertNull($mock->mockableMethod()); + $this->assertTrue($mock->anotherMockableMethod()); + } + + public function testAddMethodsWithNonExistentMethodNames(): void + { + $this->expectException(RuntimeException::class); + + $this->getMockBuilder(Mockable::class) + ->addMethods(['mockableMethod']) + ->getMock(); + } + + public function testAddMethodsWithExistingMethodNames(): void + { + $mock = $this->getMockBuilder(Mockable::class) + ->addMethods(['mockableMethodWithFakeMethod']) + ->getMock(); + + $this->assertNull($mock->mockableMethodWithFakeMethod()); + $this->assertTrue($mock->anotherMockableMethod()); + } + public function testEmptyMethodExceptionsToMockCanBeSpecified(): void { $mock = $this->getMockBuilder(Mockable::class) @@ -61,6 +108,66 @@ public function testEmptyMethodExceptionsToMockCanBeSpecified(): void $this->assertNull($mock->anotherMockableMethod()); } + public function testNotAbleToUseAddMethodsAfterOnlyMethods(): void + { + $this->expectException(RuntimeException::class); + + $this->getMockBuilder(Mockable::class) + ->onlyMethods(['mockableMethod']) + ->addMethods(['mockableMethodWithFakeMethod']) + ->getMock(); + } + + public function testNotAbleToUseOnlyMethodsAfterAddMethods(): void + { + $this->expectException(RuntimeException::class); + + $this->getMockBuilder(Mockable::class) + ->addMethods(['mockableMethodWithFakeMethod']) + ->onlyMethods(['mockableMethod']) + ->getMock(); + } + + public function testAbleToUseSetMethodsAfterOnlyMethods(): void + { + $mock = $this->getMockBuilder(Mockable::class) + ->onlyMethods(['mockableMethod']) + ->setMethods(['mockableMethodWithCrazyName']) + ->getMock(); + + $this->assertNull($mock->mockableMethodWithCrazyName()); + } + + public function testAbleToUseSetMethodsAfterAddMethods(): void + { + $mock = $this->getMockBuilder(Mockable::class) + ->addMethods(['notAMethod']) + ->setMethods(['mockableMethodWithCrazyName']) + ->getMock(); + + $this->assertNull($mock->mockableMethodWithCrazyName()); + } + + public function testNotAbleToUseAddMethodsAfterSetMethods(): void + { + $this->expectException(RuntimeException::class); + + $this->getMockBuilder(Mockable::class) + ->setMethods(['mockableMethod']) + ->addMethods(['mockableMethodWithFakeMethod']) + ->getMock(); + } + + public function testNotAbleToUseOnlyMethodsAfterSetMethods(): void + { + $this->expectException(RuntimeException::class); + + $this->getMockBuilder(Mockable::class) + ->setMethods(['mockableMethodWithFakeMethod']) + ->onlyMethods(['mockableMethod']) + ->getMock(); + } + public function testByDefaultDoesNotPassArgumentsToTheConstructor(): void { $mock = $this->getMockBuilder(Mockable::class)->getMock(); diff --git a/tests/unit/Framework/TestCaseTest.php b/tests/unit/Framework/TestCaseTest.php index 29e1ab5dd73..fe6e7d087b0 100644 --- a/tests/unit/Framework/TestCaseTest.php +++ b/tests/unit/Framework/TestCaseTest.php @@ -857,6 +857,26 @@ public function testCreatePartialMockCanMockNoMethods(): void $this->assertTrue($mock->anotherMockableMethod()); } + public function testCreatePartialMockWithFakeMethods(): void + { + $test = new \TestWithDifferentStatuses('testWithCreatePartialMockWarning'); + + $test->run(); + + $this->assertSame(BaseTestRunner::STATUS_WARNING, $test->getStatus()); + $this->assertFalse($test->hasFailed()); + } + + public function testCreatePartialMockWithRealMethods(): void + { + $test = new \TestWithDifferentStatuses('testWithCreatePartialMockPassesNoWarning'); + + $test->run(); + + $this->assertSame(BaseTestRunner::STATUS_PASSED, $test->getStatus()); + $this->assertFalse($test->hasFailed()); + } + public function testCreateMockSkipsConstructor(): void { /** @var \Mockable $mock */