feat: always use attributes instead of annotations

This commit is contained in:
Nuno Maduro
2024-01-05 18:00:14 +00:00
parent 04d2fa5ce8
commit 53dc9ffa06
20 changed files with 152 additions and 328 deletions

View File

@ -51,7 +51,7 @@
"require-dev": {
"pestphp/pest-dev-tools": "^3.0.0",
"pestphp/pest-plugin-type-coverage": "^3.0.0",
"symfony/process": "^7.0.0"
"symfony/process": "^7.0.2"
},
"minimum-stability": "dev",
"prefer-stable": true,

View File

@ -10,6 +10,7 @@ use Pest\Support\ChainableClosure;
use Pest\Support\ExceptionTrace;
use Pest\Support\Reflection;
use Pest\TestSuite;
use PHPUnit\Framework\Attributes\PostCondition;
use PHPUnit\Framework\TestCase;
use ReflectionException;
use ReflectionFunction;
@ -337,7 +338,7 @@ trait Testable
return ExceptionTrace::ensure(fn (): mixed => call_user_func_array(Closure::bind($closure, $this, $this::class), $arguments));
}
/** @postCondition */
#[PostCondition]
protected function __MarkTestIncompleteIfSnapshotHaveChanged(): void
{
if (count($this->__snapshotChanges) === 0) {

View File

@ -1,21 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Contracts;
use Pest\Factories\TestCaseMethodFactory;
/**
* @internal
*/
interface AddsAnnotations
{
/**
* Adds annotations to the given test case method.
*
* @param array<int, string> $annotations
* @return array<int, string>
*/
public function __invoke(TestCaseMethodFactory $method, array $annotations): array;
}

View File

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace Pest\Evaluators;
use Pest\Factories\Attribute;
/**
* @internal
*/
final class Attributes
{
/**
* Evaluates the given attributes and returns the code.
*/
public static function code(iterable $attributes): string
{
return implode(PHP_EOL, array_map(function (Attribute $attribute): string {
$name = $attribute->name;
if ($attribute->arguments === []) {
return " #[\\{$name}]";
}
$arguments = array_map(fn (string $argument): string => var_export($argument, true), $attribute->arguments);
return sprintf(' #[\\%s(%s)]', $name, implode(', ', $arguments));
}, $attributes));
}
}

View File

@ -1,29 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Factories\Annotations;
use Pest\Contracts\AddsAnnotations;
use Pest\Factories\TestCaseMethodFactory;
use Pest\Support\Str;
/**
* @internal
*/
final class Depends implements AddsAnnotations
{
/**
* {@inheritdoc}
*/
public function __invoke(TestCaseMethodFactory $method, array $annotations): array
{
foreach ($method->depends as $depend) {
$depend = Str::evaluable($method->describing !== null ? Str::describe($method->describing, $depend) : $depend);
$annotations[] = "@depends $depend";
}
return $annotations;
}
}

View File

@ -1,26 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Factories\Annotations;
use Pest\Contracts\AddsAnnotations;
use Pest\Factories\TestCaseMethodFactory;
/**
* @internal
*/
final class Groups implements AddsAnnotations
{
/**
* {@inheritdoc}
*/
public function __invoke(TestCaseMethodFactory $method, array $annotations): array
{
foreach ($method->groups as $group) {
$annotations[] = "@group $group";
}
return $annotations;
}
}

View File

@ -1,30 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Factories\Annotations;
use Pest\Contracts\AddsAnnotations;
use Pest\Factories\TestCaseMethodFactory;
final class TestDox implements AddsAnnotations
{
/**
* {@inheritdoc}
*/
public function __invoke(TestCaseMethodFactory $method, array $annotations): array
{
/*
* Escapes docblock according to
* https://manual.phpdoc.org/HTMLframesConverter/default/phpDocumentor/tutorial_phpDocumentor.howto.pkg.html#basics.desc
*
* Note: '@' escaping is not needed as it cannot be the first character of the line (it always starts with @testdox).
*/
assert($method->description !== null);
$methodDescription = str_replace('*/', '{@*}', $method->description);
$annotations[] = "@testdox $methodDescription";
return $annotations;
}
}

View File

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Pest\Factories;
/**
* @internal
*/
final class Attribute
{
/**
* @param iterable<int, string> $arguments
*/
public function __construct(public string $name, public iterable $arguments = [])
{
//
}
}

View File

@ -1,27 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Factories\Attributes;
use Pest\Factories\TestCaseMethodFactory;
/**
* @internal
*/
abstract class Attribute
{
/**
* Determine if the attribute should be placed above the class instead of above the method.
*/
public static bool $above = false;
/**
* @param array<int, string> $attributes
* @return array<int, string>
*/
public function __invoke(TestCaseMethodFactory $method, array $attributes): array // @phpstan-ignore-line
{
return $attributes;
}
}

View File

@ -1,46 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Factories\Attributes;
use Pest\Factories\Covers\CoversClass;
use Pest\Factories\Covers\CoversFunction;
use Pest\Factories\TestCaseMethodFactory;
/**
* @internal
*/
final class Covers extends Attribute
{
/**
* Determine if the attribute should be placed above the class instead of above the method.
*/
public static bool $above = true;
/**
* Adds attributes regarding the "covers" feature.
*
* @param array<int, string> $attributes
* @return array<int, string>
*/
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}')]";
} else {
$attributes[] = "#[\PHPUnit\Framework\Attributes\CoversNothing()]";
}
}
return $attributes;
}
}

