Merge branch 'develop' into 2.x

This commit is contained in:
Nuno Maduro
2023-07-19 10:23:58 -05:00
71 changed files with 1480 additions and 232 deletions

View File

@ -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());

View File

@ -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));
}
}

View File

@ -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));
}
}

View File

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Pest\Exceptions;
use InvalidArgumentException;
use NunoMaduro\Collision\Contracts\RenderlessEditor;
use NunoMaduro\Collision\Contracts\RenderlessTrace;
use Symfony\Component\Console\Exception\ExceptionInterface;
/**
* @internal
*/
final class BeforeAllWithinDescribe extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace
{
/**
* Creates a new Exception instance.
*/
public function __construct(string $filename)
{
parent::__construct(sprintf('The beforeAll method can not be used within describe functions. Filename `%s`.', $filename));
}
}

View File

@ -7,12 +7,15 @@ namespace Pest;
use BadMethodCallException;
use Closure;
use Pest\Arch\Contracts\ArchExpectation;
use Pest\Arch\Expectations\Targeted;
use Pest\Arch\Expectations\ToBeUsedIn;
use Pest\Arch\Expectations\ToBeUsedInNothing;
use Pest\Arch\Expectations\ToOnlyBeUsedIn;
use Pest\Arch\Expectations\ToOnlyUse;
use Pest\Arch\Expectations\ToUse;
use Pest\Arch\Expectations\ToUseNothing;
use Pest\Arch\PendingArchExpectation;
use Pest\Arch\Support\FileLineFinder;
use Pest\Concerns\Extendable;
use Pest\Concerns\Pipeable;
use Pest\Concerns\Retrievable;
@ -24,6 +27,7 @@ use Pest\Expectations\HigherOrderExpectation;
use Pest\Expectations\OppositeExpectation;
use Pest\Matchers\Any;
use Pest\Support\ExpectationPipeline;
use PHPUnit\Architecture\Elements\ObjectDescription;
use PHPUnit\Framework\Assert;
use PHPUnit\Framework\ExpectationFailedException;
@ -34,8 +38,13 @@ use PHPUnit\Framework\ExpectationFailedException;
*
* @property OppositeExpectation $not Creates the opposite expectation.
* @property EachExpectation $each Creates an expectation on each element on the traversable value.
* @property PendingArchExpectation $classes
* @property PendingArchExpectation $traits
* @property PendingArchExpectation $interfaces
* @property PendingArchExpectation $enums
*
* @mixin Mixins\Expectation<TValue>
* @mixin PendingArchExpectation
*/
final class Expectation
{
@ -287,9 +296,23 @@ final class Expectation
* @param array<int, mixed> $parameters
* @return Expectation<TValue>|HigherOrderExpectation<Expectation<TValue>, 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, '<?php')),
);
}
/**
* Asserts that the given expectation target is final.
*/
public function toBeFinal(): ArchExpectation
{
return Targeted::make(
$this,
fn (ObjectDescription $object): bool => $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<int, class-string>|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<int, class-string>|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.
*

View File

@ -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, '<?php')),
);
}
/**
* Asserts that the given expectation target is not final.
*/
public function toBeFinal(): ArchExpectation
{
return Targeted::make(
$this->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<int, class-string>|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<int, class-string>|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<int, string>|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('/(?<!\ )[A-Z]/', ' $0', $name)), implode(' ', array_map(fn ($argument): string => $toString($argument), $arguments))));
throw new ExpectationFailedException(sprintf(
'Expecting %s not %s %s.',
$toString($this->original->value),
strtolower((string) preg_replace('/(?<!\ )[A-Z]/', ' $0', $name)),
implode(' ', array_map(fn (mixed $argument): string => $toString($argument), $arguments)),
));
}
}

View File

@ -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<drive>[a-z]+:\\\)~i', static fn ($match): string => strtolower($match['drive']), $filename);
$filename = (string) preg_replace_callback('~^(?P<drive>[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);

View File

@ -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<Closure|iterable<int|string, mixed>|string>
*/
public array $datasets = [];
/**
* The Test Case depends, if any.
* The test's dependencies.
*
* @var array<int, string>
*/
public array $depends = [];
/**
* The Test Case groups, if any.
* The test's groups.
*
* @var array<int, string>
*/
public array $groups = [];
/**
* The covered classes and functions, if any.
* 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 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 <<<PHP

View File

@ -2,9 +2,13 @@
declare(strict_types=1);
use Pest\Concerns\Expectable;
use Pest\Exceptions\AfterAllWithinDescribe;
use Pest\Exceptions\BeforeAllWithinDescribe;
use Pest\Expectation;
use Pest\PendingCalls\AfterEachCall;
use Pest\PendingCalls\BeforeEachCall;
use Pest\PendingCalls\DescribeCall;
use Pest\PendingCalls\TestCall;
use Pest\PendingCalls\UsesCall;
use Pest\Repositories\DatasetsRepository;
@ -35,6 +39,12 @@ if (! function_exists('beforeAll')) {
*/
function beforeAll(Closure $closure): void
{
if (! is_null(DescribeCall::describing())) {
$filename = Backtrace::file();
throw new BeforeAllWithinDescribe($filename);
}
TestSuite::getInstance()->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>|TestCall|TestCase|mixed
* @return HigherOrderTapProxy<Expectable|TestCall|TestCase>|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>|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|TestCase>|TestCall|mixed
* @return Expectable|HigherOrderTapProxy<Expectable|TestCall|TestCase>|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);
}
}

