mirror of
https://github.com/pestphp/pest.git
synced 2026-03-06 07:47:22 +01:00
feat: adds snapshot testing
This commit is contained in:
@ -70,7 +70,8 @@
|
||||
"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",
|
||||
@ -79,7 +80,8 @@
|
||||
"test": [
|
||||
"@test:refacto",
|
||||
"@test:lint",
|
||||
"@test:types",
|
||||
"@test:type:check",
|
||||
"@test:type:coverage",
|
||||
"@test:unit",
|
||||
"@test:parallel",
|
||||
"@test:integration"
|
||||
@ -100,6 +102,7 @@
|
||||
"Pest\\Plugins\\ProcessIsolation",
|
||||
"Pest\\Plugins\\Profile",
|
||||
"Pest\\Plugins\\Retry",
|
||||
"Pest\\Plugins\\Snapshot",
|
||||
"Pest\\Plugins\\Version",
|
||||
"Pest\\Plugins\\Parallel"
|
||||
]
|
||||
|
||||
@ -305,6 +305,14 @@ final class Expectation
|
||||
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));
|
||||
}
|
||||
|
||||
@ -14,11 +14,14 @@ 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 Stringable;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
@ -794,6 +797,41 @@ 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(),
|
||||
default => InvalidExpectationValue::expected('Stringable|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.
|
||||
*
|
||||
|
||||
30
src/Plugins/Snapshot.php
Normal file
30
src/Plugins/Snapshot.php
Normal file
@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins;
|
||||
|
||||
use Pest\Contracts\Plugins\HandlesArguments;
|
||||
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;
|
||||
}
|
||||
|
||||
TestSuite::getInstance()->snapshots->flush();
|
||||
|
||||
return $this->popArgument('--update-snapshots', $arguments);
|
||||
}
|
||||
}
|
||||
133
src/Repositories/SnapshotRepository.php
Normal file
133
src/Repositories/SnapshotRepository.php
Normal file
@ -0,0 +1,133 @@
|
||||
<?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.');
|
||||
}
|
||||
|
||||
return [$snapshotFilename, $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);
|
||||
}
|
||||
}
|
||||
@ -163,7 +163,7 @@ final class Coverage
|
||||
* @param File $file
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function getMissingCoverage($file): array
|
||||
public static function getMissingCoverage(mixed $file): array
|
||||
{
|
||||
$shouldBeNewLine = true;
|
||||
|
||||
|
||||
@ -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');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -0,0 +1,7 @@
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<h1>Snapshot</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -0,0 +1,7 @@
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<h1>Snapshot</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -0,0 +1,7 @@
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<h1>Snapshot</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -0,0 +1,7 @@
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<h1>Snapshot</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -0,0 +1,7 @@
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<h1>Snapshot</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -0,0 +1,7 @@
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<h1>Snapshot</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -0,0 +1,7 @@
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<h1>Snapshot</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -0,0 +1,7 @@
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<h1>Snapshot</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
94
tests/Features/Expect/toMatchSnapshot.php
Normal file
94
tests/Features/Expect/toMatchSnapshot.php
Normal file
@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
use Pest\TestSuite;
|
||||
use PHPUnit\Framework\ExpectationFailedException;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->snapshotable = <<<'HTML'
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<h1>Snapshot</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
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('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);
|
||||
@ -18,7 +18,7 @@ $run = function () {
|
||||
|
||||
test('parallel', function () use ($run) {
|
||||
expect($run('--exclude-group=integration'))
|
||||
->toContain('Tests: 1 deprecated, 4 warnings, 5 incomplete, 2 notices, 13 todos, 15 skipped, 720 passed (1740 assertions)')
|
||||
->toContain('Tests: 1 deprecated, 4 warnings, 5 incomplete, 2 notices, 13 todos, 15 skipped, 728 passed (1767 assertions)')
|
||||
->toContain('Parallel: 3 processes');
|
||||
})->skipOnWindows();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user