diff --git a/src/Concerns/Testable.php b/src/Concerns/Testable.php index 9d54711e..0560cbb3 100644 --- a/src/Concerns/Testable.php +++ b/src/Concerns/Testable.php @@ -39,6 +39,11 @@ trait Testable */ public ?string $__describing = null; + /** + * Whether the test has ran or not. + */ + public bool $__ran = false; + /** * The test's test closure. */ diff --git a/src/Exceptions/AfterBeforeTestFunction.php b/src/Exceptions/AfterBeforeTestFunction.php new file mode 100644 index 00000000..7d18f701 --- /dev/null +++ b/src/Exceptions/AfterBeforeTestFunction.php @@ -0,0 +1,24 @@ +closure ??= function (): void { @@ -109,6 +118,8 @@ final class TestCaseMethodFactory $testCase->chains->chain($this); $method->chains->chain($this); + $this->__ran = true; + return \Pest\Support\Closure::bind($closure, $this, self::class)(...$arguments); }; } diff --git a/src/PendingCalls/AfterEachCall.php b/src/PendingCalls/AfterEachCall.php index 8ccc8a46..795626ec 100644 --- a/src/PendingCalls/AfterEachCall.php +++ b/src/PendingCalls/AfterEachCall.php @@ -65,7 +65,6 @@ final class AfterEachCall $this, $afterEachTestCase, ); - } /** diff --git a/src/PendingCalls/BeforeEachCall.php b/src/PendingCalls/BeforeEachCall.php index a14c03a0..41f10d6b 100644 --- a/src/PendingCalls/BeforeEachCall.php +++ b/src/PendingCalls/BeforeEachCall.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Pest\PendingCalls; use Closure; +use Pest\Exceptions\AfterBeforeTestFunction; use Pest\PendingCalls\Concerns\Describable; use Pest\Support\Backtrace; use Pest\Support\ChainableClosure; @@ -83,6 +84,18 @@ final class BeforeEachCall ); } + /** + * Runs the given closure after the test. + */ + public function after(Closure $closure): self + { + if ($this->describing === null) { + throw new AfterBeforeTestFunction($this->filename); + } + + return $this->__call('after', [$closure]); + } + /** * Saves the calls to be used on the target. * @@ -91,7 +104,8 @@ final class BeforeEachCall public function __call(string $name, array $arguments): self { if (method_exists(TestCall::class, $name)) { - $this->testCallProxies->add(Backtrace::file(), Backtrace::line(), $name, $arguments); + $this->testCallProxies + ->add(Backtrace::file(), Backtrace::line(), $name, $arguments); return $this; } diff --git a/src/PendingCalls/TestCall.php b/src/PendingCalls/TestCall.php index 10216812..5422347f 100644 --- a/src/PendingCalls/TestCall.php +++ b/src/PendingCalls/TestCall.php @@ -6,6 +6,7 @@ namespace Pest\PendingCalls; use Closure; use Pest\Exceptions\InvalidArgumentException; +use Pest\Exceptions\TestDescriptionMissing; use Pest\Factories\Attribute; use Pest\Factories\TestCaseMethodFactory; use Pest\Mutate\Decorators\TestCallDecorator as MutationTestCallDecorator; @@ -52,10 +53,10 @@ final class TestCall public function __construct( private readonly TestSuite $testSuite, private readonly string $filename, - ?string $description = null, + private ?string $description = null, ?Closure $closure = null ) { - $this->testCaseMethod = new TestCaseMethodFactory($filename, $description, $closure); + $this->testCaseMethod = new TestCaseMethodFactory($filename, $closure); $this->descriptionLess = $description === null; @@ -64,6 +65,42 @@ final class TestCall $this->testSuite->beforeEach->get($this->filename)[0]($this); } + /** + * Runs the given closure after the test. + */ + public function after(Closure $closure): self + { + if ($this->description === null) { + throw new TestDescriptionMissing($this->filename); + } + + $description = is_null($this->describing) + ? $this->description + : Str::describe($this->describing, $this->description); + + $filename = $this->filename; + + $when = function () use ($closure, $filename, $description): void { + if ($this::$__filename !== $filename) { // @phpstan-ignore-line + return; + } + + if ($this->__description !== $description) { // @phpstan-ignore-line + return; + } + + if ($this->__ran !== true) { // @phpstan-ignore-line + return; + } + + $closure->call($this); + }; + + new AfterEachCall($this->testSuite, $this->filename, $when->bindTo(new \stdClass())); + + return $this; + } + /** * Asserts that the test fails with the given message. */ @@ -438,10 +475,10 @@ final class TestCall if ($this->descriptionLess) { Exporter::default(); - if ($this->testCaseMethod->description !== null) { - $this->testCaseMethod->description .= ' → '; + if ($this->description !== null) { + $this->description .= ' → '; } - $this->testCaseMethod->description .= $arguments === null + $this->description .= $arguments === null ? $name : sprintf('%s %s', $name, $exporter->shortenedRecursiveExport($arguments)); } @@ -452,7 +489,7 @@ final class TestCall /** * Mutates the test. */ - public function mutate(string $profile = 'default'): self|MutationTestCallDecorator + public function mutate(string $profile = 'default'): self|MutationTestCallDecorator // @phpstan-ignore-line { if (class_exists(MutationTestCallDecorator::class)) { return (new MutationTestCallDecorator($this)) @@ -467,9 +504,15 @@ final class TestCall */ public function __destruct() { + if ($this->description === null) { + throw new TestDescriptionMissing($this->filename); + } + if (! is_null($this->describing)) { $this->testCaseMethod->describing = $this->describing; - $this->testCaseMethod->description = Str::describe($this->describing, $this->testCaseMethod->description); // @phpstan-ignore-line + $this->testCaseMethod->description = Str::describe($this->describing, $this->description); + } else { + $this->testCaseMethod->description = $this->description; } $this->testSuite->tests->set($this->testCaseMethod); diff --git a/tests/.snapshots/success.txt b/tests/.snapshots/success.txt index 46d5d2d7..1f5012b6 100644 --- a/tests/.snapshots/success.txt +++ b/tests/.snapshots/success.txt @@ -7,6 +7,17 @@ PASS Tests\Environments\Windows ✓ global functions are loaded + WARN Tests\Features\After + ✓ it can run after test + ✓ it can run after test twice + - it does not run when skipped + - something → it does not run when skipped + ✓ something → it can run after test + ✓ something 2 → it can run after test + ✓ high order test + - high order test with skip + ✓ post 'foo' → defer Closure → expect Closure → toBe 1 + PASS Tests\Features\AfterAll ✓ deletes file after all @@ -1444,4 +1455,4 @@ WARN Tests\Visual\Version - visual snapshot of help command output - Tests: 2 deprecated, 4 warnings, 5 incomplete, 2 notices, 13 todos, 21 skipped, 1034 passed (2519 assertions) \ No newline at end of file + Tests: 2 deprecated, 4 warnings, 5 incomplete, 2 notices, 13 todos, 24 skipped, 1040 passed (2561 assertions) \ No newline at end of file diff --git a/tests/Features/After.php b/tests/Features/After.php new file mode 100644 index 00000000..2f2c270f --- /dev/null +++ b/tests/Features/After.php @@ -0,0 +1,176 @@ +count = 0; +}); + +afterEach(function () { + match ($this->name()) { + '__pest_evaluable_it_can_run_after_test' => expect($this->count)->toBe(1), + '__pest_evaluable_it_can_run_after_test_twice' => expect($this->count)->toBe(1), + '__pest_evaluable_it_does_not_run_when_skipped' => expect($this->count)->toBe(0), + '__pest_evaluable__something__→_it_does_not_run_when_skipped' => expect($this->count)->toBe(0), + '__pest_evaluable__something__→_it_can_run_after_test' => expect($this->count)->toBe(1), + '__pest_evaluable__something_2__→_it_can_run_after_test' => expect($this->count)->toBe(1), + '__pest_evaluable_high_order_test' => expect($this->count)->toBe(1), + '__pest_evaluable_high_order_test_with_skip' => expect($this->count)->toBe(0), + '__pest_evaluable_post__foo__→_defer_Closure_→_expect_Closure_→_toBe_1' => expect($this->count)->toBe(1), + default => $this->fail('Unexpected test name: '.$this->name()), + }; + + $this->count++; +}); + +it('can run after test', function () { + expect($this->count)->toBe(0); + + $this->count++; +})->after(function () { + expect($this->count)->toBe(2); + + $this->count++; +}); + +it('can run after test twice', function () { + expect($this->count)->toBe(0); + + $this->count++; +})->after(function () { + expect($this->count)->toBe(2); + + $this->count++; +})->after(function () { + expect($this->count)->toBe(3); + + $this->count++; +}); + +it('does not run when skipped', function () { + dd('This should not run 1'); +})->skip()->after(function () { + dd('This should not run 2'); +}); + +afterEach(function () { + match ($this->name()) { + '__pest_evaluable_it_can_run_after_test' => expect($this->count)->toBe(3), + '__pest_evaluable_it_can_run_after_test_twice' => expect($this->count)->toBe(4), + '__pest_evaluable_it_does_not_run_when_skipped' => expect($this->count)->toBe(1), + '__pest_evaluable__something__→_it_does_not_run_when_skipped' => expect($this->count)->toBe(1), + '__pest_evaluable__something__→_it_can_run_after_test' => expect($this->count)->toBe(2), + '__pest_evaluable__something_2__→_it_can_run_after_test' => expect($this->count)->toBe(2), + '__pest_evaluable_high_order_test' => expect($this->count)->toBe(2), + '__pest_evaluable_high_order_test_with_skip' => expect($this->count)->toBe(1), + '__pest_evaluable_post__foo__→_defer_Closure_→_expect_Closure_→_toBe_1' => expect($this->count)->toBe(2), + + default => $this->fail('Unexpected test name: '.$this->name()), + + }; + + $this->count++; +}); + +afterEach(function () { + match ($this->name()) { + '__pest_evaluable_it_can_run_after_test' => expect($this->count)->toBe(4), + '__pest_evaluable_it_can_run_after_test_twice' => expect($this->count)->toBe(5), + '__pest_evaluable_it_does_not_run_when_skipped' => expect($this->count)->toBe(2), + '__pest_evaluable__something__→_it_does_not_run_when_skipped' => expect($this->count)->toBe(2), + '__pest_evaluable__something__→_it_can_run_after_test' => expect($this->count)->toBe(3), + '__pest_evaluable__something_2__→_it_can_run_after_test' => expect($this->count)->toBe(3), + '__pest_evaluable_high_order_test' => expect($this->count)->toBe(3), + '__pest_evaluable_high_order_test_with_skip' => expect($this->count)->toBe(2), + '__pest_evaluable_post__foo__→_defer_Closure_→_expect_Closure_→_toBe_1' => expect($this->count)->toBe(3), + default => $this->fail('Unexpected test name: '.$this->name()), + }; + + $this->count++; +}); + +describe('something', function () { + it('does not run when skipped', function () { + dd('This should not run 3'); + })->skip()->after(function () { + dd('This should not run 4'); + }); + + it('can run after test', function () { + expect($this->count)->toBe(0); + + $this->count++; + })->after(function () { + expect($this->count)->toBe(5); + + $this->count++; + })->after(function () { + expect($this->count)->toBe(6); + + $this->count++; + }); +})->after(function () { + expect($this->count)->toBe(4); + + $this->count++; +}); + +describe('something 2', function () { + it('can run after test', function () { + expect($this->count)->toBe(0); + + $this->count++; + })->after(function () { + expect($this->count)->toBe(4); + + $this->count++; + }); +})->after(function () { + expect($this->count)->toBe(4); + + $this->count++; +})->after(function () { + expect($this->count)->toBe(5); + + $this->count++; +}); + +test('high order test') + ->defer(fn () => $this->count++) + ->expect(fn () => $this->count)->toBe(1) + ->after(function () { + expect($this->count)->toBe(4); + + $this->count++; + }); + +test('high order test with skip') + ->skip() + ->defer(fn () => $this->count++) + ->expect(fn () => $this->count)->toBe(1) + ->after(function () { + dd('This should not run 5'); + }); + +uses(Postable::class); + +/** + * @return TestCase|TestCall|Gettable + */ +function post(string $route) +{ + return test()->post($route); +} + +trait Postable +{ + /** + * @return TestCase|TestCall|Gettable + */ + public function post(string $route) + { + expect($route)->not->toBeEmpty(); + + return $this; + } +} + +post('foo')->defer(fn () => $this->count++)->expect(fn () => $this->count)->toBe(1); diff --git a/tests/Helpers.php b/tests/Helpers.php index 57d38e06..7a7be690 100644 --- a/tests/Helpers.php +++ b/tests/Helpers.php @@ -1,6 +1,11 @@ assertTrue($value); + + return test(); } diff --git a/tests/Unit/TestSuite.php b/tests/Unit/TestSuite.php index eb033d0c..5a2ceb58 100644 --- a/tests/Unit/TestSuite.php +++ b/tests/Unit/TestSuite.php @@ -7,7 +7,8 @@ use Pest\TestSuite; it('does not allow to add the same test description twice', function () { $testSuite = new TestSuite(getcwd(), 'tests'); - $method = new TestCaseMethodFactory('foo', 'bar', null); + $method = new TestCaseMethodFactory('foo', null); + $method->description = 'bar'; $testSuite->tests->set($method); $testSuite->tests->set($method); @@ -19,9 +20,11 @@ it('does not allow to add the same test description twice', function () { it('alerts users about tests with arguments but no input', function () { $testSuite = new TestSuite(getcwd(), 'tests'); - $method = new TestCaseMethodFactory('foo', 'bar', function (int $arg) { + $method = new TestCaseMethodFactory('foo', function (int $arg) { }); + $method->description = 'bar'; + $testSuite->tests->set($method); })->throws( DatasetMissing::class, @@ -31,8 +34,13 @@ it('alerts users about tests with arguments but no input', function () { it('can return an array of all test suite filenames', function () { $testSuite = new TestSuite(getcwd(), 'tests'); - $testSuite->tests->set(new TestCaseMethodFactory('a', 'b', null)); - $testSuite->tests->set(new TestCaseMethodFactory('c', 'd', null)); + $method = new TestCaseMethodFactory('a', null); + $method->description = 'b'; + $testSuite->tests->set($method); + + $method = new TestCaseMethodFactory('c', null); + $method->description = 'd'; + $testSuite->tests->set($method); expect($testSuite->tests->getFilenames())->toEqual([ 'a', diff --git a/tests/Visual/Parallel.php b/tests/Visual/Parallel.php index 37c8e0a2..1a701fb5 100644 --- a/tests/Visual/Parallel.php +++ b/tests/Visual/Parallel.php @@ -16,7 +16,7 @@ $run = function () { test('parallel', function () use ($run) { expect($run('--exclude-group=integration')) - ->toContain('Tests: 2 deprecated, 4 warnings, 5 incomplete, 2 notices, 13 todos, 16 skipped, 1020 passed (2487 assertions)') + ->toContain('Tests: 2 deprecated, 4 warnings, 5 incomplete, 2 notices, 13 todos, 19 skipped, 1026 passed (2529 assertions)') ->toContain('Parallel: 3 processes'); })->skipOnWindows();