fix: snapshot key on retry / repeat

This commit is contained in:
nuno maduro
2026-08-24 09:31:34 +01:00
parent 39cbdcd52b
commit b8c2265d66
30 changed files with 479 additions and 145 deletions
+2
View File
@@ -344,6 +344,8 @@ trait Testable
$this->tearDown();
TestSuite::getInstance()->snapshots->forget();
Closure::bind(fn (): array => $this->mockObjects = [], $this, TestCase::class)();
foreach (array_keys(array_diff_key(get_object_vars($this), $initialProperties)) as $property) {
+40 -1
View File
@@ -34,6 +34,7 @@ use Pest\Support\Reflection;
use PHPUnit\Architecture\Elements\ObjectDescription;
use PHPUnit\Framework\ExpectationFailedException;
use ReflectionEnum;
use ReflectionFunction;
use ReflectionMethod;
use ReflectionProperty;
@@ -329,8 +330,9 @@ final class Expectation
}
$closure = $this->getExpectationClosure($method);
$reflectionClosure = new \ReflectionFunction($closure);
$reflectionClosure = new ReflectionFunction($closure);
$expectation = $reflectionClosure->getClosureThis();
$parameters = $this->positionalParameters($reflectionClosure, $parameters);
if ($reflectionClosure->getReturnType()?->__toString() === ArchExpectation::class) {
return $closure(...$parameters);
@@ -346,6 +348,43 @@ final class Expectation
return $this;
}
/**
* @param array<array-key, mixed> $parameters
* @return array<array-key, mixed>
*/
private function positionalParameters(ReflectionFunction $closure, array $parameters): array
{
if ($parameters === [] || array_is_list($parameters)) {
return $parameters;
}
$positional = [];
foreach ($closure->getParameters() as $position => $parameter) {
if ($parameter->isVariadic()) {
return $parameters;
}
$name = $parameter->getName();
if (array_key_exists($position, $parameters)) {
$positional[] = $parameters[$position];
unset($parameters[$position]);
} elseif (array_key_exists($name, $parameters)) {
$positional[] = $parameters[$name];
unset($parameters[$name]);
} elseif ($parameter->isDefaultValueAvailable()) {
$positional[] = $parameter->getDefaultValue();
} else {
return $parameters;
}
}
return $parameters === [] ? $positional : $parameters;
}
/**
* @throws ExpectationNotFound
*/
+28 -30
View File
@@ -14,7 +14,7 @@ use InvalidArgumentException;
use JsonSerializable;
use Pest\Exceptions\InvalidExpectationValue;
use Pest\Matchers\Any;
use Pest\Plugins\Snapshot;
use Pest\Plugins\Snapshot as SnapshotPlugin;
use Pest\Support\Arr;
use Pest\Support\Exporter;
use Pest\Support\NullClosure;
@@ -712,10 +712,11 @@ final class Expectation
/**
* @return self<TValue>
*/
public function toMatchSnapshot(string $message = ''): self
public function toMatchSnapshot(string $message = '', ?string $as = null): self
{
$snapshots = TestSuite::getInstance()->snapshots;
$snapshots->startNewExpectation();
$snapshot = $as === null ? $snapshots->next() : $snapshots->named($as);
$testCase = TestSuite::getInstance()->test;
assert($testCase instanceof TestCase);
@@ -733,39 +734,36 @@ final class Expectation
default => InvalidExpectationValue::expected('array|object|string'),
};
if (! $snapshots->has()) {
if (! Snapshot::shouldCreateMissingSnapshots()) {
$filename = $snapshots->filename();
Assert::fail($message === '' ? "Snapshot is missing at [$filename]. Run Pest with --update-snapshots to create it." : $message);
if (! $snapshot->exists()) {
if (! SnapshotPlugin::shouldCreateMissingSnapshots()) {
Assert::fail($message === '' ? "Snapshot is missing at [{$snapshot->path()}]. Run Pest with --update-snapshots to create it." : $message);
}
$filename = $snapshots->save($string);
$snapshot->write($string);
TestSuite::getInstance()->registerSnapshotChange("Snapshot created at [$filename]");
} else {
[$filename, $content] = $snapshots->get();
TestSuite::getInstance()->registerSnapshotChange("Snapshot created at [{$snapshot->path()}]");
$normalizedContent = strtr($content, ["\r\n" => "\n", "\r" => "\n"]);
$normalizedString = strtr($string, ["\r\n" => "\n", "\r" => "\n"]);
if (Snapshot::$updateSnapshots && $normalizedContent !== $normalizedString) {
$snapshots->save($string);
TestSuite::getInstance()->registerSnapshotChange("Snapshot updated at [$filename]");
} else {
if (Snapshot::$updateSnapshots) {
TestSuite::getInstance()->registerSnapshotChange("Snapshot unchanged at [$filename]");
}
Assert::assertSame(
$normalizedContent,
$normalizedString,
$message === '' ? "Failed asserting that the string value matches its snapshot ($filename)." : $message
);
}
return $this;
}
if (SnapshotPlugin::$updateSnapshots) {
if (! $snapshot->matches($string)) {
$snapshot->write($string);
TestSuite::getInstance()->registerSnapshotChange("Snapshot updated at [{$snapshot->path()}]");
return $this;
}
TestSuite::getInstance()->registerSnapshotChange("Snapshot unchanged at [{$snapshot->path()}]");
}
Assert::assertSame(
$snapshot->normalize($snapshot->read()),
$snapshot->normalize($string),
$message === '' ? "Failed asserting that the string value matches its snapshot ({$snapshot->path()})." : $message
);
return $this;
}
+68
View File
@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace Pest\Repositories;
use Pest\Exceptions\ShouldNotHappen;
/**
* @internal
*/
final readonly class Snapshot
{
public function __construct(
private string $filename,
private string $basePath,
) {}
public function exists(): bool
{
return file_exists($this->filename);
}
public function path(): string
{
return str_replace($this->basePath, '', $this->filename);
}
/**
* @throws ShouldNotHappen
*/
public function read(): string
{
$contents = file_get_contents($this->filename);
if ($contents === false) {
throw ShouldNotHappen::fromMessage('Snapshot file could not be read.');
}
return $contents;
}
public function write(string $contents): self
{
$directory = dirname($this->filename);
if (! is_dir($directory)) {
@mkdir($directory, 0755, true);
}
file_put_contents($this->filename, $contents);
return $this;
}
/**
* @throws ShouldNotHappen
*/
public function matches(string $value): bool
{
return $this->normalize($this->read()) === $this->normalize($value);
}
public function normalize(string $value): string
{
return strtr($value, ["\r\n" => "\n", "\r" => "\n"]);
}
}
+87 -59
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Pest\Repositories;
use InvalidArgumentException;
use Pest\Exceptions\ShouldNotHappen;
use Pest\TestSuite;
@@ -12,8 +13,9 @@ use Pest\TestSuite;
*/
final class SnapshotRepository
{
/** @var array<string, int> */
private static array $expectationsCounter = [];
private static ?string $key = null;
private static int $ordinal = 0;
public function __construct(
private readonly string $rootPath,
@@ -21,47 +23,39 @@ final class SnapshotRepository
private readonly string $snapshotsPath,
) {}
public function has(): bool
public function current(): Snapshot
{
return file_exists($this->getSnapshotFilename());
$this->synchronize();
return $this->snapshot(self::$ordinal > 1 ? '__'.self::$ordinal : '');
}
/**
* @return array{0: string, 1: string}
*
* @throws ShouldNotHappen
*/
public function get(): array
public function next(): Snapshot
{
$contents = file_get_contents($snapshotFilename = $this->getSnapshotFilename());
$this->synchronize();
if ($contents === false) {
throw ShouldNotHappen::fromMessage('Snapshot file could not be read.');
self::$ordinal++;
return $this->current();
}
public function named(string $name): Snapshot
{
$this->synchronize();
$suffix = trim((string) preg_replace('/[^\w-]+/', '_', $name), '_');
if ($suffix === '') {
throw new InvalidArgumentException('The snapshot name must contain at least one alphanumeric character.');
}
$snapshot = str_replace(dirname($this->testsPath).'/', '', $snapshotFilename);
return [$snapshot, $contents];
return $this->snapshot('__'.$suffix);
}
public function save(string $snapshot): string
public function forget(): void
{
$snapshotFilename = $this->getSnapshotFilename();
$directory = dirname($snapshotFilename);
if (! is_dir($directory)) {
@mkdir($directory, 0755, true);
}
file_put_contents($snapshotFilename, $snapshot);
return $this->filename();
}
public function filename(): string
{
return str_replace(dirname($this->testsPath).'/', '', $this->getSnapshotFilename());
self::$key = null;
self::$ordinal = 0;
}
public function flush(): void
@@ -92,47 +86,81 @@ final class SnapshotRepository
}
}
private function getSnapshotFilename(): string
private function snapshot(string $suffix): Snapshot
{
$testFile = TestSuite::getInstance()->getFilename();
if (str_starts_with($testFile, $this->testsPath)) {
$startPath = $this->testsPath;
} else {
$startPath = $this->rootPath;
}
$startPath = str_starts_with($testFile, $this->testsPath) ? $this->testsPath : $this->rootPath;
$relativePath = substr($testFile, strlen($startPath));
$relativePath = substr($relativePath, 0, (int) strrpos($relativePath, '.'));
$description = TestSuite::getInstance()->getDescription();
return new Snapshot(
sprintf(
'%s/%s%s.snap',
$this->testsPath.'/'.$this->snapshotsPath.$relativePath,
TestSuite::getInstance()->getDescription(),
$suffix,
),
dirname($this->testsPath).'/',
);
}
if ($this->getCurrentSnapshotCounter() > 1) {
$description .= '__'.$this->getCurrentSnapshotCounter();
private function synchronize(): void
{
$key = TestSuite::getInstance()->getFilename().'###'.TestSuite::getInstance()->getDescription();
if (self::$key === $key) {
return;
}
return sprintf('%s/%s.snap', $this->testsPath.'/'.$this->snapshotsPath.$relativePath, $description);
}
private function getCurrentSnapshotKey(): string
{
return TestSuite::getInstance()->getFilename().'###'.TestSuite::getInstance()->getDescription();
}
private function getCurrentSnapshotCounter(): int
{
return self::$expectationsCounter[$this->getCurrentSnapshotKey()] ?? 0;
self::$key = $key;
self::$ordinal = 0;
}
/**
* @deprecated Use `next` and `current` instead.
*/
public function startNewExpectation(): void
{
$key = $this->getCurrentSnapshotKey();
$this->next();
}
if (! isset(self::$expectationsCounter[$key])) {
self::$expectationsCounter[$key] = 0;
}
/**
* @deprecated Use `current` instead.
*/
public function has(): bool
{
return $this->current()->exists();
}
self::$expectationsCounter[$key]++;
/**
* @deprecated Use `current` instead.
*
* @return array{0: string, 1: string}
*
* @throws ShouldNotHappen
*/
public function get(): array
{
$snapshot = $this->current();
return [$snapshot->path(), $snapshot->read()];
}
/**
* @deprecated Use `current` instead.
*/
public function save(string $snapshot): string
{
return $this->current()->write($snapshot)->path();
}
/**
* @deprecated Use `current` instead.
*/
public function filename(): string
{
return $this->current()->path();
}
}