View File

@ -1,15 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Factories\Covers;
/**
* @internal
*/
final class CoversClass
{
public function __construct(public string $class)
{
}
}

View File

@ -1,15 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Factories\Covers;
/**
* @internal
*/
final class CoversFunction
{
public function __construct(public string $function)
{
}
}

View File

@ -1,12 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Factories\Covers;
/**
* @internal
*/
final class CoversNothing
{
}

View File

@ -6,8 +6,8 @@ namespace Pest\Factories;
use ParseError;
use Pest\Concerns;
use Pest\Contracts\AddsAnnotations;
use Pest\Contracts\HasPrintableTestCaseName;
use Pest\Evaluators\Attributes;
use Pest\Exceptions\DatasetMissing;
use Pest\Exceptions\ShouldNotHappen;
use Pest\Exceptions\TestAlreadyExist;
@ -26,25 +26,12 @@ final class TestCaseFactory
{
use HigherOrderable;
/**
* The list of annotations.
*
* @var array<int, class-string<AddsAnnotations>>
*/
private const ANNOTATIONS = [
Annotations\Depends::class,
Annotations\Groups::class,
Annotations\TestDox::class,
];
/**
* The list of attributes.
*
* @var array<int, class-string<\Pest\Factories\Attributes\Attribute>>
* @var iterable<int, Attribute>
*/
private const ATTRIBUTES = [
Attributes\Covers::class,
];
public iterable $attributes = [];
/**
* The FQN of the Test Case class.
@ -145,32 +132,21 @@ final class TestCaseFactory
$className = 'InvalidTestName'.Str::random();
}
$classAvailableAttributes = array_filter(self::ATTRIBUTES, fn (string $attribute): bool => $attribute::$above);
$methodAvailableAttributes = array_filter(self::ATTRIBUTES, fn (string $attribute): bool => ! $attribute::$above);
$this->attributes = [
new Attribute(
\PHPUnit\Framework\Attributes\TestDox::class,
[$this->filename],
),
...$this->attributes,
];
$classAttributes = [];
foreach ($classAvailableAttributes as $attribute) {
$classAttributes = array_reduce(
$methods,
fn (array $carry, TestCaseMethodFactory $methodFactory): array => (new $attribute())->__invoke($methodFactory, $carry),
$classAttributes
);
}
$attributesCode = Attributes::code($this->attributes);
$methodsCode = implode('', array_map(
fn (TestCaseMethodFactory $methodFactory): string => $methodFactory->buildForEvaluation(
self::ANNOTATIONS,
$methodAvailableAttributes
),
fn (TestCaseMethodFactory $methodFactory): string => $methodFactory->buildForEvaluation(),
$methods
));
$classAttributesCode = implode('', array_map(
static fn (string $attribute): string => sprintf("\n%s", $attribute),
array_unique($classAttributes),
));
try {
$classCode = <<<PHP
namespace $namespace;
@ -178,10 +154,7 @@ final class TestCaseFactory
use Pest\Repositories\DatasetsRepository as __PestDatasets;
use Pest\TestSuite as __PestTestSuite;
/**
* @testdox $filename
*/
$classAttributesCode
$attributesCode
#[\AllowDynamicProperties]
final class $className extends $baseClass implements $hasPrintableTestCaseClassFQN {
$traitsCode

View File

@ -5,13 +5,14 @@ declare(strict_types=1);
namespace Pest\Factories;
use Closure;
use Pest\Contracts\AddsAnnotations;
use Pest\Evaluators\Attributes;
use Pest\Exceptions\ShouldNotHappen;
use Pest\Factories\Concerns\HigherOrderable;
use Pest\Repositories\DatasetsRepository;
use Pest\Support\Str;
use Pest\TestSuite;
use PHPUnit\Framework\Assert;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
/**
@ -21,6 +22,13 @@ final class TestCaseMethodFactory
{
use HigherOrderable;
/**
* The list of attributes.
*
* @var array<int, class-string<Attribute>>
*/
public array $attributes = [];
/**
* The test's describing, if any.
*/
@ -57,13 +65,6 @@ final class TestCaseMethodFactory
*/
public array $groups = [];
/**
* The covered classes and functions.
*
* @var array<int, \Pest\Factories\Covers\CoversClass|\Pest\Factories\Covers\CoversFunction|\Pest\Factories\Covers\CoversNothing>
*/
public array $covers = [];
/**
* Creates a new test case method factory instance.
*/
@ -121,11 +122,8 @@ final class TestCaseMethodFactory
/**
* Creates a PHPUnit method as a string ready for evaluation.
*
* @param array<int, class-string<AddsAnnotations>> $annotationsToUse
* @param array<int, class-string<\Pest\Factories\Attributes\Attribute>> $attributesToUse
*/
public function buildForEvaluation(array $annotationsToUse, array $attributesToUse): string
public function buildForEvaluation(): string
{
if ($this->description === null) {
throw ShouldNotHappen::fromMessage('The test description may not be empty.');
@ -134,36 +132,42 @@ final class TestCaseMethodFactory
$methodName = Str::evaluable($this->description);
$datasetsCode = '';
$annotations = ['@test'];
$attributes = [];
foreach ($annotationsToUse as $annotation) {
$annotations = (new $annotation())->__invoke($this, $annotations);
}
// prepend attribute
$this->attributes = [
new Attribute(
\PHPUnit\Framework\Attributes\Test::class,
[],
),
new Attribute(
\PHPUnit\Framework\Attributes\TestDox::class,
[str_replace('*/', '{@*}', $this->description)],
),
...$this->attributes,
];
foreach ($attributesToUse as $attribute) {
$attributes = (new $attribute())->__invoke($this, $attributes);
foreach ($this->depends as $depend) {
$depend = Str::evaluable($this->describing !== null ? Str::describe($this->describing, $depend) : $depend);
$this->attributes[] = new Attribute(
\PHPUnit\Framework\Attributes\Depends::class,
[$depend],
);
}
if ($this->datasets !== [] || $this->repetitions > 1) {
$dataProviderName = $methodName.'_dataset';
$annotations[] = "@dataProvider $dataProviderName";
$this->attributes[] = new Attribute(
DataProvider::class,
[$dataProviderName],
);
$datasetsCode = $this->buildDatasetForEvaluation($methodName, $dataProviderName);
}
$annotations = implode('', array_map(
static fn (string $annotation): string => sprintf("\n * %s", $annotation), $annotations,
));
$attributes = implode('', array_map(
static fn (string $attribute): string => sprintf("\n %s", $attribute), $attributes,
));
$attributesCode = Attributes::code($this->attributes);
return <<<PHP
/**$annotations
*/
$attributes
$attributesCode
public function $methodName()
{
\$test = \Pest\TestSuite::getInstance()->tests->get(self::\$__filename)->getMethod(\$this->name())->getClosure(\$this);
@ -173,7 +177,7 @@ final class TestCaseMethodFactory
...func_get_args(),
);
}
$datasetsCode
$datasetsCode
PHP;
}

View File

@ -6,9 +6,7 @@ namespace Pest\PendingCalls;
use Closure;
use Pest\Exceptions\InvalidArgumentException;
use Pest\Factories\Covers\CoversClass;
use Pest\Factories\Covers\CoversFunction;
use Pest\Factories\Covers\CoversNothing;
use Pest\Factories\Attribute;
use Pest\Factories\TestCaseMethodFactory;
use Pest\PendingCalls\Concerns\Describable;
use Pest\Plugins\Only;
@ -30,6 +28,13 @@ final class TestCall
{
use Describable;
/**
* The list of test case factory attributes.
*
* @var array<int, Attribute>
*/
private array $testCaseFactoryAttributes = [];
/**
* The Test Case Factory.
*/
@ -165,7 +170,10 @@ final class TestCall
public function group(string ...$groups): self
{
foreach ($groups as $group) {
$this->testCaseMethod->groups[] = $group;
$this->testCaseMethod->attributes[] = new Attribute(
\PHPUnit\Framework\Attributes\Group::class,
[$group],
);
}
return $this;
@ -321,7 +329,10 @@ final class TestCall
public function coversClass(string ...$classes): self
{
foreach ($classes as $class) {
$this->testCaseMethod->covers[] = new CoversClass($class);
$this->testCaseFactoryAttributes[] = new Attribute(
\PHPUnit\Framework\Attributes\CoversClass::class,
[$class],
);
}
return $this;
@ -333,7 +344,10 @@ final class TestCall
public function coversFunction(string ...$functions): self
{
foreach ($functions as $function) {
$this->testCaseMethod->covers[] = new CoversFunction($function);
$this->testCaseFactoryAttributes[] = new Attribute(
\PHPUnit\Framework\Attributes\CoversFunction::class,
[$function],
);
}
return $this;
@ -344,7 +358,10 @@ final class TestCall
*/
public function coversNothing(): self
{
$this->testCaseMethod->covers = [new CoversNothing()];
$this->testCaseMethod->attributes[] = new Attribute(
\PHPUnit\Framework\Attributes\CoversNothing::class,
[],
);
return $this;
}
@ -417,5 +434,9 @@ final class TestCall
}
$this->testSuite->tests->set($this->testCaseMethod);
foreach ($this->testCaseFactoryAttributes as $attribute) {
$this->testSuite->tests->get($this->filename)->attributes[] = $attribute;
}
}
}

View File

@ -9,9 +9,11 @@ use Pest\Contracts\TestCaseFilter;
use Pest\Contracts\TestCaseMethodFilter;
use Pest\Exceptions\TestCaseAlreadyInUse;
use Pest\Exceptions\TestCaseClassOrTraitNotFound;
use Pest\Factories\Attribute;
use Pest\Factories\TestCaseFactory;
use Pest\Factories\TestCaseMethodFactory;
use Pest\Support\Str;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\TestCase;
/**
@ -181,14 +183,13 @@ final class TestRepository
foreach ($testCase->methods as $method) {
foreach ($groups as $group) {
$method->groups[] = $group;
$method->attributes[] = new Attribute(
Group::class,
[$group],
);
}
}
foreach ($testCase->methods as $method) {
$method->groups = [...$groups, ...$method->groups];
}
$testCase->factoryProxies->add($testCase->filename, 0, '__addBeforeAll', [$hooks[0] ?? null]);
$testCase->factoryProxies->add($testCase->filename, 0, '__addBeforeEach', [$hooks[1] ?? null]);
$testCase->factoryProxies->add($testCase->filename, 0, '__addAfterEach', [$hooks[2] ?? null]);

View File

@ -3,8 +3,8 @@
use Pest\PendingCalls\TestCall;
use Pest\TestSuite;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\CoversFunction;
use Tests\Fixtures\Covers\CoversClass1;
use Tests\Fixtures\Covers\CoversClass2;
use Tests\Fixtures\Covers\CoversClass3;
use Tests\Fixtures\Covers\CoversTrait;
@ -17,45 +17,39 @@ function testCoversFunction()
it('uses the correct PHPUnit attribute for class', function () {
$attributes = (new ReflectionClass($this))->getAttributes();
expect($attributes[0]->getName())->toBe('PHPUnit\Framework\Attributes\CoversClass');
expect($attributes[0]->getArguments()[0])->toBe('Tests\Fixtures\Covers\CoversClass1');
expect($attributes[1]->getName())->toBe('PHPUnit\Framework\Attributes\CoversClass');
expect($attributes[1]->getArguments()[0])->toBe('Tests\Fixtures\Covers\CoversClass1');
})->coversClass(CoversClass1::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('testCoversFunction');
expect($attributes[2]->getName())->toBe('PHPUnit\Framework\Attributes\CoversFunction');
expect($attributes[2]->getArguments()[0])->toBe('testCoversFunction');
})->coversFunction('testCoversFunction');
it('removes duplicated attributes', function () {
$attributes = (new ReflectionClass($this))->getAttributes();
expect($attributes[2]->getName())->toBe(CoversClass::class);
expect($attributes[2]->getArguments()[0])->toBe(CoversClass2::class);
})
->coversClass(CoversClass2::class, CoversClass1::class)
->coversFunction('testCoversFunction');
it('guesses if the given argument is a class or function', function () {
$attributes = (new ReflectionClass($this))->getAttributes();
expect($attributes[3]->getName())->toBe(CoversClass::class);
expect($attributes[3]->getArguments()[0])->toBe(CoversClass3::class);
expect($attributes[4]->getName())->toBe(CoversFunction::class);
expect($attributes[4]->getArguments()[0])->toBe('testCoversFunction');
})->covers(CoversClass3::class, 'testCoversFunction');
it('uses the correct PHPUnit attribute for trait', function () {
$attributes = (new ReflectionClass($this))->getAttributes();
expect($attributes[4]->getName())->toBe('PHPUnit\Framework\Attributes\CoversClass');
expect($attributes[4]->getArguments()[0])->toBe('Tests\Fixtures\Covers\CoversTrait');
expect($attributes[5]->getName())->toBe('PHPUnit\Framework\Attributes\CoversClass');
expect($attributes[5]->getArguments()[0])->toBe('Tests\Fixtures\Covers\CoversTrait');
})->coversClass(CoversTrait::class);
it('uses the correct PHPUnit attribute for covers nothing', function () {
$attributes = (new ReflectionClass($this))->getAttributes();
$attributes = (new ReflectionMethod($this, $this->name()))->getAttributes();
expect($attributes[5]->getName())->toBe('PHPUnit\Framework\Attributes\CoversNothing');
expect($attributes[5]->getArguments())->toHaveCount(0);
expect($attributes[2]->getName())->toBe('PHPUnit\Framework\Attributes\CoversNothing');
expect($attributes[2]->getArguments())->toHaveCount(0);
})->coversNothing();
it('throws exception if no class nor method has been found', function () {

View File

@ -12,7 +12,7 @@ class ExecutedTest extends TestCase
{
public static $executed = false;
/** @test */
#[Test]
public function testThatGetsExecuted(): void
{
self::$executed = true;

View File

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Tests\CustomTestCase;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use function PHPUnit\Framework\assertTrue;
@ -15,7 +16,7 @@ class ParentTest extends TestCase
return false;
}
/** @test */
#[Test]
public function testOverrideMethod(): void
{
assertTrue($this->getEntity() || true);