diff --git a/composer.json b/composer.json index a32a3185..52ccdba9 100644 --- a/composer.json +++ b/composer.json @@ -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" ] diff --git a/src/Expectation.php b/src/Expectation.php index bd8524a3..ad0da961 100644 --- a/src/Expectation.php +++ b/src/Expectation.php @@ -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)); } diff --git a/src/Mixins/Expectation.php b/src/Mixins/Expectation.php index 385b0e95..e9da32dc 100644 --- a/src/Mixins/Expectation.php +++ b/src/Mixins/Expectation.php @@ -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 + */ + 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. * diff --git a/src/Plugins/Snapshot.php b/src/Plugins/Snapshot.php new file mode 100644 index 00000000..3f9cb850 --- /dev/null +++ b/src/Plugins/Snapshot.php @@ -0,0 +1,30 @@ +hasArgument('--update-snapshots', $arguments)) { + return $arguments; + } + + TestSuite::getInstance()->snapshots->flush(); + + return $this->popArgument('--update-snapshots', $arguments); + } +} diff --git a/src/Repositories/SnapshotRepository.php b/src/Repositories/SnapshotRepository.php new file mode 100644 index 00000000..7725cc13 --- /dev/null +++ b/src/Repositories/SnapshotRepository.php @@ -0,0 +1,133 @@ +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); + } +} diff --git a/src/Support/Coverage.php b/src/Support/Coverage.php index 525e67e2..eccff9d3 100644 --- a/src/Support/Coverage.php +++ b/src/Support/Coverage.php @@ -163,7 +163,7 @@ final class Coverage * @param File $file * @return array */ - public static function getMissingCoverage($file): array + public static function getMissingCoverage(mixed $file): array { $shouldBeNewLine = true; diff --git a/src/TestSuite.php b/src/TestSuite.php index 1c4297f6..0baf4586 100644 --- a/src/TestSuite.php +++ b/src/TestSuite.php @@ -9,6 +9,7 @@ use Pest\Repositories\AfterAllRepository; use Pest\Repositories\AfterEachRepository; use Pest\Repositories\BeforeAllRepository; use Pest\Repositories\BeforeEachRepository; +use Pest\Repositories\SnapshotRepository; use Pest\Repositories\TestRepository; use PHPUnit\Framework\TestCase; @@ -47,6 +48,11 @@ final class TestSuite */ public AfterAllRepository $afterAll; + /** + * Holds the snapshots repository. + */ + public SnapshotRepository $snapshots; + /** * Holds the root path. */ @@ -69,8 +75,9 @@ final class TestSuite $this->tests = new TestRepository(); $this->afterEach = new AfterEachRepository(); $this->afterAll = new AfterAllRepository(); - $this->rootPath = (string) realpath($rootPath); + + $this->snapshots = new SnapshotRepository($this->rootPath.'/'.$this->testPath, '.pest/snapshots'); } /** diff --git a/tests/.pest/snapshots/Features/Expect/toMatchSnapshot/_within_describe__→_pass_with_dataset_with_data_set____my_datas_set_value______my_datas_set_value__.snap b/tests/.pest/snapshots/Features/Expect/toMatchSnapshot/_within_describe__→_pass_with_dataset_with_data_set____my_datas_set_value______my_datas_set_value__.snap new file mode 100644 index 00000000..c2b4dc0a --- /dev/null +++ b/tests/.pest/snapshots/Features/Expect/toMatchSnapshot/_within_describe__→_pass_with_dataset_with_data_set____my_datas_set_value______my_datas_set_value__.snap @@ -0,0 +1,7 @@ +
+
+
+

Snapshot

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

Snapshot

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

Snapshot

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

Snapshot

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

Snapshot

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

Snapshot

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

Snapshot

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

Snapshot

+
+
+
\ No newline at end of file diff --git a/tests/Features/Expect/toMatchSnapshot.php b/tests/Features/Expect/toMatchSnapshot.php new file mode 100644 index 00000000..8887e2e1 --- /dev/null +++ b/tests/Features/Expect/toMatchSnapshot.php @@ -0,0 +1,94 @@ +snapshotable = <<<'HTML' +
+
+
+

Snapshot

+
+
+
+ HTML; +}); + +test('pass', function () { + TestSuite::getInstance()->snapshots->save($this, $this->snapshotable); + + expect($this->snapshotable)->toMatchSnapshot(); +}); + +test('pass with `__toString`', function () { + TestSuite::getInstance()->snapshots->save($this, $this->snapshotable); + + $object = new class($this->snapshotable) + { + public function __construct(protected string $snapshotable) + { + } + + public function __toString() + { + return $this->snapshotable; + } + }; + + expect($object)->toMatchSnapshot()->toMatchSnapshot(); +}); + +test('pass with `toString`', function () { + TestSuite::getInstance()->snapshots->save($this, $this->snapshotable); + + $object = new class($this->snapshotable) + { + public function __construct(protected string $snapshotable) + { + } + + public function toString() + { + return $this->snapshotable; + } + }; + + expect($object)->toMatchSnapshot()->toMatchSnapshot(); +}); + +test('pass with dataset', function ($data) { + TestSuite::getInstance()->snapshots->save($this, $this->snapshotable); + [$filename] = TestSuite::getInstance()->snapshots->get($this, $this->snapshotable); + + expect($filename)->toEndWith('pass_with_dataset_with_data_set____my_datas_set_value______my_datas_set_value__.snap') + ->and($this->snapshotable)->toMatchSnapshot(); +})->with(['my-datas-set-value']); + +describe('within describe', function () { + test('pass with dataset', function ($data) { + TestSuite::getInstance()->snapshots->save($this, $this->snapshotable); + [$filename] = TestSuite::getInstance()->snapshots->get($this, $this->snapshotable); + + expect($filename)->toEndWith('pass_with_dataset_with_data_set____my_datas_set_value______my_datas_set_value__.snap') + ->and($this->snapshotable)->toMatchSnapshot(); + }); +})->with(['my-datas-set-value']); + +test('failures', function () { + TestSuite::getInstance()->snapshots->save($this, $this->snapshotable); + + expect('contain that does not match snapshot')->toMatchSnapshot(); +})->throws(ExpectationFailedException::class, 'Failed asserting that two strings are identical.'); + +test('failures with custom message', function () { + TestSuite::getInstance()->snapshots->save($this, $this->snapshotable); + + expect('contain that does not match snapshot')->toMatchSnapshot('oh no'); +})->throws(ExpectationFailedException::class, 'oh no'); + +test('not failures', function () { + TestSuite::getInstance()->snapshots->save($this, $this->snapshotable); + + expect($this->snapshotable)->not->toMatchSnapshot(); +})->throws(ExpectationFailedException::class); diff --git a/tests/Visual/Parallel.php b/tests/Visual/Parallel.php index b79aa537..e7eb527e 100644 --- a/tests/Visual/Parallel.php +++ b/tests/Visual/Parallel.php @@ -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();