diff --git a/.github/workflows/static.yml b/.github/workflows/static.yml index 49528668..dfddaaf5 100644 --- a/.github/workflows/static.yml +++ b/.github/workflows/static.yml @@ -30,8 +30,11 @@ jobs: - name: Install Dependencies run: composer update --prefer-stable --no-interaction --no-progress --ansi - - name: Types - run: composer test:types + - name: Type Check + run: composer test:type:check + + - name: Type Coverage + run: composer test:type:coverage - name: Refacto run: composer test:refacto diff --git a/composer.json b/composer.json index 8b3698c6..ec6159d5 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "pestphp/pest", - "description": "An elegant PHP Testing Framework.", + "description": "The elegant PHP Testing Framework.", "keywords": [ "php", "framework", @@ -50,9 +50,11 @@ }, "require-dev": { "pestphp/pest-dev-tools": "^2.12.0", + "pestphp/pest-plugin-type-coverage": "^2.0.0", "symfony/process": "^6.3.0" }, - "minimum-stability": "stable", + "minimum-stability": "dev", + "prefer-stable": true, "config": { "sort-packages": true, "preferred-install": "dist", @@ -68,16 +70,18 @@ "lint": "pint", "test:refacto": "rector --dry-run", "test:lint": "pint --test", - "test:types": "phpstan analyse --ansi --memory-limit=-1 --debug", + "test:type:check": "phpstan analyse --ansi --memory-limit=-1 --debug", + "test:type:coverage": "php bin/pest --type-coverage --min=100", "test:unit": "php bin/pest --colors=always --exclude-group=integration --compact", "test:inline": "php bin/pest --colors=always --configuration=phpunit.inline.xml", "test:parallel": "php bin/pest --colors=always --exclude-group=integration --parallel --processes=10", "test:integration": "php bin/pest --colors=always --group=integration", - "update:snapshots": "REBUILD_SNAPSHOTS=true php bin/pest --colors=always", + "update:snapshots": "REBUILD_SNAPSHOTS=true php bin/pest --colors=always --update-snapshots", "test": [ "@test:refacto", "@test:lint", - "@test:types", + "@test:type:check", + "@test:type:coverage", "@test:unit", "@test:parallel", "@test:integration" @@ -98,6 +102,7 @@ "Pest\\Plugins\\ProcessIsolation", "Pest\\Plugins\\Profile", "Pest\\Plugins\\Retry", + "Pest\\Plugins\\Snapshot", "Pest\\Plugins\\Version", "Pest\\Plugins\\Parallel" ] diff --git a/src/Concerns/Testable.php b/src/Concerns/Testable.php index ce020c3a..273dfb75 100644 --- a/src/Concerns/Testable.php +++ b/src/Concerns/Testable.php @@ -23,37 +23,42 @@ use Throwable; trait Testable { /** - * Test method description. + * The test's description. */ private string $__description; /** - * Test "latest" method description. + * The test's latest description. */ private static string $__latestDescription; /** - * The Test Case "test" closure. + * The test's describing, if any. + */ + public ?string $__describing = null; + + /** + * The test's test closure. */ private Closure $__test; /** - * The Test Case "setUp" closure. + * The test's before each closure. */ private ?Closure $__beforeEach = null; /** - * The Test Case "tearDown" closure. + * The test's after each closure. */ private ?Closure $__afterEach = null; /** - * The Test Case "setUpBeforeClass" closure. + * The test's before all closure. */ private static ?Closure $__beforeAll = null; /** - * The test "tearDownAfterClass" closure. + * The test's after all closure. */ private static ?Closure $__afterAll = null; @@ -78,6 +83,7 @@ trait Testable if ($test->hasMethod($name)) { $method = $test->getMethod($name); $this->__description = self::$__latestDescription = $method->description; + $this->__describing = $method->describing; $this->__test = $method->getClosure($this); } } @@ -92,7 +98,7 @@ trait Testable } self::$__beforeAll = (self::$__beforeAll instanceof Closure) - ? ChainableClosure::fromStatic(self::$__beforeAll, $hook) + ? ChainableClosure::boundStatically(self::$__beforeAll, $hook) : $hook; } @@ -106,7 +112,7 @@ trait Testable } self::$__afterAll = (self::$__afterAll instanceof Closure) - ? ChainableClosure::fromStatic(self::$__afterAll, $hook) + ? ChainableClosure::boundStatically(self::$__afterAll, $hook) : $hook; } @@ -136,7 +142,7 @@ trait Testable } $this->{$property} = ($this->{$property} instanceof Closure) - ? ChainableClosure::from($this->{$property}, $hook) + ? ChainableClosure::bound($this->{$property}, $hook) : $hook; } @@ -150,7 +156,7 @@ trait Testable $beforeAll = TestSuite::getInstance()->beforeAll->get(self::$__filename); if (self::$__beforeAll instanceof Closure) { - $beforeAll = ChainableClosure::fromStatic(self::$__beforeAll, $beforeAll); + $beforeAll = ChainableClosure::boundStatically(self::$__beforeAll, $beforeAll); } call_user_func(Closure::bind($beforeAll, null, self::class)); @@ -164,7 +170,7 @@ trait Testable $afterAll = TestSuite::getInstance()->afterAll->get(self::$__filename); if (self::$__afterAll instanceof Closure) { - $afterAll = ChainableClosure::fromStatic(self::$__afterAll, $afterAll); + $afterAll = ChainableClosure::boundStatically(self::$__afterAll, $afterAll); } call_user_func(Closure::bind($afterAll, null, self::class)); @@ -184,7 +190,7 @@ trait Testable $beforeEach = TestSuite::getInstance()->beforeEach->get(self::$__filename)[1]; if ($this->__beforeEach instanceof Closure) { - $beforeEach = ChainableClosure::from($this->__beforeEach, $beforeEach); + $beforeEach = ChainableClosure::bound($this->__beforeEach, $beforeEach); } $this->__callClosure($beforeEach, func_get_args()); @@ -198,7 +204,7 @@ trait Testable $afterEach = TestSuite::getInstance()->afterEach->get(self::$__filename); if ($this->__afterEach instanceof Closure) { - $afterEach = ChainableClosure::from($this->__afterEach, $afterEach); + $afterEach = ChainableClosure::bound($this->__afterEach, $afterEach); } $this->__callClosure($afterEach, func_get_args()); diff --git a/src/Exceptions/BeforeEachAlreadyExist.php b/src/Exceptions/AfterAllWithinDescribe.php similarity index 70% rename from src/Exceptions/BeforeEachAlreadyExist.php rename to src/Exceptions/AfterAllWithinDescribe.php index 2425ce7f..4cdf2c2b 100644 --- a/src/Exceptions/BeforeEachAlreadyExist.php +++ b/src/Exceptions/AfterAllWithinDescribe.php @@ -12,13 +12,13 @@ use Symfony\Component\Console\Exception\ExceptionInterface; /** * @internal */ -final class BeforeEachAlreadyExist extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace +final class AfterAllWithinDescribe extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace { /** * Creates a new Exception instance. */ public function __construct(string $filename) { - parent::__construct(sprintf('The beforeEach already exists in the filename `%s`.', $filename)); + parent::__construct(sprintf('The afterAll method can not be used within describe functions. Filename `%s`.', $filename)); } } diff --git a/src/Exceptions/AfterEachAlreadyExist.php b/src/Exceptions/BeforeAllAlreadyExist.php similarity index 79% rename from src/Exceptions/AfterEachAlreadyExist.php rename to src/Exceptions/BeforeAllAlreadyExist.php index cbdd4a7c..a21389fe 100644 --- a/src/Exceptions/AfterEachAlreadyExist.php +++ b/src/Exceptions/BeforeAllAlreadyExist.php @@ -12,13 +12,13 @@ use Symfony\Component\Console\Exception\ExceptionInterface; /** * @internal */ -final class AfterEachAlreadyExist extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace +final class BeforeAllAlreadyExist extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace { /** * Creates a new Exception instance. */ public function __construct(string $filename) { - parent::__construct(sprintf('The afterEach already exists in the filename `%s`.', $filename)); + parent::__construct(sprintf('The beforeAll already exists in the filename `%s`.', $filename)); } } diff --git a/src/Exceptions/BeforeAllWithinDescribe.php b/src/Exceptions/BeforeAllWithinDescribe.php new file mode 100644 index 00000000..31845284 --- /dev/null +++ b/src/Exceptions/BeforeAllWithinDescribe.php @@ -0,0 +1,24 @@ + + * @mixin PendingArchExpectation */ final class Expectation { @@ -287,9 +296,23 @@ final class Expectation * @param array $parameters * @return Expectation|HigherOrderExpectation, TValue> */ - public function __call(string $method, array $parameters): Expectation|HigherOrderExpectation + public function __call(string $method, array $parameters): Expectation|HigherOrderExpectation|PendingArchExpectation { if (! self::hasMethod($method)) { + if (! is_object($this->value) && method_exists(PendingArchExpectation::class, $method)) { + $pendingArchExpectation = new PendingArchExpectation($this, []); + + return $pendingArchExpectation->$method(...$parameters); // @phpstan-ignore-line + } + + if (! is_object($this->value)) { + throw new BadMethodCallException(sprintf( + 'Method "%s" does not exist in %s.', + $method, + gettype($this->value) + )); + } + /* @phpstan-ignore-next-line */ return new HigherOrderExpectation($this, call_user_func_array($this->value->$method(...), $parameters)); } @@ -333,6 +356,11 @@ final class Expectation public function __get(string $name) { if (! self::hasMethod($name)) { + if (! is_object($this->value) && method_exists(PendingArchExpectation::class, $name)) { + /* @phpstan-ignore-next-line */ + return $this->{$name}(); + } + /* @phpstan-ignore-next-line */ return new HigherOrderExpectation($this, $this->retrieve($name, $this->value)); } @@ -369,6 +397,252 @@ final class Expectation return ToUse::make($this, $targets); } + /** + * Asserts that the given expectation target use the "declare(strict_types=1)" declaration. + */ + public function toUseStrictTypes(): ArchExpectation + { + return Targeted::make( + $this, + fn (ObjectDescription $object): bool => str_contains((string) file_get_contents($object->path), 'declare(strict_types=1);'), + 'to use strict types', + FileLineFinder::where(fn (string $line): bool => str_contains($line, ' $object->reflectionClass->isFinal(), + 'to be final', + FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')), + ); + } + + /** + * Asserts that the given expectation target is readonly. + */ + public function toBeReadonly(): ArchExpectation + { + return Targeted::make( + $this, + fn (ObjectDescription $object): bool => $object->reflectionClass->isReadOnly() && assert(true), // @phpstan-ignore-line, + 'to be readonly', + FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')), + ); + } + + /** + * Asserts that the given expectation target is trait. + */ + public function toBeTrait(): ArchExpectation + { + return Targeted::make( + $this, + fn (ObjectDescription $object): bool => $object->reflectionClass->isTrait(), + 'to be trait', + FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')), + ); + } + + /** + * Asserts that the given expectation targets are traits. + */ + public function toBeTraits(): ArchExpectation + { + return $this->toBeTrait(); + } + + /** + * Asserts that the given expectation target is abstract. + */ + public function toBeAbstract(): ArchExpectation + { + return Targeted::make( + $this, + fn (ObjectDescription $object): bool => $object->reflectionClass->isAbstract(), + 'to be abstract', + FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')), + ); + } + + /** + * Asserts that the given expectation target is enum. + */ + public function toBeEnum(): ArchExpectation + { + return Targeted::make( + $this, + fn (ObjectDescription $object): bool => $object->reflectionClass->isEnum(), + 'to be enum', + FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')), + ); + } + + /** + * Asserts that the given expectation targets are enums. + */ + public function toBeEnums(): ArchExpectation + { + return $this->toBeEnum(); + } + + /** + * Asserts that the given expectation targets is an class. + */ + public function toBeClass(): ArchExpectation + { + return Targeted::make( + $this, + fn (ObjectDescription $object): bool => class_exists($object->name), + 'to be class', + FileLineFinder::where(fn (string $line): bool => true), + ); + } + + /** + * Asserts that the given expectation targets are classes. + */ + public function toBeClasses(): ArchExpectation + { + return $this->toBeClass(); + } + + /** + * Asserts that the given expectation target is interface. + */ + public function toBeInterface(): ArchExpectation + { + return Targeted::make( + $this, + fn (ObjectDescription $object): bool => $object->reflectionClass->isInterface(), + 'to be interface', + FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')), + ); + } + + /** + * Asserts that the given expectation targets are interfaces. + */ + public function toBeInterfaces(): ArchExpectation + { + return $this->toBeInterface(); + } + + /** + * Asserts that the given expectation target to be subclass of the given class. + * + * @param class-string $class + */ + public function toExtend(string $class): ArchExpectation + { + return Targeted::make( + $this, + fn (ObjectDescription $object): bool => $class === $object->reflectionClass->getName() || $object->reflectionClass->isSubclassOf($class), + sprintf("to extend '%s'", $class), + FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')), + ); + } + + /** + * Asserts that the given expectation target to be have a parent class. + */ + public function toExtendNothing(): ArchExpectation + { + return Targeted::make( + $this, + fn (ObjectDescription $object): bool => $object->reflectionClass->getParentClass() === false, + 'to extend nothing', + FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')), + ); + } + + /** + * Asserts that the given expectation target to not implement any interfaces. + */ + public function toImplementNothing(): ArchExpectation + { + return Targeted::make( + $this, + fn (ObjectDescription $object): bool => $object->reflectionClass->getInterfaceNames() === [], + 'to implement nothing', + FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')), + ); + } + + /** + * Asserts that the given expectation target to only implement the given interfaces. + * + * @param array|class-string $interfaces + */ + public function toOnlyImplement(array|string $interfaces): ArchExpectation + { + $interfaces = is_array($interfaces) ? $interfaces : [$interfaces]; + + return Targeted::make( + $this, + fn (ObjectDescription $object): bool => count($interfaces) === count($object->reflectionClass->getInterfaceNames()) + && array_diff($interfaces, $object->reflectionClass->getInterfaceNames()) === [], + "to only implement '".implode("', '", $interfaces)."'", + FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')), + ); + } + + /** + * Asserts that the given expectation target to have the given suffix. + */ + public function toHaveSuffix(string $suffix): ArchExpectation + { + return Targeted::make( + $this, + fn (ObjectDescription $object): bool => str_ends_with($object->reflectionClass->getName(), $suffix), + "to have suffix '{$suffix}'", + FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')), + ); + } + + /** + * Asserts that the given expectation target to have the given suffix. + */ + public function toHavePrefix(string $suffix): ArchExpectation + { + return Targeted::make( + $this, + fn (ObjectDescription $object): bool => str_starts_with($object->reflectionClass->getName(), $suffix), + "to have prefix '{$suffix}'", + FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')), + ); + } + + /** + * Asserts that the given expectation target to implement the given interfaces. + * + * @param array|class-string $interfaces + */ + public function toImplement(array|string $interfaces): ArchExpectation + { + $interfaces = is_array($interfaces) ? $interfaces : [$interfaces]; + + return Targeted::make( + $this, + function (ObjectDescription $object) use ($interfaces): bool { + foreach ($interfaces as $interface) { + if (! $object->reflectionClass->implementsInterface($interface)) { + return false; + } + } + + return true; + }, + "to implement '".implode("', '", $interfaces)."'", + FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')), + ); + } + /** * Asserts that the given expectation target "only" use on the given dependencies. * diff --git a/src/Expectations/OppositeExpectation.php b/src/Expectations/OppositeExpectation.php index 327676db..cd672e81 100644 --- a/src/Expectations/OppositeExpectation.php +++ b/src/Expectations/OppositeExpectation.php @@ -5,15 +5,20 @@ declare(strict_types=1); namespace Pest\Expectations; use Pest\Arch\Contracts\ArchExpectation; +use Pest\Arch\Expectations\Targeted; use Pest\Arch\Expectations\ToBeUsedIn; use Pest\Arch\Expectations\ToBeUsedInNothing; use Pest\Arch\Expectations\ToUse; use Pest\Arch\GroupArchExpectation; +use Pest\Arch\PendingArchExpectation; use Pest\Arch\SingleArchExpectation; +use Pest\Arch\Support\FileLineFinder; use Pest\Exceptions\InvalidExpectation; use Pest\Expectation; use Pest\Support\Arr; use Pest\Support\Exporter; +use PHPUnit\Architecture\Elements\ObjectDescription; +use PHPUnit\Framework\AssertionFailedError; use PHPUnit\Framework\ExpectationFailedException; /** @@ -72,6 +77,191 @@ final class OppositeExpectation } /** + * Asserts that the given expectation target does not use the "declare(strict_types=1)" declaration. + */ + public function toUseStrictTypes(): ArchExpectation + { + return Targeted::make( + $this->original, + fn (ObjectDescription $object): bool => ! str_contains((string) file_get_contents($object->path), 'declare(strict_types=1);'), + 'not to use strict types', + FileLineFinder::where(fn (string $line): bool => str_contains($line, 'original, + fn (ObjectDescription $object): bool => ! $object->reflectionClass->isFinal(), + 'not to be final', + FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')), + ); + } + + /** + * Asserts that the given expectation target is not readonly. + */ + public function toBeReadonly(): ArchExpectation + { + return Targeted::make( + $this->original, + fn (ObjectDescription $object): bool => ! $object->reflectionClass->isReadOnly() && assert(true), // @phpstan-ignore-line + 'not to be readonly', + FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')), + ); + } + + /** + * Asserts that the given expectation target is not trait. + */ + public function toBeTrait(): ArchExpectation + { + return Targeted::make( + $this->original, + fn (ObjectDescription $object): bool => ! $object->reflectionClass->isTrait(), + 'not to be trait', + FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')), + ); + } + + /** + * Asserts that the given expectation target is not abstract. + */ + public function toBeAbstract(): ArchExpectation + { + return Targeted::make( + $this->original, + fn (ObjectDescription $object): bool => ! $object->reflectionClass->isAbstract(), + 'not to be abstract', + FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')), + ); + } + + /** + * Asserts that the given expectation target is not enum. + */ + public function toBeEnum(): ArchExpectation + { + return Targeted::make( + $this->original, + fn (ObjectDescription $object): bool => ! $object->reflectionClass->isEnum(), + 'not to be enum', + FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')), + ); + } + + /** + * Asserts that the given expectation target is not interface. + */ + public function toBeInterface(): ArchExpectation + { + return Targeted::make( + $this->original, + fn (ObjectDescription $object): bool => ! $object->reflectionClass->isInterface(), + 'not to be interface', + FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')), + ); + } + + /** + * Asserts that the given expectation target to be subclass of the given class. + * + * @param class-string $class + */ + public function toExtend(string $class): ArchExpectation + { + return Targeted::make( + $this->original, + fn (ObjectDescription $object): bool => ! $object->reflectionClass->isSubclassOf($class), + sprintf("not to extend '%s'", $class), + FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')), + ); + } + + /** + * Asserts that the given expectation target to be not have any parent class. + */ + public function toExtendNothing(): ArchExpectation + { + return Targeted::make( + $this->original, + fn (ObjectDescription $object): bool => $object->reflectionClass->getParentClass() !== false, + 'to extend a class', + FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')), + ); + } + + /** + * Asserts that the given expectation target not to implement the given interfaces. + * + * @param array|string $interfaces + */ + public function toImplement(array|string $interfaces): ArchExpectation + { + $interfaces = is_array($interfaces) ? $interfaces : [$interfaces]; + + return Targeted::make( + $this->original, + function (ObjectDescription $object) use ($interfaces): bool { + foreach ($interfaces as $interface) { + if ($object->reflectionClass->implementsInterface($interface)) { + return false; + } + } + + return true; + }, + "not to implement '".implode("', '", $interfaces)."'", + FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')), + ); + } + + /** + * Asserts that the given expectation target to not implement any interfaces. + */ + public function toImplementNothing(): ArchExpectation + { + return Targeted::make( + $this->original, + fn (ObjectDescription $object): bool => $object->reflectionClass->getInterfaceNames() !== [], + 'to implement an interface', + FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')), + ); + } + + /** + * Not supported. + * + * @param array|string $interfaces + */ + public function toOnlyImplement(array|string $interfaces): never + { + throw InvalidExpectation::fromMethods(['not', 'toOnlyImplement']); + } + + /** + * Not supported. + */ + public function toHavePrefix(string $suffix): never + { + throw InvalidExpectation::fromMethods(['not', 'toHavePrefix']); + } + + /** + * Not supported. + */ + public function toHaveSuffix(string $suffix): never + { + throw InvalidExpectation::fromMethods(['not', 'toHaveSuffix']); + } + + /** + * Not supported. + * * @param array|string $targets */ public function toOnlyUse(array|string $targets): never @@ -79,6 +269,9 @@ final class OppositeExpectation throw InvalidExpectation::fromMethods(['not', 'toOnlyUse']); } + /** + * Not supported. + */ public function toUseNothing(): never { throw InvalidExpectation::fromMethods(['not', 'toUseNothing']); @@ -126,9 +319,13 @@ final class OppositeExpectation public function __call(string $name, array $arguments): Expectation { try { + if (! is_object($this->original->value) && method_exists(PendingArchExpectation::class, $name)) { + throw InvalidExpectation::fromMethods(['not', $name]); + } + /* @phpstan-ignore-next-line */ $this->original->{$name}(...$arguments); - } catch (ExpectationFailedException) { + } catch (ExpectationFailedException|AssertionFailedError) { return $this->original; } @@ -143,8 +340,12 @@ final class OppositeExpectation public function __get(string $name): Expectation { try { + if (! is_object($this->original->value) && method_exists(PendingArchExpectation::class, $name)) { + throw InvalidExpectation::fromMethods(['not', $name]); + } + $this->original->{$name}; // @phpstan-ignore-line - } catch (ExpectationFailedException) { // @phpstan-ignore-line + } catch (ExpectationFailedException) { return $this->original; } @@ -162,8 +363,13 @@ final class OppositeExpectation $exporter = Exporter::default(); - $toString = fn ($argument): string => $exporter->shortenedExport($argument); + $toString = fn (mixed $argument): string => $exporter->shortenedExport($argument); - throw new ExpectationFailedException(sprintf('Expecting %s not %s %s.', $toString($this->original->value), strtolower((string) preg_replace('/(? $toString($argument), $arguments)))); + throw new ExpectationFailedException(sprintf( + 'Expecting %s not %s %s.', + $toString($this->original->value), + strtolower((string) preg_replace('/(? $toString($argument), $arguments)), + )); } } diff --git a/src/Factories/TestCaseFactory.php b/src/Factories/TestCaseFactory.php index 0b5b66f4..cbe50985 100644 --- a/src/Factories/TestCaseFactory.php +++ b/src/Factories/TestCaseFactory.php @@ -98,7 +98,7 @@ final class TestCaseFactory { if ('\\' === DIRECTORY_SEPARATOR) { // In case Windows, strtolower drive name, like in UsesCall. - $filename = (string) preg_replace_callback('~^(?P[a-z]+:\\\)~i', static fn ($match): string => strtolower($match['drive']), $filename); + $filename = (string) preg_replace_callback('~^(?P[a-z]+:\\\)~i', static fn (array $match): string => strtolower($match['drive']), $filename); } $filename = str_replace('\\\\', '\\', addslashes((string) realpath($filename))); @@ -134,7 +134,7 @@ final class TestCaseFactory $hasPrintableTestCaseClassFQN = sprintf('\%s', HasPrintableTestCaseName::class); $traitsCode = sprintf('use %s;', implode(', ', array_map( - static fn ($trait): string => sprintf('\%s', $trait), $this->traits)) + static fn (string $trait): string => sprintf('\%s', $trait), $this->traits)) ); $partsFQN = explode('\\', $classFQN); diff --git a/src/Factories/TestCaseMethodFactory.php b/src/Factories/TestCaseMethodFactory.php index 95b5c8ff..b4fce58c 100644 --- a/src/Factories/TestCaseMethodFactory.php +++ b/src/Factories/TestCaseMethodFactory.php @@ -22,40 +22,45 @@ final class TestCaseMethodFactory use HigherOrderable; /** - * Determines if the Test Case Method is a "todo". + * The test's describing, if any. + */ + public ?string $describing = null; + + /** + * Determines if the test is a "todo". */ public bool $todo = false; /** - * The Test Case Dataset, if any. + * The test's datasets. * * @var array|string> */ public array $datasets = []; /** - * The Test Case depends, if any. + * The test's dependencies. * * @var array */ public array $depends = []; /** - * The Test Case groups, if any. + * The test's groups. * * @var array */ public array $groups = []; /** - * The covered classes and functions, if any. + * The covered classes and functions. * * @var array */ public array $covers = []; /** - * Creates a new Factory instance. + * Creates a new test case method factory instance. */ public function __construct( public string $filename, @@ -70,7 +75,7 @@ final class TestCaseMethodFactory } /** - * Makes the Test Case classes. + * Creates the test's closure. */ public function getClosure(TestCase $concrete): Closure { @@ -142,11 +147,11 @@ final class TestCaseMethodFactory } $annotations = implode('', array_map( - static fn ($annotation): string => sprintf("\n * %s", $annotation), $annotations, + static fn (string $annotation): string => sprintf("\n * %s", $annotation), $annotations, )); $attributes = implode('', array_map( - static fn ($attribute): string => sprintf("\n %s", $attribute), $attributes, + static fn (string $attribute): string => sprintf("\n %s", $attribute), $attributes, )); return <<beforeAll->set($closure); } } @@ -43,7 +53,7 @@ if (! function_exists('beforeEach')) { /** * Runs the given closure before each test in the current file. * - * @return HigherOrderTapProxy|TestCall|TestCase|mixed + * @return HigherOrderTapProxy|Expectable|TestCall|TestCase|mixed */ function beforeEach(Closure $closure = null): BeforeEachCall { @@ -67,6 +77,22 @@ if (! function_exists('dataset')) { } } +if (! function_exists('describe')) { + /** + * Adds the given closure as a group of tests. The first argument + * is the group description; the second argument is a closure + * that contains the group tests. + * + * @return HigherOrderTapProxy|Expectable|TestCall|TestCase|mixed + */ + function describe(string $description, Closure $tests): DescribeCall + { + $filename = Backtrace::testFile(); + + return new DescribeCall(TestSuite::getInstance(), $filename, $description, $tests); + } +} + if (! function_exists('uses')) { /** * The uses function binds the given @@ -88,7 +114,7 @@ if (! function_exists('test')) { * is the test description; the second argument is * a closure that contains the test expectations. * - * @return TestCall|TestCase|mixed + * @return Expectable|TestCall|TestCase|mixed */ function test(string $description = null, Closure $closure = null): HigherOrderTapProxy|TestCall { @@ -108,7 +134,7 @@ if (! function_exists('it')) { * is the test description; the second argument is * a closure that contains the test expectations. * - * @return TestCall|TestCase|mixed + * @return Expectable|TestCall|TestCase|mixed */ function it(string $description, Closure $closure = null): TestCall { @@ -127,7 +153,7 @@ if (! function_exists('todo')) { * is marked as incomplete. Yet, Collision, Pest's * printer, will display it as a "todo" test. * - * @return TestCall|TestCase|mixed + * @return Expectable|TestCall|TestCase|mixed */ function todo(string $description): TestCall { @@ -143,7 +169,7 @@ if (! function_exists('afterEach')) { /** * Runs the given closure after each test in the current file. * - * @return HigherOrderTapProxy|TestCall|mixed + * @return Expectable|HigherOrderTapProxy|TestCall|mixed */ function afterEach(Closure $closure = null): AfterEachCall { @@ -159,6 +185,12 @@ if (! function_exists('afterAll')) { */ function afterAll(Closure $closure): void { + if (! is_null(DescribeCall::describing())) { + $filename = Backtrace::file(); + + throw new AfterAllWithinDescribe($filename); + } + TestSuite::getInstance()->afterAll->set($closure); } } diff --git a/src/Mixins/Expectation.php b/src/Mixins/Expectation.php index 8ab46105..b90e601a 100644 --- a/src/Mixins/Expectation.php +++ b/src/Mixins/Expectation.php @@ -9,17 +9,21 @@ use Closure; use DateTimeInterface; use Error; use InvalidArgumentException; +use JsonSerializable; use Pest\Exceptions\InvalidExpectationValue; use Pest\Matchers\Any; use Pest\Support\Arr; use Pest\Support\Exporter; use Pest\Support\NullClosure; +use Pest\TestSuite; use PHPUnit\Framework\Assert; use PHPUnit\Framework\Constraint\Constraint; use PHPUnit\Framework\ExpectationFailedException; +use PHPUnit\Framework\TestCase; use ReflectionFunction; use ReflectionNamedType; use Throwable; +use Traversable; /** * @internal @@ -794,6 +798,46 @@ final class Expectation return $this; } + /** + * Asserts that the value "stringable" matches the given snapshot.. + * + * @return self + */ + public function toMatchSnapshot(string $message = ''): self + { + $string = match (true) { + is_string($this->value) => $this->value, + is_object($this->value) && method_exists($this->value, '__toString') => $this->value->__toString(), + is_object($this->value) && method_exists($this->value, 'toString') => $this->value->toString(), + $this->value instanceof \Illuminate\Testing\TestResponse => $this->value->getContent(), // @phpstan-ignore-line + is_array($this->value) => json_encode($this->value, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT), + $this->value instanceof Traversable => json_encode(iterator_to_array($this->value), JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT), + $this->value instanceof JsonSerializable => json_encode($this->value->jsonSerialize(), JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT), + is_object($this->value) && method_exists($this->value, 'toArray') => json_encode($this->value->toArray(), JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT), + default => InvalidExpectationValue::expected('array|object|string'), + }; + + $testCase = TestSuite::getInstance()->test; + assert($testCase instanceof TestCase); + $snapshots = TestSuite::getInstance()->snapshots; + + if ($snapshots->has($testCase, $string)) { + [$filename, $content] = $snapshots->get($testCase, $string); + + Assert::assertSame( + $content, + $string, + $message === '' ? "Failed asserting that the string value matches its snapshot ($filename)." : $message + ); + } else { + $filename = $snapshots->save($testCase, $string); + + $testCase::markTestIncomplete('Snapshot created at ['.$filename.'].'); + } + + return $this; + } + /** * Asserts that the value matches a regular expression. * diff --git a/src/PendingCalls/AfterEachCall.php b/src/PendingCalls/AfterEachCall.php index 585fa197..9a22258e 100644 --- a/src/PendingCalls/AfterEachCall.php +++ b/src/PendingCalls/AfterEachCall.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Pest\PendingCalls; use Closure; +use Pest\PendingCalls\Concerns\Describable; use Pest\Support\Backtrace; use Pest\Support\ChainableClosure; use Pest\Support\HigherOrderMessageCollection; @@ -16,6 +17,8 @@ use Pest\TestSuite; */ final class AfterEachCall { + use Describable; + /** * The "afterEach" closure. */ @@ -37,6 +40,8 @@ final class AfterEachCall $this->closure = $closure instanceof Closure ? $closure : NullClosure::create(); $this->proxies = new HigherOrderMessageCollection(); + + $this->describing = DescribeCall::describing(); } /** @@ -44,14 +49,23 @@ final class AfterEachCall */ public function __destruct() { + $describing = $this->describing; + $proxies = $this->proxies; + $afterEachTestCase = ChainableClosure::boundWhen( + fn (): bool => is_null($describing) || $this->__describing === $describing, // @phpstan-ignore-line + ChainableClosure::bound(fn () => $proxies->chain($this), $this->closure)->bindTo($this, self::class), // @phpstan-ignore-line + )->bindTo($this, self::class); + + assert($afterEachTestCase instanceof Closure); + $this->testSuite->afterEach->set( $this->filename, - ChainableClosure::from(function () use ($proxies): void { - $proxies->chain($this); - }, $this->closure) + $this, + $afterEachTestCase, ); + } /** diff --git a/src/PendingCalls/BeforeEachCall.php b/src/PendingCalls/BeforeEachCall.php index 2a2fb5f8..200c4fa9 100644 --- a/src/PendingCalls/BeforeEachCall.php +++ b/src/PendingCalls/BeforeEachCall.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Pest\PendingCalls; use Closure; +use Pest\PendingCalls\Concerns\Describable; use Pest\Support\Backtrace; use Pest\Support\ChainableClosure; use Pest\Support\HigherOrderMessageCollection; @@ -16,6 +17,8 @@ use Pest\TestSuite; */ final class BeforeEachCall { + use Describable; + /** * Holds the before each closure. */ @@ -35,7 +38,7 @@ final class BeforeEachCall * Creates a new Pending Call. */ public function __construct( - private readonly TestSuite $testSuite, + public readonly TestSuite $testSuite, private readonly string $filename, Closure $closure = null ) { @@ -43,6 +46,8 @@ final class BeforeEachCall $this->testCallProxies = new HigherOrderMessageCollection(); $this->testCaseProxies = new HigherOrderMessageCollection(); + + $this->describing = DescribeCall::describing(); } /** @@ -50,16 +55,31 @@ final class BeforeEachCall */ public function __destruct() { + $describing = $this->describing; $testCaseProxies = $this->testCaseProxies; + $beforeEachTestCall = function (TestCall $testCall) use ($describing): void { + if ($describing !== $this->describing) { + return; + } + if ($describing !== $testCall->describing) { + return; + } + $this->testCallProxies->chain($testCall); + }; + + $beforeEachTestCase = ChainableClosure::boundWhen( + fn (): bool => is_null($describing) || $this->__describing === $describing, // @phpstan-ignore-line + ChainableClosure::bound(fn () => $testCaseProxies->chain($this), $this->closure)->bindTo($this, self::class), // @phpstan-ignore-line + )->bindTo($this, self::class); + + assert($beforeEachTestCase instanceof Closure); + $this->testSuite->beforeEach->set( $this->filename, - function (TestCall $testCall): void { - $this->testCallProxies->chain($testCall); - }, - ChainableClosure::from(function () use ($testCaseProxies): void { - $testCaseProxies->chain($this); - }, $this->closure), + $this, + $beforeEachTestCall, + $beforeEachTestCase, ); } diff --git a/src/PendingCalls/Concerns/Describable.php b/src/PendingCalls/Concerns/Describable.php new file mode 100644 index 00000000..bb013681 --- /dev/null +++ b/src/PendingCalls/Concerns/Describable.php @@ -0,0 +1,13 @@ +description; + + try { + ($this->tests)(); + } finally { + self::$describing = null; + } + } + + /** + * Dynamically calls methods on each test call. + * + * @param array $arguments + */ + public function __call(string $name, array $arguments): BeforeEachCall + { + $filename = Backtrace::file(); + + $beforeEachCall = new BeforeEachCall(TestSuite::getInstance(), $filename); + + $beforeEachCall->describing = $this->description; + + return $beforeEachCall->{$name}(...$arguments); // @phpstan-ignore-line + } +} diff --git a/src/PendingCalls/TestCall.php b/src/PendingCalls/TestCall.php index 99e45291..74d88377 100644 --- a/src/PendingCalls/TestCall.php +++ b/src/PendingCalls/TestCall.php @@ -10,6 +10,7 @@ use Pest\Factories\Covers\CoversClass; use Pest\Factories\Covers\CoversFunction; use Pest\Factories\Covers\CoversNothing; use Pest\Factories\TestCaseMethodFactory; +use Pest\PendingCalls\Concerns\Describable; use Pest\Plugins\Only; use Pest\Support\Backtrace; use Pest\Support\Exporter; @@ -25,10 +26,12 @@ use PHPUnit\Framework\TestCase; */ final class TestCall { + use Describable; + /** * The Test Case Factory. */ - private readonly TestCaseMethodFactory $testCaseMethod; + public readonly TestCaseMethodFactory $testCaseMethod; /** * If test call is descriptionLess. @@ -48,7 +51,9 @@ final class TestCall $this->descriptionLess = $description === null; - $this->testSuite->beforeEach->get($filename)[0]($this); + $this->describing = DescribeCall::describing(); + + $this->testSuite->beforeEach->get($this->filename)[0]($this); } /** @@ -316,12 +321,14 @@ final class TestCall private function addChain(string $file, int $line, string $name, array $arguments = null): self { $exporter = Exporter::default(); + $this->testCaseMethod ->chains ->add($file, $line, $name, $arguments); if ($this->descriptionLess) { Exporter::default(); + if ($this->testCaseMethod->description !== null) { $this->testCaseMethod->description .= ' → '; } @@ -338,6 +345,11 @@ final class TestCall */ public function __destruct() { + if (! is_null($this->describing)) { + $this->testCaseMethod->describing = $this->describing; + $this->testCaseMethod->description = sprintf('`%s` → %s', $this->describing, $this->testCaseMethod->description); + } + $this->testSuite->tests->set($this->testCaseMethod); } } diff --git a/src/PendingCalls/UsesCall.php b/src/PendingCalls/UsesCall.php index eabd5de9..2cd33efb 100644 --- a/src/PendingCalls/UsesCall.php +++ b/src/PendingCalls/UsesCall.php @@ -66,11 +66,11 @@ final class UsesCall */ public function in(string ...$targets): void { - $targets = array_map(function ($path): string { + $targets = array_map(function (string $path): string { $startChar = DIRECTORY_SEPARATOR; if ('\\' === DIRECTORY_SEPARATOR || preg_match('~\A[A-Z]:(?![^/\\\\])~i', $path) > 0) { - $path = (string) preg_replace_callback('~^(?P[a-z]+:\\\)~i', fn ($match): string => strtolower($match['drive']), $path); + $path = (string) preg_replace_callback('~^(?P[a-z]+:\\\)~i', fn (array $match): string => strtolower($match['drive']), $path); $startChar = strtolower((string) preg_replace('~^([a-z]+:\\\).*$~i', '$1', __DIR__)); } diff --git a/src/Plugins/Concerns/HandleArguments.php b/src/Plugins/Concerns/HandleArguments.php index 4734a045..13bfbbbc 100644 --- a/src/Plugins/Concerns/HandleArguments.php +++ b/src/Plugins/Concerns/HandleArguments.php @@ -44,6 +44,6 @@ trait HandleArguments unset($arguments[$argument]); - return array_flip($arguments); + return array_values(array_flip($arguments)); } } diff --git a/src/Plugins/Coverage.php b/src/Plugins/Coverage.php index 07b21232..668ff09f 100644 --- a/src/Plugins/Coverage.php +++ b/src/Plugins/Coverage.php @@ -50,7 +50,7 @@ final class Coverage implements AddsOutput, HandlesArguments */ public function handleArguments(array $originals): array { - $arguments = [...[''], ...array_values(array_filter($originals, function ($original): bool { + $arguments = [...[''], ...array_values(array_filter($originals, function (string $original): bool { foreach ([self::COVERAGE_OPTION, self::MIN_OPTION] as $option) { if ($original === sprintf('--%s', $option)) { return true; diff --git a/src/Plugins/Help.php b/src/Plugins/Help.php index cbbb13a4..b0643461 100644 --- a/src/Plugins/Help.php +++ b/src/Plugins/Help.php @@ -107,6 +107,10 @@ final class Help implements HandlesArguments 'arg' => '--parallel', 'desc' => 'Run tests in parallel', ], + [ + 'arg' => '--update-snapshots', + 'desc' => 'Update snapshots for tests using the "toMatchSnapshot" expectation', + ], ], ...$content['Execution']]; $content['Selection'] = [[ diff --git a/src/Plugins/Parallel.php b/src/Plugins/Parallel.php index 1e610c5f..3354b743 100644 --- a/src/Plugins/Parallel.php +++ b/src/Plugins/Parallel.php @@ -115,13 +115,13 @@ final class Parallel implements HandlesArguments private function runTestSuiteInParallel(array $arguments): int { $handlers = array_filter( - array_map(fn ($handler): object|string => Container::getInstance()->get($handler), self::HANDLERS), - fn ($handler): bool => $handler instanceof HandlesArguments, + array_map(fn (string $handler): object|string => Container::getInstance()->get($handler), self::HANDLERS), + fn (object|string $handler): bool => $handler instanceof HandlesArguments, ); $filteredArguments = array_reduce( $handlers, - fn ($arguments, HandlesArguments $handler): array => $handler->handleArguments($arguments), + fn (array $arguments, HandlesArguments $handler): array => $handler->handleArguments($arguments), $arguments ); @@ -139,13 +139,13 @@ final class Parallel implements HandlesArguments private function runWorkerHandlers(array $arguments): array { $handlers = array_filter( - array_map(fn ($handler): object|string => Container::getInstance()->get($handler), self::HANDLERS), - fn ($handler): bool => $handler instanceof HandlersWorkerArguments, + array_map(fn (string $handler): object|string => Container::getInstance()->get($handler), self::HANDLERS), + fn (object|string $handler): bool => $handler instanceof HandlersWorkerArguments, ); return array_reduce( $handlers, - fn ($arguments, HandlersWorkerArguments $handler): array => $handler->handleWorkerArguments($arguments), + fn (array $arguments, HandlersWorkerArguments $handler): array => $handler->handleWorkerArguments($arguments), $arguments ); } diff --git a/src/Plugins/Parallel/Handlers/Parallel.php b/src/Plugins/Parallel/Handlers/Parallel.php index 2e87a521..76a59af6 100644 --- a/src/Plugins/Parallel/Handlers/Parallel.php +++ b/src/Plugins/Parallel/Handlers/Parallel.php @@ -30,7 +30,7 @@ final class Parallel implements HandlesArguments */ public function handleArguments(array $arguments): array { - $args = array_reduce(self::ARGS_TO_REMOVE, fn ($args, $arg): array => $this->popArgument($arg, $args), $arguments); + $args = array_reduce(self::ARGS_TO_REMOVE, fn (array $args, string $arg): array => $this->popArgument($arg, $args), $arguments); return $this->pushArgument('--runner='.WrapperRunner::class, $args); } diff --git a/src/Plugins/Parallel/Paratest/WrapperRunner.php b/src/Plugins/Parallel/Paratest/WrapperRunner.php index c45d6a5a..2a1db6bb 100644 --- a/src/Plugins/Parallel/Paratest/WrapperRunner.php +++ b/src/Plugins/Parallel/Paratest/WrapperRunner.php @@ -23,6 +23,7 @@ use ParaTest\WrapperRunner\WrapperWorker; use Pest\Result; use Pest\TestSuite; use PHPUnit\Event\Facade as EventFacade; +use PHPUnit\Event\TestRunner\WarningTriggered; use PHPUnit\Runner\CodeCoverage; use PHPUnit\TestRunner\TestResult\Facade as TestResultFacade; use PHPUnit\TestRunner\TestResult\TestResult; @@ -317,7 +318,10 @@ final class WrapperRunner implements RunnerInterface $testResultSum->testTriggeredPhpunitErrorEvents(), $testResultSum->testTriggeredPhpunitWarningEvents(), $testResultSum->testRunnerTriggeredDeprecationEvents(), - array_values(array_filter($testResultSum->testRunnerTriggeredWarningEvents(), fn ($event): bool => ! str_contains($event->message(), 'No tests found'))), + array_values(array_filter( + $testResultSum->testRunnerTriggeredWarningEvents(), + fn (WarningTriggered $event): bool => ! str_contains($event->message(), 'No tests found') + )), ); $this->printer->printResults( diff --git a/src/Plugins/Snapshot.php b/src/Plugins/Snapshot.php new file mode 100644 index 00000000..717512c2 --- /dev/null +++ b/src/Plugins/Snapshot.php @@ -0,0 +1,35 @@ +hasArgument('--update-snapshots', $arguments)) { + return $arguments; + } + + if ($this->hasArgument('--parallel', $arguments)) { + throw new InvalidOption('The [--update-snapshots] option is not supported when running in parallel.'); + } + + TestSuite::getInstance()->snapshots->flush(); + + return $this->popArgument('--update-snapshots', $arguments); + } +} diff --git a/src/Repositories/AfterEachRepository.php b/src/Repositories/AfterEachRepository.php index affc4493..ef88f375 100644 --- a/src/Repositories/AfterEachRepository.php +++ b/src/Repositories/AfterEachRepository.php @@ -6,7 +6,7 @@ namespace Pest\Repositories; use Closure; use Mockery; -use Pest\Exceptions\AfterEachAlreadyExist; +use Pest\PendingCalls\AfterEachCall; use Pest\Support\ChainableClosure; use Pest\Support\NullClosure; @@ -23,13 +23,18 @@ final class AfterEachRepository /** * Sets a after each closure. */ - public function set(string $filename, Closure $closure): void + public function set(string $filename, AfterEachCall $afterEachCall, Closure $afterEachTestCase): void { if (array_key_exists($filename, $this->state)) { - throw new AfterEachAlreadyExist($filename); + $fromAfterEachTestCase = $this->state[$filename]; + + $afterEachTestCase = ChainableClosure::bound($fromAfterEachTestCase, $afterEachTestCase) + ->bindTo($afterEachCall, $afterEachCall::class); } - $this->state[$filename] = $closure; + assert($afterEachTestCase instanceof Closure); + + $this->state[$filename] = $afterEachTestCase; } /** @@ -39,7 +44,7 @@ final class AfterEachRepository { $afterEach = $this->state[$filename] ?? NullClosure::create(); - return ChainableClosure::from(function (): void { + return ChainableClosure::bound(function (): void { if (class_exists(Mockery::class)) { if ($container = Mockery::getContainer()) { /* @phpstan-ignore-next-line */ diff --git a/src/Repositories/BeforeAllRepository.php b/src/Repositories/BeforeAllRepository.php index 26e18c5f..51736b41 100644 --- a/src/Repositories/BeforeAllRepository.php +++ b/src/Repositories/BeforeAllRepository.php @@ -5,7 +5,7 @@ declare(strict_types=1); namespace Pest\Repositories; use Closure; -use Pest\Exceptions\BeforeEachAlreadyExist; +use Pest\Exceptions\BeforeAllAlreadyExist; use Pest\Support\NullClosure; use Pest\Support\Reflection; @@ -39,7 +39,7 @@ final class BeforeAllRepository $filename = Reflection::getFileNameFromClosure($closure); if (array_key_exists($filename, $this->state)) { - throw new BeforeEachAlreadyExist($filename); + throw new BeforeAllAlreadyExist($filename); } $this->state[$filename] = $closure; diff --git a/src/Repositories/BeforeEachRepository.php b/src/Repositories/BeforeEachRepository.php index e74f78ed..5f6072e7 100644 --- a/src/Repositories/BeforeEachRepository.php +++ b/src/Repositories/BeforeEachRepository.php @@ -5,7 +5,8 @@ declare(strict_types=1); namespace Pest\Repositories; use Closure; -use Pest\Exceptions\BeforeEachAlreadyExist; +use Pest\PendingCalls\BeforeEachCall; +use Pest\Support\ChainableClosure; use Pest\Support\NullClosure; /** @@ -21,10 +22,14 @@ final class BeforeEachRepository /** * Sets a before each closure. */ - public function set(string $filename, Closure $beforeEachTestCall, Closure $beforeEachTestCase): void + public function set(string $filename, BeforeEachCall $beforeEachCall, Closure $beforeEachTestCall, Closure $beforeEachTestCase): void { if (array_key_exists($filename, $this->state)) { - throw new BeforeEachAlreadyExist($filename); + [$fromBeforeEachTestCall, $fromBeforeEachTestCase] = $this->state[$filename]; + + $beforeEachTestCall = ChainableClosure::unbound($fromBeforeEachTestCall, $beforeEachTestCall); + $beforeEachTestCase = ChainableClosure::bound($fromBeforeEachTestCase, $beforeEachTestCase)->bindTo($beforeEachCall, $beforeEachCall::class); + assert($beforeEachTestCase instanceof Closure); } $this->state[$filename] = [$beforeEachTestCall, $beforeEachTestCase]; diff --git a/src/Repositories/DatasetsRepository.php b/src/Repositories/DatasetsRepository.php index ead6b2b9..a43f1a84 100644 --- a/src/Repositories/DatasetsRepository.php +++ b/src/Repositories/DatasetsRepository.php @@ -66,11 +66,11 @@ final class DatasetsRepository } /** - * @return Closure|array|never + * @return Closure|array * * @throws ShouldNotHappen */ - public static function get(string $filename, string $description) + public static function get(string $filename, string $description): Closure|array { $dataset = self::$withs[$filename.self::SEPARATOR.$description]; @@ -138,7 +138,7 @@ final class DatasetsRepository /** * @param array|string> $datasets - * @return array> + * @return array> */ private static function processDatasets(array $datasets, string $currentTestFile): array { @@ -193,7 +193,7 @@ final class DatasetsRepository $closestScopeDatasetKey = array_reduce( array_keys($matchingDatasets), - fn ($keyA, $keyB) => $keyA !== null && strlen((string) $keyA) > strlen($keyB) ? $keyA : $keyB + fn (string|int|null $keyA, string|int|null $keyB): string|int|null => $keyA !== null && strlen((string) $keyA) > strlen((string) $keyB) ? $keyA : $keyB ); if ($closestScopeDatasetKey === null) { diff --git a/src/Repositories/SnapshotRepository.php b/src/Repositories/SnapshotRepository.php new file mode 100644 index 00000000..89a4135e --- /dev/null +++ b/src/Repositories/SnapshotRepository.php @@ -0,0 +1,135 @@ +getFilenameAndDescription($testCase); + + return file_exists($this->getSnapshotFilename($filename, $description)); + } + + /** + * Gets the snapshot. + * + * @return array{0: string, 1: string} + * + * @throws ShouldNotHappen + */ + public function get(TestCase $testCase, string $description): array + { + [$filename, $description] = $this->getFilenameAndDescription($testCase); + + $contents = file_get_contents($snapshotFilename = $this->getSnapshotFilename($filename, $description)); + + if ($contents === false) { + throw ShouldNotHappen::fromMessage('Snapshot file could not be read.'); + } + + $snapshot = str_replace(dirname($this->testsPath).'/', '', $snapshotFilename); + + return [$snapshot, $contents]; + } + + /** + * Saves the given snapshot for the given test case. + */ + public function save(TestCase $testCase, string $snapshot): string + { + [$filename, $description] = $this->getFilenameAndDescription($testCase); + + $snapshotFilename = $this->getSnapshotFilename($filename, $description); + + if (! file_exists(dirname($snapshotFilename))) { + mkdir(dirname($snapshotFilename), 0755, true); + } + + file_put_contents($snapshotFilename, $snapshot); + + return str_replace(dirname($this->testsPath).'/', '', $snapshotFilename); + } + + /** + * Flushes the snapshots. + */ + public function flush(): void + { + $absoluteSnapshotsPath = $this->testsPath.'/'.$this->snapshotsPath; + + $deleteDirectory = function (string $path) use (&$deleteDirectory): void { + if (file_exists($path)) { + $scannedDir = scandir($path); + assert(is_array($scannedDir)); + + $files = array_diff($scannedDir, ['.', '..']); + + foreach ($files as $file) { + if (is_dir($path.'/'.$file)) { + $deleteDirectory($path.'/'.$file); + } else { + unlink($path.'/'.$file); + } + } + + rmdir($path); + } + }; + + if (file_exists($absoluteSnapshotsPath)) { + $deleteDirectory($absoluteSnapshotsPath); + } + } + + /** + * Gets the snapshot's "filename" and "description". + * + * @return array{0: string, 1: string} + */ + private function getFilenameAndDescription(TestCase $testCase): array + { + $filename = (fn () => self::$__filename)->call($testCase, $testCase::class); // @phpstan-ignore-line + + $description = str_replace('__pest_evaluable_', '', $testCase->name()); + $datasetAsString = str_replace('__pest_evaluable_', '', Str::evaluable($testCase->dataSetAsStringWithData())); + + $description = str_replace(' ', '_', $description.$datasetAsString); + + return [$filename, $description]; + } + + /** + * Gets the snapshot's "filename". + */ + private function getSnapshotFilename(string $filename, string $description): string + { + $relativePath = str_replace($this->testsPath, '', $filename); + + // remove extension from filename + $relativePath = substr($relativePath, 0, (int) strrpos($relativePath, '.')); + + return sprintf('%s/%s.snap', $this->testsPath.'/'.$this->snapshotsPath.$relativePath, $description); + } +} diff --git a/src/Support/Backtrace.php b/src/Support/Backtrace.php index 8f683ff7..729bf08d 100644 --- a/src/Support/Backtrace.php +++ b/src/Support/Backtrace.php @@ -78,9 +78,7 @@ final class Backtrace */ public static function file(): string { - $trace = debug_backtrace(self::BACKTRACE_OPTIONS)[1]; - - assert(array_key_exists(self::FILE, $trace)); + $trace = self::backtrace(); return $trace[self::FILE]; } @@ -90,9 +88,7 @@ final class Backtrace */ public static function dirname(): string { - $trace = debug_backtrace(self::BACKTRACE_OPTIONS)[1]; - - assert(array_key_exists(self::FILE, $trace)); + $trace = self::backtrace(); return dirname($trace[self::FILE]); } @@ -102,8 +98,30 @@ final class Backtrace */ public static function line(): int { - $trace = debug_backtrace(self::BACKTRACE_OPTIONS)[1]; + $trace = self::backtrace(); return $trace['line'] ?? 0; } + + /** + * @return array{function: string, line?: int, file: string, class?: class-string, type?: string, args?: mixed[], object?: object} + */ + private static function backtrace(): array + { + $backtrace = debug_backtrace(self::BACKTRACE_OPTIONS); + + foreach ($backtrace as $trace) { + if (! isset($trace['file'])) { + continue; + } + + if (str_contains($trace['file'], 'pest/src')) { + continue; + } + + return $trace; + } + + throw ShouldNotHappen::fromMessage('Backtrace not found.'); + } } diff --git a/src/Support/ChainableClosure.php b/src/Support/ChainableClosure.php index 55dcca3a..b012e907 100644 --- a/src/Support/ChainableClosure.php +++ b/src/Support/ChainableClosure.php @@ -13,9 +13,25 @@ use Pest\Exceptions\ShouldNotHappen; final class ChainableClosure { /** - * Calls the given `$closure` and chains the `$next` closure. + * Calls the given `$closure` when the given condition is true, "bound" to the same object. */ - public static function from(Closure $closure, Closure $next): Closure + public static function boundWhen(Closure $condition, Closure $next): Closure + { + return function () use ($condition, $next): void { + if (! is_object($this)) { // @phpstan-ignore-line + throw ShouldNotHappen::fromMessage('$this not bound to chainable closure.'); + } + + if (\Pest\Support\Closure::bind($condition, $this, self::class)(...func_get_args())) { + \Pest\Support\Closure::bind($next, $this, self::class)(...func_get_args()); + } + }; + } + + /** + * Calls the given `$closure` and chains the `$next` closure, "bound" to the same object. + */ + public static function bound(Closure $closure, Closure $next): Closure { return function () use ($closure, $next): void { if (! is_object($this)) { // @phpstan-ignore-line @@ -28,9 +44,20 @@ final class ChainableClosure } /** - * Call the given static `$closure` and chains the `$next` closure. + * Calls the given `$closure` and chains the `$next` closure, "unbound" of any object. */ - public static function fromStatic(Closure $closure, Closure $next): Closure + public static function unbound(Closure $closure, Closure $next): Closure + { + return function () use ($closure, $next): void { + $closure(...func_get_args()); + $next(...func_get_args()); + }; + } + + /** + * Call the given static `$closure` and chains the `$next` closure, "bound" to the same object statically. + */ + public static function boundStatically(Closure $closure, Closure $next): Closure { return static function () use ($closure, $next): void { \Pest\Support\Closure::bind($closure, null, self::class)(...func_get_args()); diff --git a/src/Support/Coverage.php b/src/Support/Coverage.php index 526b15fa..eccff9d3 100644 --- a/src/Support/Coverage.php +++ b/src/Support/Coverage.php @@ -159,10 +159,11 @@ final class Coverage * ['11', '20..25', '50', '60..80']; * ``` * + * * @param File $file * @return array */ - public static function getMissingCoverage($file): array + public static function getMissingCoverage(mixed $file): array { $shouldBeNewLine = true; diff --git a/src/Support/ExceptionTrace.php b/src/Support/ExceptionTrace.php index 8d57f1db..0f6dc10b 100644 --- a/src/Support/ExceptionTrace.php +++ b/src/Support/ExceptionTrace.php @@ -18,11 +18,9 @@ final class ExceptionTrace /** * Ensures the given closure reports the good execution context. * - * @return mixed - * * @throws Throwable */ - public static function ensure(Closure $closure) + public static function ensure(Closure $closure): mixed { try { return $closure(); diff --git a/src/Support/ExpectationPipeline.php b/src/Support/ExpectationPipeline.php index 1df555f8..c9d3f6ed 100644 --- a/src/Support/ExpectationPipeline.php +++ b/src/Support/ExpectationPipeline.php @@ -84,6 +84,6 @@ final class ExpectationPipeline */ public function carry(): Closure { - return fn ($stack, $pipe): Closure => fn () => $pipe($stack, ...$this->passables); + return fn (mixed $stack, callable $pipe): Closure => fn () => $pipe($stack, ...$this->passables); } } diff --git a/src/Support/Reflection.php b/src/Support/Reflection.php index 18a71aa6..3a6f2377 100644 --- a/src/Support/Reflection.php +++ b/src/Support/Reflection.php @@ -24,9 +24,8 @@ final class Reflection * Calls the given method with args on the given object. * * @param array $args - * @return mixed */ - public static function call(object $object, string $method, array $args = []) + public static function call(object $object, string $method, array $args = []): mixed { $reflectionClass = new ReflectionClass($object); @@ -53,9 +52,8 @@ final class Reflection * Bind a callable to the TestCase and return the result. * * @param array $args - * @return mixed */ - public static function bindCallable(callable $callable, array $args = []) + public static function bindCallable(callable $callable, array $args = []): mixed { return Closure::fromCallable($callable)->bindTo(TestSuite::getInstance()->test)(...$args); } @@ -63,10 +61,8 @@ final class Reflection /** * Bind a callable to the TestCase and return the result, * passing in the current dataset values as arguments. - * - * @return mixed */ - public static function bindCallableWithData(callable $callable) + public static function bindCallableWithData(callable $callable): mixed { $test = TestSuite::getInstance()->test; @@ -87,10 +83,8 @@ final class Reflection /** * Gets the property value from of the given object. - * - * @return mixed */ - public static function getPropertyValue(object $object, string $property) + public static function getPropertyValue(object $object, string $property): mixed { $reflectionClass = new ReflectionClass($object); @@ -206,10 +200,7 @@ final class Reflection return $arguments; } - /** - * @return mixed - */ - public static function getFunctionVariable(Closure $function, string $key) + public static function getFunctionVariable(Closure $function, string $key): mixed { return (new ReflectionFunction($function))->getStaticVariables()[$key] ?? null; } diff --git a/src/TestCaseFilters/GitDirtyTestCaseFilter.php b/src/TestCaseFilters/GitDirtyTestCaseFilter.php index 88ef4985..a4620a91 100644 --- a/src/TestCaseFilters/GitDirtyTestCaseFilter.php +++ b/src/TestCaseFilters/GitDirtyTestCaseFilter.php @@ -59,13 +59,18 @@ final class GitDirtyTestCaseFilter implements TestCaseFilter $dirtyFiles[substr($dirtyFile, 3)] = trim(substr($dirtyFile, 0, 3)); } - $dirtyFiles = array_filter($dirtyFiles, fn ($status): bool => $status !== 'D'); + $dirtyFiles = array_filter($dirtyFiles, fn (string $status): bool => $status !== 'D'); - $dirtyFiles = array_map(fn ($file, $status): string => in_array($status, ['R', 'RM'], true) ? explode(' -> ', $file)[1] : $file, array_keys($dirtyFiles), $dirtyFiles); + $dirtyFiles = array_map( + fn (string $file, string $status): string => in_array($status, ['R', 'RM'], true) + ? explode(' -> ', $file)[1] + : $file, array_keys($dirtyFiles), $dirtyFiles, + ); $dirtyFiles = array_filter( $dirtyFiles, - fn ($file): bool => str_starts_with('.'.DIRECTORY_SEPARATOR.$file, TestSuite::getInstance()->testPath) || str_starts_with($file, TestSuite::getInstance()->testPath) + fn (string $file): bool => str_starts_with('.'.DIRECTORY_SEPARATOR.$file, TestSuite::getInstance()->testPath) + || str_starts_with($file, TestSuite::getInstance()->testPath) ); $dirtyFiles = array_values($dirtyFiles); diff --git a/src/TestSuite.php b/src/TestSuite.php index 1c4297f6..0baf4586 100644 --- a/src/TestSuite.php +++ b/src/TestSuite.php @@ -9,6 +9,7 @@ use Pest\Repositories\AfterAllRepository; use Pest\Repositories\AfterEachRepository; use Pest\Repositories\BeforeAllRepository; use Pest\Repositories\BeforeEachRepository; +use Pest\Repositories\SnapshotRepository; use Pest\Repositories\TestRepository; use PHPUnit\Framework\TestCase; @@ -47,6 +48,11 @@ final class TestSuite */ public AfterAllRepository $afterAll; + /** + * Holds the snapshots repository. + */ + public SnapshotRepository $snapshots; + /** * Holds the root path. */ @@ -69,8 +75,9 @@ final class TestSuite $this->tests = new TestRepository(); $this->afterEach = new AfterEachRepository(); $this->afterAll = new AfterAllRepository(); - $this->rootPath = (string) realpath($rootPath); + + $this->snapshots = new SnapshotRepository($this->rootPath.'/'.$this->testPath, '.pest/snapshots'); } /** diff --git a/tests/.pest/snapshots/Features/Expect/toMatchSnapshot/_within_describe__→_pass_with_dataset_with_data_set____my_datas_set_value______my_datas_set_value__.snap b/tests/.pest/snapshots/Features/Expect/toMatchSnapshot/_within_describe__→_pass_with_dataset_with_data_set____my_datas_set_value______my_datas_set_value__.snap new file mode 100644 index 00000000..c2b4dc0a --- /dev/null +++ b/tests/.pest/snapshots/Features/Expect/toMatchSnapshot/_within_describe__→_pass_with_dataset_with_data_set____my_datas_set_value______my_datas_set_value__.snap @@ -0,0 +1,7 @@ +
+
+
+

Snapshot

+
+
+
\ No newline at end of file diff --git a/tests/.pest/snapshots/Features/Expect/toMatchSnapshot/failures.snap b/tests/.pest/snapshots/Features/Expect/toMatchSnapshot/failures.snap new file mode 100644 index 00000000..c2b4dc0a --- /dev/null +++ b/tests/.pest/snapshots/Features/Expect/toMatchSnapshot/failures.snap @@ -0,0 +1,7 @@ +
+
+
+

Snapshot

+
+
+
\ No newline at end of file diff --git a/tests/.pest/snapshots/Features/Expect/toMatchSnapshot/failures_with_custom_message.snap b/tests/.pest/snapshots/Features/Expect/toMatchSnapshot/failures_with_custom_message.snap new file mode 100644 index 00000000..c2b4dc0a --- /dev/null +++ b/tests/.pest/snapshots/Features/Expect/toMatchSnapshot/failures_with_custom_message.snap @@ -0,0 +1,7 @@ +
+
+
+

Snapshot

+
+
+
\ No newline at end of file diff --git a/tests/.pest/snapshots/Features/Expect/toMatchSnapshot/not_failures.snap b/tests/.pest/snapshots/Features/Expect/toMatchSnapshot/not_failures.snap new file mode 100644 index 00000000..c2b4dc0a --- /dev/null +++ b/tests/.pest/snapshots/Features/Expect/toMatchSnapshot/not_failures.snap @@ -0,0 +1,7 @@ +
+
+
+

Snapshot

+
+
+
\ No newline at end of file diff --git a/tests/.pest/snapshots/Features/Expect/toMatchSnapshot/pass.snap b/tests/.pest/snapshots/Features/Expect/toMatchSnapshot/pass.snap new file mode 100644 index 00000000..c2b4dc0a --- /dev/null +++ b/tests/.pest/snapshots/Features/Expect/toMatchSnapshot/pass.snap @@ -0,0 +1,7 @@ +
+
+
+

Snapshot

+
+
+
\ No newline at end of file diff --git a/tests/.pest/snapshots/Features/Expect/toMatchSnapshot/pass_with______toString_.snap b/tests/.pest/snapshots/Features/Expect/toMatchSnapshot/pass_with______toString_.snap new file mode 100644 index 00000000..c2b4dc0a --- /dev/null +++ b/tests/.pest/snapshots/Features/Expect/toMatchSnapshot/pass_with______toString_.snap @@ -0,0 +1,7 @@ +
+
+
+

Snapshot

+
+
+
\ No newline at end of file diff --git a/tests/.pest/snapshots/Features/Expect/toMatchSnapshot/pass_with__toArray_.snap b/tests/.pest/snapshots/Features/Expect/toMatchSnapshot/pass_with__toArray_.snap new file mode 100644 index 00000000..afd4f5f9 --- /dev/null +++ b/tests/.pest/snapshots/Features/Expect/toMatchSnapshot/pass_with__toArray_.snap @@ -0,0 +1,3 @@ +{ + "key": "
\n
\n
\n

Snapshot<\/h1>\n <\/div>\n <\/div>\n <\/div>" +} \ No newline at end of file diff --git a/tests/.pest/snapshots/Features/Expect/toMatchSnapshot/pass_with__toString_.snap b/tests/.pest/snapshots/Features/Expect/toMatchSnapshot/pass_with__toString_.snap new file mode 100644 index 00000000..c2b4dc0a --- /dev/null +++ b/tests/.pest/snapshots/Features/Expect/toMatchSnapshot/pass_with__toString_.snap @@ -0,0 +1,7 @@ +
+
+
+

Snapshot

+
+
+
\ No newline at end of file diff --git a/tests/.pest/snapshots/Features/Expect/toMatchSnapshot/pass_with_array.snap b/tests/.pest/snapshots/Features/Expect/toMatchSnapshot/pass_with_array.snap new file mode 100644 index 00000000..afd4f5f9 --- /dev/null +++ b/tests/.pest/snapshots/Features/Expect/toMatchSnapshot/pass_with_array.snap @@ -0,0 +1,3 @@ +{ + "key": "
\n
\n
\n

Snapshot<\/h1>\n <\/div>\n <\/div>\n <\/div>" +} \ No newline at end of file diff --git a/tests/.pest/snapshots/Features/Expect/toMatchSnapshot/pass_with_dataset_with_data_set____my_datas_set_value______my_datas_set_value__.snap b/tests/.pest/snapshots/Features/Expect/toMatchSnapshot/pass_with_dataset_with_data_set____my_datas_set_value______my_datas_set_value__.snap new file mode 100644 index 00000000..c2b4dc0a --- /dev/null +++ b/tests/.pest/snapshots/Features/Expect/toMatchSnapshot/pass_with_dataset_with_data_set____my_datas_set_value______my_datas_set_value__.snap @@ -0,0 +1,7 @@ +
+
+
+

Snapshot

+
+
+
\ No newline at end of file diff --git a/tests/.snapshots/collision.txt b/tests/.pest/snapshots/Visual/Collision/collision_with_data_set___________array_____.snap similarity index 100% rename from tests/.snapshots/collision.txt rename to tests/.pest/snapshots/Visual/Collision/collision_with_data_set___________array_____.snap diff --git a/tests/.snapshots/collision-parallel.txt b/tests/.pest/snapshots/Visual/Collision/collision_with_data_set_______parallel______array____parallel___.snap similarity index 100% rename from tests/.snapshots/collision-parallel.txt rename to tests/.pest/snapshots/Visual/Collision/collision_with_data_set_______parallel______array____parallel___.snap diff --git a/tests/.snapshots/help-command.txt b/tests/.pest/snapshots/Visual/Help/visual_snapshot_of_help_command_output.snap similarity index 98% rename from tests/.snapshots/help-command.txt rename to tests/.pest/snapshots/Visual/Help/visual_snapshot_of_help_command_output.snap index 48b91214..06c328e1 100644 --- a/tests/.snapshots/help-command.txt +++ b/tests/.pest/snapshots/Visual/Help/visual_snapshot_of_help_command_output.snap @@ -34,6 +34,7 @@ EXECUTION OPTIONS: --parallel ........................................... Run tests in parallel + --update-snapshots Update snapshots for tests using the "toMatchSnapshot" expectation --process-isolation ................ Run each test in a separate PHP process --globals-backup ................. Backup and restore $GLOBALS for each test --static-backup ......... Backup and restore static properties for each test diff --git a/tests/.snapshots/version-command.txt b/tests/.pest/snapshots/Visual/Version/visual_snapshot_of_help_command_output.snap similarity index 100% rename from tests/.snapshots/version-command.txt rename to tests/.pest/snapshots/Visual/Version/visual_snapshot_of_help_command_output.snap diff --git a/tests/.snapshots/coverage.txt b/tests/.snapshots/coverage.txt deleted file mode 100644 index e7cf7549..00000000 --- a/tests/.snapshots/coverage.txt +++ /dev/null @@ -1,60 +0,0 @@ - - PASS Tests\Playground - ✓ basic - - Tests: 1 passed - Time: 0.20s - Cov: 6.49% - - Actions/AddsDefaults ........................................... 0.0 % - Actions/AddsTests .............................................. 0.0 % - Actions/LoadStructure .......................................... 0.0 % - Actions/ValidatesConfiguration ................................. 0.0 % - Actions/ValidatesEnvironment ................................... 0.0 % - Concerns/TestCase 40..54, 71..88, 123..126, 147 ............... 44.4 % - Console/Command ................................................ 0.0 % - Contracts/HasPrintableTestCaseName ............................. 0.0 % - Contracts/Plugins/AddsOutput ................................ 100.0 % - Contracts/Plugins/HandlesArguments .......................... 100.0 % - Datasets ....................................................... 0.0 % - Exceptions/AfterAllAlreadyExist ................................ 0.0 % - Exceptions/AfterEachAlreadyExist ............................... 0.0 % - Exceptions/AttributeNotSupportedYet ............................ 0.0 % - Exceptions/BeforeEachAlreadyExist .............................. 0.0 % - Exceptions/DatasetAlreadyExist ................................. 0.0 % - Exceptions/DatasetDoesNotExist ................................. 0.0 % - Exceptions/FileOrFolderNotFound ................................ 0.0 % - Exceptions/InvalidConsoleArgument .............................. 0.0 % - Exceptions/InvalidPestCommand .................................. 0.0 % - Exceptions/InvalidUsesPath ..................................... 0.0 % - Exceptions/ShouldNotHappen ..................................... 0.0 % - Exceptions/TestAlreadyExist .................................... 0.0 % - Exceptions/TestCaseAlreadyInUse ................................ 0.0 % - Exceptions/TestCaseClassOrTraitNotFound ........................ 0.0 % - Factories/TestCaseFactory 111..133, 141..204 ................... 8.2 % - Laravel/Commands/PestDatasetCommand ............................ 0.0 % - Laravel/Commands/PestInstallCommand ............................ 0.0 % - Laravel/Commands/PestTestCommand ............................... 0.0 % - Laravel/PestServiceProvider .................................... 0.0 % - PendingObjects/AfterEachCall ................................... 0.0 % - PendingObjects/BeforeEachCall .................................. 0.0 % - PendingObjects/TestCall ........................................ 0.0 % - PendingObjects/UsesCall ........................................ 0.0 % - Plugin ......................................................... 0.0 % - Repositories/AfterAllRepository ................................ 0.0 % - Repositories/AfterEachRepository 28..33 ....................... 60.0 % - Repositories/BeforeAllRepository ............................... 0.0 % - Repositories/BeforeEachRepository 26..31 ...................... 20.0 % - Repositories/TestRepository .................................... 0.0 % - Support/Backtrace .............................................. 0.0 % - Support/ChainableClosure .................................... 100.0 % - Support/Container .............................................. 0.0 % - Support/ExceptionTrace 25..32 ................................. 28.6 % - Support/HigherOrderMessage ..................................... 0.0 % - Support/HigherOrderMessageCollection 24..25, 33, 43 ........... 50.0 % - Support/HigherOrderTapProxy .................................... 0.0 % - Support/NullClosure ......................................... 100.0 % - Support/Reflection ............................................. 0.0 % - Support/Str .................................................... 0.0 % - TestSuite 80..87, 95..101, 105 ................................ 20.0 % - globals ........................................................ 0.0 % diff --git a/tests/.snapshots/success.txt b/tests/.snapshots/success.txt index 80b07807..45efb415 100644 --- a/tests/.snapshots/success.txt +++ b/tests/.snapshots/success.txt @@ -53,6 +53,7 @@ ✓ it appends CoversNothing to method attributes ✓ it does not append CoversNothing to other methods ✓ it throws exception if no class nor method has been found + ✓ a "describe" group of tests → it does not append CoversNothing to method attributes PASS Tests\Features\DatasetsTests - 1 todo ✓ it throws exception if dataset does not exist @@ -172,6 +173,29 @@ ! deprecated → str_contains(): Passing null to parameter #1 ($haystack) of type string is deprecated // tests/Features/Deprecated.php:6 ! user deprecated → Since foo 1.0: This is a deprecation description // vendor/symfony/deprecation-contracts/function.php:25 + PASS Tests\Features\Describe - 5 todos + ✓ before each + ✓ hooks → value + ✓ hooks in different orders → value + ↓ todo + ✓ previous describable before each does not get applied here + ↓ todo on hook → should not fail + ↓ todo on hook → should run + ↓ todo on describe → should not fail + ↓ todo on describe → should run + ✓ should run + ✓ with with (1) + ✓ with on hook → value with (2) + ✓ with on describe → value with (3) + + PASS Tests\Features\DescriptionLess + ✓ get 'foo' + ✓ get 'foo' → get 'bar' → expect true → toBeTrue + ✓ get 'foo' → expect true → toBeTrue + ✓ a "describe" group of tests → get 'foo' + ✓ a "describe" group of tests → get 'foo' → get 'bar' → expect true → toBeTrue + ✓ a "describe" group of tests → get 'foo' → expect true → toBeTrue + PASS Tests\Features\Exceptions ✓ it gives access the the underlying expectException ✓ it catch exceptions @@ -641,6 +665,18 @@ ✓ pass with class ✓ failures ✓ failures with custom message + ✓ not failures + + PASS Tests\Features\Expect\toMatchSnapshot + ✓ pass + ✓ pass with __toString + ✓ pass with toString + ✓ pass with dataset with ('my-datas-set-value') + ✓ within describe → pass with dataset with ('my-datas-set-value') + ✓ pass with toArray + ✓ pass with array + ✓ failures + ✓ failures with custom message ✓ not failures PASS Tests\Features\Expect\toStartWith @@ -714,18 +750,17 @@ ✓ it is not incompleted because of expect ✓ it is not incompleted because of assert ✓ it is not incompleted because of test with assertions + … a "describe" group of tests → it is incompleted PASS Tests\Features\It ✓ it is a test ✓ it is a higher order message test + ✓ a "describe" group of tests → it is a test + ✓ a "describe" group of tests → it is a higher order message test NOTI Tests\Features\Notices ! notice → This is a notice description // tests/Features/Notices.php:4 - - PASS Tests\Features\PendingHigherOrderTests - ✓ get 'foo' - ✓ get 'foo' → get 'bar' → expect true → toBeTrue - ✓ get 'foo' → expect true → toBeTrue + ! a "describe" group of tests → notice → This is a notice description // tests/Features/Notices.php:11 PASS Tests\Features\ScopedDatasets\Directory\NestedDirectory1\TestFileInNestedDirectoryWithDatasetsFile ✓ uses dataset with (1) @@ -787,6 +822,7 @@ PASS Tests\Features\ThrowsNoExceptions ✓ it allows access to the underlying expectNotToPerformAssertions method ✓ it allows performing no expectations without being risky + ✓ a "describe" group of tests → it allows performing no expectations without being risky PASS Tests\Features\Todo - 3 todos ↓ something todo later @@ -797,6 +833,7 @@ WARN Tests\Features\Warnings ! warning → Undefined property: P\Tests\Features\Warnings::$fooqwdfwqdfqw ! user warning → This is a warning description + ! a "describe" group of tests → user warning → This is a warning description WARN Tests\Fixtures\CollisionTest - error @@ -1044,7 +1081,7 @@ ✓ todo ✓ todo in parallel - PASS Tests\Visual\Version - ✓ visual snapshot of help command output + WARN Tests\Visual\Version + - visual snapshot of help command output - Tests: 2 deprecated, 3 warnings, 4 incomplete, 1 notice, 8 todos, 18 skipped, 718 passed (1735 assertions) \ No newline at end of file + Tests: 2 deprecated, 4 warnings, 5 incomplete, 2 notices, 13 todos, 19 skipped, 742 passed (1787 assertions) \ No newline at end of file diff --git a/tests/.snapshots/todo.txt b/tests/.snapshots/todo.txt index 1d506b46..1d3687a5 100644 --- a/tests/.snapshots/todo.txt +++ b/tests/.snapshots/todo.txt @@ -7,6 +7,13 @@ TODO Tests\Features\DatasetsTests - 1 todo ↓ forbids to define tests in Datasets dirs and Datasets.php files + TODO Tests\Features\Describe - 5 todos + ↓ todo + ↓ todo on hook → should not fail + ↓ todo on hook → should run + ↓ todo on describe → should not fail + ↓ todo on describe → should run + TODO Tests\Features\Todo - 3 todos ↓ something todo later ↓ something todo later chained @@ -15,4 +22,4 @@ PASS Tests\CustomTestCase\ExecutedTest ✓ that gets executed - Tests: 8 todos, 1 passed (1 assertions) + Tests: 13 todos, 1 passed (1 assertions) diff --git a/tests/Arch.php b/tests/Arch.php index eb47a06e..a46c37b2 100644 --- a/tests/Arch.php +++ b/tests/Arch.php @@ -30,4 +30,4 @@ test('contracts') 'NunoMaduro\Collision\Contracts', 'Pest\Factories\TestCaseMethodFactory', 'Symfony\Component\Console', - ]); + ])->toBeInterfaces(); diff --git a/tests/Features/AfterEach.php b/tests/Features/AfterEach.php index cdaed1d3..47d1fb60 100644 --- a/tests/Features/AfterEach.php +++ b/tests/Features/AfterEach.php @@ -7,7 +7,11 @@ beforeEach(function () use ($state) { }); afterEach(function () { - $this->state->bar = 2; + $this->state->bar = 1; +}); + +afterEach(function () { + unset($this->state->bar); }); it('does not get executed before the test', function () { @@ -18,3 +22,7 @@ it('gets executed after the test', function () { expect($this->state)->toHaveProperty('bar'); expect($this->state->bar)->toBe(2); }); + +afterEach(function () { + $this->state->bar = 2; +}); diff --git a/tests/Features/BeforeEach.php b/tests/Features/BeforeEach.php index a2e70d61..7ef6144b 100644 --- a/tests/Features/BeforeEach.php +++ b/tests/Features/BeforeEach.php @@ -4,12 +4,24 @@ beforeEach(function () { $this->bar = 2; }); +beforeEach(function () { + $this->bar++; +}); + +beforeEach(function () { + $this->bar = 0; +}); + it('gets executed before each test', function () { - expect($this->bar)->toBe(2); + expect($this->bar)->toBe(1); $this->bar = 'changed'; }); it('gets executed before each test once again', function () { - expect($this->bar)->toBe(2); + expect($this->bar)->toBe(1); +}); + +beforeEach(function () { + $this->bar++; }); diff --git a/tests/Features/Covers.php b/tests/Features/Covers.php index 12ec4ac5..31074e09 100644 --- a/tests/Features/Covers.php +++ b/tests/Features/Covers.php @@ -68,3 +68,11 @@ it('throws exception if no class nor method has been found', function () { $testCall->covers('fakeName'); })->throws(InvalidArgumentException::class, 'No class or method named "fakeName" has been found.'); + +describe('a "describe" group of tests', function () { + it('does not append CoversNothing to method attributes', function () { + $phpDoc = (new ReflectionClass($this))->getMethod($this->name()); + + expect(str_contains($phpDoc->getDocComment(), '* @coversNothing'))->toBeTrue(); + }); +})->coversNothing(); diff --git a/tests/Features/Describe.php b/tests/Features/Describe.php new file mode 100644 index 00000000..6173a4dd --- /dev/null +++ b/tests/Features/Describe.php @@ -0,0 +1,78 @@ + $this->count = 1); + +test('before each', function () { + expect($this->count)->toBe(1); +}); + +describe('hooks', function () { + beforeEach(function () { + $this->count++; + }); + + test('value', function () { + expect($this->count)->toBe(2); + $this->count++; + }); + + afterEach(function () { + expect($this->count)->toBe(3); + }); +}); + +describe('hooks in different orders', function () { + beforeEach(function () { + $this->count++; + }); + + test('value', function () { + expect($this->count)->toBe(3); + $this->count++; + }); + + afterEach(function () { + expect($this->count)->toBe(4); + }); + + beforeEach(function () { + $this->count++; + }); +}); + +test('todo')->todo()->shouldNotRun(); + +test('previous describable before each does not get applied here', function () { + expect($this->count)->toBe(1); +}); + +describe('todo on hook', function () { + beforeEach()->todo(); + + test('should not fail')->shouldNotRun(); + test('should run')->expect(true)->toBeTrue(); +}); + +describe('todo on describe', function () { + test('should not fail')->shouldNotRun(); + + test('should run')->expect(true)->toBeTrue(); +})->todo(); + +test('should run')->expect(true)->toBeTrue(); + +test('with', fn ($foo) => expect($foo)->toBe(1))->with([1]); + +describe('with on hook', function () { + beforeEach()->with([2]); + + test('value', function ($foo) { + expect($foo)->toBe(2); + }); +}); + +describe('with on describe', function () { + test('value', function ($foo) { + expect($foo)->toBe(3); + }); +})->with([3]); diff --git a/tests/Features/PendingHigherOrderTests.php b/tests/Features/DescriptionLess.php similarity index 71% rename from tests/Features/PendingHigherOrderTests.php rename to tests/Features/DescriptionLess.php index a22cf027..971d5cd3 100644 --- a/tests/Features/PendingHigherOrderTests.php +++ b/tests/Features/DescriptionLess.php @@ -29,3 +29,9 @@ trait Gettable get('foo'); // not incomplete because closure is created... get('foo')->get('bar')->expect(true)->toBeTrue(); get('foo')->expect(true)->toBeTrue(); + +describe('a "describe" group of tests', function () { + get('foo'); // not incomplete because closure is created... + get('foo')->get('bar')->expect(true)->toBeTrue(); + get('foo')->expect(true)->toBeTrue(); +}); diff --git a/tests/Features/Expect/toMatchSnapshot.php b/tests/Features/Expect/toMatchSnapshot.php new file mode 100644 index 00000000..29a63a59 --- /dev/null +++ b/tests/Features/Expect/toMatchSnapshot.php @@ -0,0 +1,122 @@ +snapshotable = <<<'HTML' +
+
+
+

Snapshot

+
+
+
+ HTML; +}); + +test('pass', function () { + TestSuite::getInstance()->snapshots->save($this, $this->snapshotable); + + expect($this->snapshotable)->toMatchSnapshot(); +}); + +test('pass with `__toString`', function () { + TestSuite::getInstance()->snapshots->save($this, $this->snapshotable); + + $object = new class($this->snapshotable) + { + public function __construct(protected string $snapshotable) + { + } + + public function __toString() + { + return $this->snapshotable; + } + }; + + expect($object)->toMatchSnapshot()->toMatchSnapshot(); +}); + +test('pass with `toString`', function () { + TestSuite::getInstance()->snapshots->save($this, $this->snapshotable); + + $object = new class($this->snapshotable) + { + public function __construct(protected string $snapshotable) + { + } + + public function toString() + { + return $this->snapshotable; + } + }; + + expect($object)->toMatchSnapshot()->toMatchSnapshot(); +}); + +test('pass with dataset', function ($data) { + TestSuite::getInstance()->snapshots->save($this, $this->snapshotable); + [$filename] = TestSuite::getInstance()->snapshots->get($this, $this->snapshotable); + + expect($filename)->toEndWith('pass_with_dataset_with_data_set____my_datas_set_value______my_datas_set_value__.snap') + ->and($this->snapshotable)->toMatchSnapshot(); +})->with(['my-datas-set-value']); + +describe('within describe', function () { + test('pass with dataset', function ($data) { + TestSuite::getInstance()->snapshots->save($this, $this->snapshotable); + [$filename] = TestSuite::getInstance()->snapshots->get($this, $this->snapshotable); + + expect($filename)->toEndWith('pass_with_dataset_with_data_set____my_datas_set_value______my_datas_set_value__.snap') + ->and($this->snapshotable)->toMatchSnapshot(); + }); +})->with(['my-datas-set-value']); + +test('pass with `toArray`', function () { + TestSuite::getInstance()->snapshots->save($this, json_encode(['key' => $this->snapshotable], JSON_PRETTY_PRINT)); + + $object = new class($this->snapshotable) + { + public function __construct(protected string $snapshotable) + { + } + + public function toArray() + { + return [ + 'key' => $this->snapshotable, + ]; + } + }; + + expect($object)->toMatchSnapshot()->toMatchSnapshot(); +}); + +test('pass with array', function () { + TestSuite::getInstance()->snapshots->save($this, json_encode(['key' => $this->snapshotable], JSON_PRETTY_PRINT)); + + expect([ + 'key' => $this->snapshotable, + ])->toMatchSnapshot()->toMatchSnapshot(); +}); + +test('failures', function () { + TestSuite::getInstance()->snapshots->save($this, $this->snapshotable); + + expect('contain that does not match snapshot')->toMatchSnapshot(); +})->throws(ExpectationFailedException::class, 'Failed asserting that two strings are identical.'); + +test('failures with custom message', function () { + TestSuite::getInstance()->snapshots->save($this, $this->snapshotable); + + expect('contain that does not match snapshot')->toMatchSnapshot('oh no'); +})->throws(ExpectationFailedException::class, 'oh no'); + +test('not failures', function () { + TestSuite::getInstance()->snapshots->save($this, $this->snapshotable); + + expect($this->snapshotable)->not->toMatchSnapshot(); +})->throws(ExpectationFailedException::class); diff --git a/tests/Features/Incompleted.php b/tests/Features/Incompleted.php index b32a1954..c55c1c2b 100644 --- a/tests/Features/Incompleted.php +++ b/tests/Features/Incompleted.php @@ -15,3 +15,7 @@ it('is not incompleted because of assert')->assertTrue(true); it('is not incompleted because of test with assertions', function () { expect(true)->toBeTrue(); }); + +describe('a "describe" group of tests', function () { + it('is incompleted'); +}); diff --git a/tests/Features/It.php b/tests/Features/It.php index dc483a2e..a9582b2a 100644 --- a/tests/Features/It.php +++ b/tests/Features/It.php @@ -5,3 +5,11 @@ it('is a test', function () { }); it('is a higher order message test')->expect(true)->toBeTrue(); + +describe('a "describe" group of tests', function () { + it('is a test', function () { + expect(['key' => 'foo'])->toHaveKey('key')->key->toBeString(); + }); + + it('is a higher order message test')->expect(true)->toBeTrue(); +}); diff --git a/tests/Features/Notices.php b/tests/Features/Notices.php index dd245450..fb94fcf9 100644 --- a/tests/Features/Notices.php +++ b/tests/Features/Notices.php @@ -5,3 +5,11 @@ test('notice', function () { expect(true)->toBeTrue(); }); + +describe('a "describe" group of tests', function () { + test('notice', function () { + trigger_error('This is a notice description', E_USER_NOTICE); + + expect(true)->toBeTrue(); + }); +}); diff --git a/tests/Features/ThrowsNoExceptions.php b/tests/Features/ThrowsNoExceptions.php index 98f38574..5ae0422c 100644 --- a/tests/Features/ThrowsNoExceptions.php +++ b/tests/Features/ThrowsNoExceptions.php @@ -9,3 +9,9 @@ it('allows access to the underlying expectNotToPerformAssertions method', functi it('allows performing no expectations without being risky', function () { $result = 1 + 1; })->throwsNoExceptions(); + +describe('a "describe" group of tests', function () { + it('allows performing no expectations without being risky', function () { + $result = 1 + 1; + }); +})->throwsNoExceptions(); diff --git a/tests/Features/Warnings.php b/tests/Features/Warnings.php index e638b3f0..76a51f2c 100644 --- a/tests/Features/Warnings.php +++ b/tests/Features/Warnings.php @@ -11,3 +11,11 @@ test('user warning', function () { expect(true)->toBeTrue(); }); + +describe('a "describe" group of tests', function () { + test('user warning', function () { + trigger_error('This is a warning description', E_USER_WARNING); + + expect(true)->toBeTrue(); + }); +}); diff --git a/tests/Visual/Collision.php b/tests/Visual/Collision.php index de6dad47..23f9b815 100644 --- a/tests/Visual/Collision.php +++ b/tests/Visual/Collision.php @@ -1,12 +1,6 @@ getOutput()); }; - if (getenv('REBUILD_SNAPSHOTS')) { - $outputContent = explode("\n", $output()); + $outputContent = explode("\n", $output()); + array_pop($outputContent); + array_pop($outputContent); + array_pop($outputContent); + + if (in_array('--parallel', $arguments)) { array_pop($outputContent); array_pop($outputContent); - array_pop($outputContent); - - if (in_array('--parallel', $arguments)) { - array_pop($outputContent); - array_pop($outputContent); - } - - file_put_contents($snapshot, implode("\n", $outputContent)); - - $this->markTestSkipped('Snapshot rebuilt.'); } - expect($output())->toContain(file_get_contents($snapshot)); + expect(implode("\n", $outputContent))->toMatchSnapshot(); })->with([ [['']], [['--parallel']], diff --git a/tests/Visual/Help.php b/tests/Visual/Help.php index a3a77a87..4f48171d 100644 --- a/tests/Visual/Help.php +++ b/tests/Visual/Help.php @@ -1,8 +1,6 @@ 'DefaultPrinter', 'COLLISION_IGNORE_DURATION' => 'true'])); @@ -11,11 +9,5 @@ test('visual snapshot of help command output', function () { return preg_replace('#\\x1b[[][^A-Za-z]*[A-Za-z]#', '', $process->getOutput()); }; - if (getenv('REBUILD_SNAPSHOTS')) { - file_put_contents($snapshot, $output()); - - $this->markTestSkipped('Snapshot rebuilt.'); - } - - expect($output())->toContain(file_get_contents($snapshot)); + expect($output())->toMatchSnapshot(); })->skipOnWindows(); diff --git a/tests/Visual/Parallel.php b/tests/Visual/Parallel.php index e8ff77d1..eff61789 100644 --- a/tests/Visual/Parallel.php +++ b/tests/Visual/Parallel.php @@ -11,14 +11,12 @@ $run = function () { $process->run(); - // expect($process->getExitCode())->toBe(0); - return preg_replace('#\\x1b[[][^A-Za-z]*[A-Za-z]#', '', $process->getOutput()); }; test('parallel', function () use ($run) { expect($run('--exclude-group=integration')) - ->toContain('Tests: 1 deprecated, 3 warnings, 4 incomplete, 1 notice, 8 todos, 15 skipped, 707 passed (1720 assertions)') + ->toContain('Tests: 1 deprecated, 4 warnings, 5 incomplete, 2 notices, 13 todos, 15 skipped, 732 passed (1773 assertions)') ->toContain('Parallel: 3 processes'); })->skipOnWindows(); diff --git a/tests/Visual/Version.php b/tests/Visual/Version.php index 7db09e3c..50b156b8 100644 --- a/tests/Visual/Version.php +++ b/tests/Visual/Version.php @@ -1,8 +1,6 @@ 'DefaultPrinter', 'COLLISION_IGNORE_DURATION' => 'true'])); @@ -11,11 +9,5 @@ test('visual snapshot of help command output', function () { return preg_replace('#\\x1b[[][^A-Za-z]*[A-Za-z]#', '', $process->getOutput()); }; - if (getenv('REBUILD_SNAPSHOTS')) { - file_put_contents($snapshot, $output()); - - $this->markTestSkipped('Snapshot rebuilt.'); - } - - expect($output())->toContain(file_get_contents($snapshot)); -})->skipOnWindows(); + expect($output())->toMatchSnapshot(); +})->skipOnWindows()->skip(! getenv('REBUILD_SNAPSHOTS') && getenv('EXCLUDE'));