diff --git a/src/Factories/Annotations/CoversNothing.php b/src/Factories/Annotations/CoversNothing.php new file mode 100644 index 00000000..8faaca1f --- /dev/null +++ b/src/Factories/Annotations/CoversNothing.php @@ -0,0 +1,30 @@ + $annotations + * + * @return array + */ + public function __invoke(TestCaseMethodFactory $method, array $annotations): array + { + if (($method->covers[0] ?? null) instanceof CoversNothingFactory) { + $annotations[] = '@coversNothing'; + } + + return $annotations; + } +} diff --git a/src/Factories/Attributes/Attribute.php b/src/Factories/Attributes/Attribute.php new file mode 100644 index 00000000..22a08968 --- /dev/null +++ b/src/Factories/Attributes/Attribute.php @@ -0,0 +1,18 @@ + $attributes + * + * @return array + */ + public function __invoke(TestCaseMethodFactory $method, array $attributes): array + { + foreach ($method->covers as $covering) { + if ($covering instanceof CoversClass) { + // Prepend a backslash for FQN classes + if (str_contains($covering->class, '\\')) { + $covering->class = '\\' . $covering->class; + } + + $attributes[] = "#[\PHPUnit\Framework\Attributes\CoversClass({$covering->class}::class)]"; + } elseif ($covering instanceof CoversFunction) { + $attributes[] = "#[\PHPUnit\Framework\Attributes\CoversFunction('{$covering->function}')]"; + } + } + + return $attributes; + } +} diff --git a/src/Factories/Covers/CoversClass.php b/src/Factories/Covers/CoversClass.php new file mode 100644 index 00000000..44f58487 --- /dev/null +++ b/src/Factories/Covers/CoversClass.php @@ -0,0 +1,15 @@ +> + */ + private static array $attributes = [ + Attributes\Covers::class, ]; /** @@ -141,11 +151,33 @@ final class TestCaseFactory $classFQN .= $className; } + $classAvailableAttributes = array_filter(self::$attributes, fn (string $attribute) => $attribute::ABOVE_CLASS); + $methodAvailableAttributes = array_filter(self::$attributes, fn (string $attribute) => !$attribute::ABOVE_CLASS); + + $classAttributes = []; + + foreach ($classAvailableAttributes as $attribute) { + $classAttributes = array_reduce( + $methods, + fn (array $carry, TestCaseMethodFactory $methodFactory) => (new $attribute())->__invoke($methodFactory, $carry), + $classAttributes + ); + } + $methodsCode = implode('', array_map( - fn (TestCaseMethodFactory $methodFactory) => $methodFactory->buildForEvaluation($classFQN, self::$annotations), + fn (TestCaseMethodFactory $methodFactory) => $methodFactory->buildForEvaluation( + $classFQN, + self::$annotations, + $methodAvailableAttributes + ), $methods )); + $classAttributesCode = implode('', array_map( + static fn (string $attribute) => sprintf("\n %s", $attribute), + array_unique($classAttributes), + )); + try { eval(" namespace $namespace; @@ -153,6 +185,7 @@ final class TestCaseFactory use Pest\Repositories\DatasetsRepository as __PestDatasets; use Pest\TestSuite as __PestTestSuite; + $classAttributesCode final class $className extends $baseClass implements $hasPrintableTestCaseClassFQN { $traitsCode diff --git a/src/Factories/TestCaseMethodFactory.php b/src/Factories/TestCaseMethodFactory.php index 987a2d27..7830b693 100644 --- a/src/Factories/TestCaseMethodFactory.php +++ b/src/Factories/TestCaseMethodFactory.php @@ -47,6 +47,13 @@ final class TestCaseMethodFactory */ public array $groups = []; + /** + * The covered classes and functions, if any. + * + * @var array + */ + public array $covers = []; + /** * Creates a new Factory instance. */ @@ -106,9 +113,10 @@ final class TestCaseMethodFactory /** * Creates a PHPUnit method as a string ready for evaluation. * - * @param array $annotationsToUse + * @param array $annotationsToUse + * @param array> $attributesToUse */ - public function buildForEvaluation(string $classFQN, array $annotationsToUse): string + public function buildForEvaluation(string $classFQN, array $annotationsToUse, array $attributesToUse): string { if ($this->description === null) { throw ShouldNotHappen::fromMessage('The test description may not be empty.'); @@ -122,12 +130,18 @@ final class TestCaseMethodFactory $datasetsCode = ''; $annotations = ['@test']; + $attributes = []; foreach ($annotationsToUse as $annotation) { /** @phpstan-ignore-next-line */ $annotations = (new $annotation())->__invoke($this, $annotations); } + foreach ($attributesToUse as $attribute) { + /** @phpstan-ignore-next-line */ + $attributes = (new $attribute())->__invoke($this, $attributes); + } + if (count($this->datasets) > 0) { $dataProviderName = $methodName . '_dataset'; $annotations[] = "@dataProvider $dataProviderName"; @@ -138,10 +152,15 @@ final class TestCaseMethodFactory static fn ($annotation) => sprintf("\n * %s", $annotation), $annotations, )); + $attributes = implode('', array_map( + static fn ($attribute) => sprintf("\n %s", $attribute), $attributes, + )); + return <<__runTest( diff --git a/src/PendingCalls/TestCall.php b/src/PendingCalls/TestCall.php index f9e786c1..48f612b8 100644 --- a/src/PendingCalls/TestCall.php +++ b/src/PendingCalls/TestCall.php @@ -5,6 +5,10 @@ declare(strict_types=1); namespace Pest\PendingCalls; use Closure; +use InvalidArgumentException; +use Pest\Factories\Covers\CoversClass; +use Pest\Factories\Covers\CoversFunction; +use Pest\Factories\Covers\CoversNothing; use Pest\Factories\TestCaseMethodFactory; use Pest\Support\Backtrace; use Pest\Support\HigherOrderCallables; @@ -168,6 +172,63 @@ final class TestCall return $this; } + /** + * Sets the covered classes or methods. + */ + public function covers(string ...$classesOrFunctions): TestCall + { + foreach ($classesOrFunctions as $classOrFunction) { + $isClass = class_exists($classOrFunction); + $isMethod = function_exists($classOrFunction); + + if (!$isClass && !$isMethod) { + throw new InvalidArgumentException(sprintf('No class or method named "%s" has been found.', $classOrFunction)); + } + + if ($isClass) { + $this->coversClass($classOrFunction); + } else { + $this->coversFunction($classOrFunction); + } + } + + return $this; + } + + /** + * Sets the covered classes. + */ + public function coversClass(string ...$classes): TestCall + { + foreach ($classes as $class) { + $this->testCaseMethod->covers[] = new CoversClass($class); + } + + return $this; + } + + /** + * Sets the covered functions. + */ + public function coversFunction(string ...$functions): TestCall + { + foreach ($functions as $function) { + $this->testCaseMethod->covers[] = new CoversFunction($function); + } + + return $this; + } + + /** + * Sets that the current test covers nothing. + */ + public function coversNothing(): TestCall + { + $this->testCaseMethod->covers = [new CoversNothing()]; + + return $this; + } + /** * Saves the property accessors to be used on the target. */ diff --git a/tests/Features/Covers.php b/tests/Features/Covers.php new file mode 100644 index 00000000..63c3d8a1 --- /dev/null +++ b/tests/Features/Covers.php @@ -0,0 +1,78 @@ +getAttributes(); + + expect($attributes[0]->getName())->toBe('PHPUnit\Framework\Attributes\CoversClass'); + expect($attributes[0]->getArguments()[0])->toBe('P\Tests\Features\TestCoversClass1'); +})->coversClass(TestCoversClass1::class); + +it('uses the correct PHPUnit attribute for function', function () { + $attributes = (new ReflectionClass($this))->getAttributes(); + + expect($attributes[1]->getName())->toBe('PHPUnit\Framework\Attributes\CoversFunction'); + expect($attributes[1]->getArguments()[0])->toBe('foo'); +})->coversFunction('foo'); + +it('removes duplicated attributes', function () { + $attributes = (new ReflectionClass($this))->getAttributes(); + + expect($attributes[2]->getName())->toBe('PHPUnit\Framework\Attributes\CoversClass'); + expect($attributes[2]->getArguments()[0])->toBe('P\Tests\Features\TestCoversClass2'); + expect($attributes[3]->getName())->toBe('PHPUnit\Framework\Attributes\CoversClass'); + expect($attributes[3]->getArguments()[0])->toBe('Pest\Factories\Attributes\Covers'); + expect($attributes[4]->getName())->toBe('PHPUnit\Framework\Attributes\CoversFunction'); + expect($attributes[4]->getArguments()[0])->toBe('bar'); + expect($attributes[5]->getName())->toBe('PHPUnit\Framework\Attributes\CoversFunction'); + expect($attributes[5]->getArguments()[0])->toBe('baz'); +}) + ->coversClass(TestCoversClass2::class, TestCoversClass1::class, Covers::class) + ->coversFunction('bar', 'foo', 'baz'); + +it('guesses if the given argument is a class or function', function () { + $attributes = (new ReflectionClass($this))->getAttributes(); + + expect($attributes[6]->getName())->toBe('PHPUnit\Framework\Attributes\CoversClass'); + expect($attributes[6]->getArguments()[0])->toBe('P\Tests\Features\TestCoversClass3'); + expect($attributes[7]->getName())->toBe('PHPUnit\Framework\Attributes\CoversFunction'); + expect($attributes[7]->getArguments()[0])->toBe('testCoversFunction'); +})->covers(TestCoversClass3::class, 'testCoversFunction'); + +it('appends CoversNothing to method attributes', function () { + $phpDoc = (new ReflectionClass($this))->getMethod($this->getName()); + + expect(str_contains($phpDoc->getDocComment(), '* @coversNothing'))->toBeTrue(); +})->coversNothing(); + +it('does not append CoversNothing to other methods', function () { + $phpDoc = (new ReflectionClass($this))->getMethod($this->getName()); + + expect(str_contains($phpDoc->getDocComment(), '* @coversNothing'))->toBeFalse(); +}); + +it('throws exception if no class nor method has been found', function () { + $testCall = new TestCall(TestSuite::getInstance(), 'filename', 'description', fn () => 'closure'); + + $testCall->covers('fakeName'); +})->throws(InvalidArgumentException::class, 'No class or method named "fakeName" has been found.');