mirror of
https://github.com/pestphp/pest.git
synced 2026-03-06 15:57:21 +01:00
feat: adds snapshot testing
This commit is contained in:
@ -70,7 +70,8 @@
|
|||||||
"lint": "pint",
|
"lint": "pint",
|
||||||
"test:refacto": "rector --dry-run",
|
"test:refacto": "rector --dry-run",
|
||||||
"test:lint": "pint --test",
|
"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:unit": "php bin/pest --colors=always --exclude-group=integration --compact",
|
||||||
"test:inline": "php bin/pest --colors=always --configuration=phpunit.inline.xml",
|
"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:parallel": "php bin/pest --colors=always --exclude-group=integration --parallel --processes=10",
|
||||||
@ -79,7 +80,8 @@
|
|||||||
"test": [
|
"test": [
|
||||||
"@test:refacto",
|
"@test:refacto",
|
||||||
"@test:lint",
|
"@test:lint",
|
||||||
"@test:types",
|
"@test:type:check",
|
||||||
|
"@test:type:coverage",
|
||||||
"@test:unit",
|
"@test:unit",
|
||||||
"@test:parallel",
|
"@test:parallel",
|
||||||
"@test:integration"
|
"@test:integration"
|
||||||
@ -100,6 +102,7 @@
|
|||||||
"Pest\\Plugins\\ProcessIsolation",
|
"Pest\\Plugins\\ProcessIsolation",
|
||||||
"Pest\\Plugins\\Profile",
|
"Pest\\Plugins\\Profile",
|
||||||
"Pest\\Plugins\\Retry",
|
"Pest\\Plugins\\Retry",
|
||||||
|
"Pest\\Plugins\\Snapshot",
|
||||||
"Pest\\Plugins\\Version",
|
"Pest\\Plugins\\Version",
|
||||||
"Pest\\Plugins\\Parallel"
|
"Pest\\Plugins\\Parallel"
|
||||||
]
|
]
|
||||||
|
|||||||
@ -305,6 +305,14 @@ final class Expectation
|
|||||||
return $pendingArchExpectation->$method(...$parameters); // @phpstan-ignore-line
|
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 */
|
/* @phpstan-ignore-next-line */
|
||||||
return new HigherOrderExpectation($this, call_user_func_array($this->value->$method(...), $parameters));
|
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\Arr;
|
||||||
use Pest\Support\Exporter;
|
use Pest\Support\Exporter;
|
||||||
use Pest\Support\NullClosure;
|
use Pest\Support\NullClosure;
|
||||||
|
use Pest\TestSuite;
|
||||||
use PHPUnit\Framework\Assert;
|
use PHPUnit\Framework\Assert;
|
||||||
use PHPUnit\Framework\Constraint\Constraint;
|
use PHPUnit\Framework\Constraint\Constraint;
|
||||||
use PHPUnit\Framework\ExpectationFailedException;
|
use PHPUnit\Framework\ExpectationFailedException;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
use ReflectionFunction;
|
use ReflectionFunction;
|
||||||
use ReflectionNamedType;
|
use ReflectionNamedType;
|
||||||
|
use Stringable;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -794,6 +797,41 @@ final class Expectation
|
|||||||
return $this;
|
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.
|
* 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
|
* @param File $file
|
||||||
* @return array<int, string>
|
* @return array<int, string>
|
||||||
*/
|
*/
|
||||||
public static function getMissingCoverage($file): array
|
public static function getMissingCoverage(mixed $file): array
|
||||||
{
|
{
|
||||||
$shouldBeNewLine = true;
|
$shouldBeNewLine = true;
|
||||||
|
|
||||||
|
|||||||
@ -9,6 +9,7 @@ use Pest\Repositories\AfterAllRepository;
|
|||||||
use Pest\Repositories\AfterEachRepository;
|
use Pest\Repositories\AfterEachRepository;
|
||||||
use Pest\Repositories\BeforeAllRepository;
|
use Pest\Repositories\BeforeAllRepository;
|
||||||
use Pest\Repositories\BeforeEachRepository;
|
use Pest\Repositories\BeforeEachRepository;
|
||||||
|
use Pest\Repositories\SnapshotRepository;
|
||||||
use Pest\Repositories\TestRepository;
|
use Pest\Repositories\TestRepository;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
@ -47,6 +48,11 @@ final class TestSuite
|
|||||||
*/
|
*/
|
||||||
public AfterAllRepository $afterAll;
|
public AfterAllRepository $afterAll;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Holds the snapshots repository.
|
||||||
|
*/
|
||||||
|
public SnapshotRepository $snapshots;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Holds the root path.
|
* Holds the root path.
|
||||||
*/
|
*/
|
||||||
@ -69,8 +75,9 @@ final class TestSuite
|
|||||||
$this->tests = new TestRepository();
|
$this->tests = new TestRepository();
|
||||||
$this->afterEach = new AfterEachRepository();
|
$this->afterEach = new AfterEachRepository();
|
||||||
$this->afterAll = new AfterAllRepository();
|
$this->afterAll = new AfterAllRepository();
|
||||||
|
|
||||||
$this->rootPath = (string) realpath($rootPath);
|
$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) {
|
test('parallel', function () use ($run) {
|
||||||
expect($run('--exclude-group=integration'))
|
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');
|
->toContain('Parallel: 3 processes');
|
||||||
})->skipOnWindows();
|
})->skipOnWindows();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user