View File

@ -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<TValue>
*/
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.
*

View File

@ -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,
);
}
/**

View File

@ -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,
);
}

View File

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace Pest\PendingCalls\Concerns;
/**
* @internal
*/
trait Describable
{
public ?string $describing = null;
}

View File

@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace Pest\PendingCalls;
use Closure;
use Pest\Support\Backtrace;
use Pest\TestSuite;
/**
* @internal
*/
final class DescribeCall
{
/**
* The current describe call.
*/
private static ?string $describing = null;
/**
* Creates a new Pending Call.
*/
public function __construct(
public readonly TestSuite $testSuite,
public readonly string $filename,
public readonly string $description,
public readonly Closure $tests
) {
//
}
/**
* What is the current describing.
*/
public static function describing(): ?string
{
return self::$describing;
}
/**
* Creates the Call.
*/
public function __destruct()
{
self::$describing = $this->description;
try {
($this->tests)();
} finally {
self::$describing = null;
}
}
/**
* Dynamically calls methods on each test call.
*
* @param array<int, mixed> $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
}
}

View File

@ -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);
}
}

View File

@ -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<drive>[a-z]+:\\\)~i', fn ($match): string => strtolower($match['drive']), $path);
$path = (string) preg_replace_callback('~^(?P<drive>[a-z]+:\\\)~i', fn (array $match): string => strtolower($match['drive']), $path);
$startChar = strtolower((string) preg_replace('~^([a-z]+:\\\).*$~i', '$1', __DIR__));
}

View File

@ -44,6 +44,6 @@ trait HandleArguments
unset($arguments[$argument]);
return array_flip($arguments);
return array_values(array_flip($arguments));
}
}

View File

@ -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;

View File

@ -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'] = [[

View File

@ -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
);
}

View File

@ -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);
}

View File

@ -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(

35
src/Plugins/Snapshot.php Normal file
View File

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins;
use Pest\Contracts\Plugins\HandlesArguments;
use Pest\Exceptions\InvalidOption;
use Pest\TestSuite;
/**
* @internal
*/
final class Snapshot implements HandlesArguments
{
use Concerns\HandleArguments;
/**
* {@inheritDoc}
*/
public function handleArguments(array $arguments): array
{
if (! $this->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);
}
}

View File

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

View File

@ -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;

View File

@ -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];

View File

@ -66,11 +66,11 @@ final class DatasetsRepository
}
/**
* @return Closure|array<int|string, mixed>|never
* @return Closure|array<int|string, mixed>
*
* @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<Closure|iterable<int|string, mixed>|string> $datasets
* @return array<array<mixed>>
* @return array<int, array<int, mixed>>
*/
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) {

View File

@ -0,0 +1,135 @@
<?php
declare(strict_types=1);
namespace Pest\Repositories;
use Pest\Exceptions\ShouldNotHappen;
use Pest\Support\Str;
use PHPUnit\Framework\TestCase;
/**
* @internal
*/
final class SnapshotRepository
{
/**
* Creates a snapshot repository instance.
*/
public function __construct(
readonly private string $testsPath,
readonly private string $snapshotsPath,
) {
}
/**
* Checks if the snapshot exists.
*/
public function has(TestCase $testCase, string $description): bool
{
[$filename, $description] = $this->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);
}
}

View File

@ -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.');
}
}

View File

@ -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());

View File

@ -159,10 +159,11 @@ final class Coverage
* ['11', '20..25', '50', '60..80'];
* ```
*
*
* @param File $file
* @return array<int, string>
*/
public static function getMissingCoverage($file): array
public static function getMissingCoverage(mixed $file): array
{
$shouldBeNewLine = true;

View File

@ -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();

View File

@ -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);
}
}

View File

@ -24,9 +24,8 @@ final class Reflection
* Calls the given method with args on the given object.
*
* @param array<int, mixed> $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<int, mixed> $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;
}

View File

@ -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);

View File

@ -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');
}
/**