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 */