From 465c65243dd3dc61c0ef0ba8b0a969ecc8527ef4 Mon Sep 17 00:00:00 2001 From: Nuno Maduro Date: Wed, 24 May 2023 23:21:15 +0100 Subject: [PATCH] feat(describe): improves logic around hooks --- src/Concerns/Testable.php | 19 ++-- src/Exceptions/AfterAllWithinDescribe.php | 24 +++++ src/Exceptions/BeforeAllWithinDescribe.php | 24 +++++ src/Factories/TestCaseMethodFactory.php | 6 ++ src/Functions.php | 36 +++++++ src/PendingCalls.php | 115 +++++++++++++++++++++ src/PendingCalls/AfterEachCall.php | 27 +++-- src/PendingCalls/BeforeEachCall.php | 39 +++++-- src/PendingCalls/Concerns/Describable.php | 13 +++ src/PendingCalls/DescribeCall.php | 46 +++++++++ src/PendingCalls/TestCall.php | 16 ++- src/Repositories/AfterEachRepository.php | 13 ++- src/Repositories/BeforeEachRepository.php | 13 ++- src/Support/ChainableClosure.php | 16 +++ tests/Features/Describe.php | 61 +++++++++++ 15 files changed, 433 insertions(+), 35 deletions(-) create mode 100644 src/Exceptions/AfterAllWithinDescribe.php create mode 100644 src/Exceptions/BeforeAllWithinDescribe.php create mode 100644 src/PendingCalls.php create mode 100644 src/PendingCalls/Concerns/Describable.php create mode 100644 src/PendingCalls/DescribeCall.php create mode 100644 tests/Features/Describe.php diff --git a/src/Concerns/Testable.php b/src/Concerns/Testable.php index ce020c3a..48da241f 100644 --- a/src/Concerns/Testable.php +++ b/src/Concerns/Testable.php @@ -23,14 +23,19 @@ use Throwable; trait Testable { /** - * Test method description. + * Test method's test description. */ - private string $__description; + private string $__testDescription; + + /** + * Test method's describe description, if any. + */ + public ?string $__describeDescription = null; /** * Test "latest" method description. */ - private static string $__latestDescription; + private static string $__latestTestDescription; /** * The Test Case "test" closure. @@ -77,7 +82,7 @@ trait Testable if ($test->hasMethod($name)) { $method = $test->getMethod($name); - $this->__description = self::$__latestDescription = $method->description; + $this->__testDescription = self::$__latestTestDescription = $method->description; $this->__test = $method->getClosure($this); } } @@ -230,7 +235,7 @@ trait Testable { $method = TestSuite::getInstance()->tests->get(self::$__filename)->getMethod($this->name()); - $this->__description = self::$__latestDescription = $this->dataName() ? $method->description.' with '.$this->dataName() : $method->description; + $this->__testDescription = self::$__latestTestDescription = $this->dataName() ? $method->description.' with '.$this->dataName() : $method->description; $underlyingTest = Reflection::getFunctionVariable($this->__test, 'closure'); $testParameterTypes = array_values(Reflection::getFunctionArguments($underlyingTest)); @@ -315,7 +320,7 @@ trait Testable */ public function getPrintableTestCaseMethodName(): string { - return $this->__description; + return $this->__testDescription; } /** @@ -323,6 +328,6 @@ trait Testable */ public static function getLatestPrintableTestCaseMethodName(): string { - return self::$__latestDescription; + return self::$__latestTestDescription; } } diff --git a/src/Exceptions/AfterAllWithinDescribe.php b/src/Exceptions/AfterAllWithinDescribe.php new file mode 100644 index 00000000..4cdf2c2b --- /dev/null +++ b/src/Exceptions/AfterAllWithinDescribe.php @@ -0,0 +1,24 @@ +tests->get($this->filename); + $concrete->__describeDescription = $this->describing; // @phpstan-ignore-line $testCase->factoryProxies->proxy($concrete); $this->factoryProxies->proxy($concrete); diff --git a/src/Functions.php b/src/Functions.php index 0145219e..d9b15fdf 100644 --- a/src/Functions.php +++ b/src/Functions.php @@ -2,9 +2,13 @@ declare(strict_types=1); +use Pest\Exceptions\AfterAllWithinDescribe; +use Pest\Exceptions\BeforeAllWithinDescribe; use Pest\Expectation; +use Pest\PendingCalls; use Pest\PendingCalls\AfterEachCall; use Pest\PendingCalls\BeforeEachCall; +use Pest\PendingCalls\DescribeCall; use Pest\PendingCalls\TestCall; use Pest\PendingCalls\UsesCall; use Pest\Repositories\DatasetsRepository; @@ -35,6 +39,12 @@ if (! function_exists('beforeAll')) { */ function beforeAll(Closure $closure): void { + if (! is_null(PendingCalls::$describing)) { + $filename = Backtrace::file(); + + throw new BeforeAllWithinDescribe($filename); + } + TestSuite::getInstance()->beforeAll->set($closure); } } @@ -67,6 +77,26 @@ if (! function_exists('dataset')) { } } +if (! function_exists('describe')) { + /** + * Adds the given closure as a group of tests. The first argument + * is the group description; the second argument is a closure + * that contains the group tests. + * + * @return HigherOrderTapProxy|TestCall|TestCase|mixed + */ + function describe(string $description, Closure $tests): DescribeCall + { + $filename = Backtrace::testFile(); + + PendingCalls::startDescribe( + $describeCall = new DescribeCall(TestSuite::getInstance(), $filename, $description, $tests), + ); + + return $describeCall; + } +} + if (! function_exists('uses')) { /** * The uses function binds the given @@ -159,6 +189,12 @@ if (! function_exists('afterAll')) { */ function afterAll(Closure $closure): void { + if (! is_null(PendingCalls::$describing)) { + $filename = Backtrace::file(); + + throw new AfterAllWithinDescribe($filename); + } + TestSuite::getInstance()->afterAll->set($closure); } } diff --git a/src/PendingCalls.php b/src/PendingCalls.php new file mode 100644 index 00000000..1fba1c55 --- /dev/null +++ b/src/PendingCalls.php @@ -0,0 +1,115 @@ + + */ + public static array $beforeEachCalls = []; + + /** + * The list of test pending calls. + * + * @var array + */ + public static array $testCalls = []; + + /** + * The list of after each pending calls. + * + * @var array + */ + public static array $afterEachCalls = []; + + /** + * Sets the current describe call. + */ + public static function startDescribe(DescribeCall $describeCall): void + { + self::$describing = $describeCall->description; + + ($describeCall->tests)(); + } + + /** + * Adds a new before each call. + */ + public static function beforeEach(BeforeEachCall $beforeEachCall, Closure $setter): void + { + $setter($beforeEachCall->describing = self::$describing); + } + + /** + * Adds a new test call. + */ + public static function test(TestCall $testCall, Closure $setter): void + { + if (! is_null($testCall->describing = self::$describing)) { + self::$testCalls[] = [$testCall, $setter]; + } else { + $setter(); + } + } + + /** + * Adds a new before each call. + */ + public static function afterEach(AfterEachCall $afterEachCall, Closure $setter): void + { + if (! is_null(self::$describing)) { + $afterEachCall->describing = self::$describing; + + self::$afterEachCalls[] = [$afterEachCall, $setter]; + } else { + $setter(); + } + } + + public static function endDescribe(DescribeCall $describeCall): void + { + $describing = self::$describing; + + self::$describing = null; + + foreach (self::$beforeEachCalls as [$beforeEachCall, $setter]) { + $setter($describing); + } + + self::$beforeEachCalls = []; + + foreach (self::$testCalls as [$testCall, $setter]) { + /** @var TestCall $testCall */ + $testCall->testCaseMethod->description = '`'.$describeCall->description.'` '.$testCall->testCaseMethod->description; + + $setter($describing); + } + + self::$testCalls = []; + + foreach (self::$afterEachCalls as [$afterEachCall, $setter]) { + $setter($describing); + } + + self::$afterEachCalls = []; + } +} diff --git a/src/PendingCalls/AfterEachCall.php b/src/PendingCalls/AfterEachCall.php index 585fa197..55da4381 100644 --- a/src/PendingCalls/AfterEachCall.php +++ b/src/PendingCalls/AfterEachCall.php @@ -5,6 +5,8 @@ declare(strict_types=1); namespace Pest\PendingCalls; use Closure; +use Pest\PendingCalls; +use Pest\PendingCalls\Concerns\Describable; use Pest\Support\Backtrace; use Pest\Support\ChainableClosure; use Pest\Support\HigherOrderMessageCollection; @@ -16,6 +18,8 @@ use Pest\TestSuite; */ final class AfterEachCall { + use Describable; + /** * The "afterEach" closure. */ @@ -44,14 +48,23 @@ final class AfterEachCall */ public function __destruct() { - $proxies = $this->proxies; + PendingCalls::afterEach($this, function (string $describing = null) { + $proxies = $this->proxies; + + $afterEachTestCase = ChainableClosure::when( + fn () => is_null($describing) || $this->__describeDescription === $describing, // @phpstan-ignore-line + ChainableClosure::from(fn () => $proxies->chain($this), $this->closure)->bindTo($this, self::class), // @phpstan-ignore-line + )->bindTo($this, self::class); + + assert($afterEachTestCase instanceof Closure); + + $this->testSuite->afterEach->set( + $this->filename, + $this, + $afterEachTestCase, + ); + }); - $this->testSuite->afterEach->set( - $this->filename, - ChainableClosure::from(function () use ($proxies): void { - $proxies->chain($this); - }, $this->closure) - ); } /** diff --git a/src/PendingCalls/BeforeEachCall.php b/src/PendingCalls/BeforeEachCall.php index 2a2fb5f8..44aa85b3 100644 --- a/src/PendingCalls/BeforeEachCall.php +++ b/src/PendingCalls/BeforeEachCall.php @@ -5,6 +5,8 @@ declare(strict_types=1); namespace Pest\PendingCalls; use Closure; +use Pest\PendingCalls; +use Pest\PendingCalls\Concerns\Describable; use Pest\Support\Backtrace; use Pest\Support\ChainableClosure; use Pest\Support\HigherOrderMessageCollection; @@ -16,6 +18,8 @@ use Pest\TestSuite; */ final class BeforeEachCall { + use Describable; + /** * Holds the before each closure. */ @@ -35,7 +39,7 @@ final class BeforeEachCall * Creates a new Pending Call. */ public function __construct( - private readonly TestSuite $testSuite, + public readonly TestSuite $testSuite, private readonly string $filename, Closure $closure = null ) { @@ -50,17 +54,29 @@ final class BeforeEachCall */ public function __destruct() { - $testCaseProxies = $this->testCaseProxies; + PendingCalls::beforeEach($this, function (string $describing = null) { + $testCaseProxies = $this->testCaseProxies; - $this->testSuite->beforeEach->set( - $this->filename, - function (TestCall $testCall): void { - $this->testCallProxies->chain($testCall); - }, - ChainableClosure::from(function () use ($testCaseProxies): void { - $testCaseProxies->chain($this); - }, $this->closure), - ); + $beforeEachTestCall = function (TestCall $testCall) use ($describing): void { + if (is_null($describing) || ($this->describing === $testCall->describing && $testCall->describing === $describing)) { + $this->testCallProxies->chain($testCall); + } + }; + + $beforeEachTestCase = ChainableClosure::when( + fn () => is_null($describing) || $this->__describeDescription === $describing, // @phpstan-ignore-line + ChainableClosure::from(fn () => $testCaseProxies->chain($this), $this->closure)->bindTo($this, self::class), // @phpstan-ignore-line + )->bindTo($this, self::class); + + assert($beforeEachTestCase instanceof Closure); + + $this->testSuite->beforeEach->set( + $this->filename, + $this, + $beforeEachTestCall, + $beforeEachTestCase, + ); + }); } /** @@ -70,6 +86,7 @@ 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); diff --git a/src/PendingCalls/Concerns/Describable.php b/src/PendingCalls/Concerns/Describable.php new file mode 100644 index 00000000..4eac9d2d --- /dev/null +++ b/src/PendingCalls/Concerns/Describable.php @@ -0,0 +1,13 @@ + $arguments + */ + public function __call(string $name, array $arguments): self + { + foreach (PendingCalls::$testCalls as [$testCall]) { + $testCall->{$name}(...$arguments); // @phpstan-ignore-line + } + + return $this; + } +} diff --git a/src/PendingCalls/TestCall.php b/src/PendingCalls/TestCall.php index 99e45291..cb0359ad 100644 --- a/src/PendingCalls/TestCall.php +++ b/src/PendingCalls/TestCall.php @@ -10,6 +10,8 @@ use Pest\Factories\Covers\CoversClass; use Pest\Factories\Covers\CoversFunction; use Pest\Factories\Covers\CoversNothing; use Pest\Factories\TestCaseMethodFactory; +use Pest\PendingCalls; +use Pest\PendingCalls\Concerns\Describable; use Pest\Plugins\Only; use Pest\Support\Backtrace; use Pest\Support\Exporter; @@ -25,10 +27,12 @@ use PHPUnit\Framework\TestCase; */ final class TestCall { + use Describable; + /** * The Test Case Factory. */ - private readonly TestCaseMethodFactory $testCaseMethod; + public readonly TestCaseMethodFactory $testCaseMethod; /** * If test call is descriptionLess. @@ -48,7 +52,7 @@ final class TestCall $this->descriptionLess = $description === null; - $this->testSuite->beforeEach->get($filename)[0]($this); + $this->testSuite->beforeEach->get($this->filename)[0]($this); } /** @@ -316,12 +320,14 @@ final class TestCall private function addChain(string $file, int $line, string $name, array $arguments = null): self { $exporter = Exporter::default(); + $this->testCaseMethod ->chains ->add($file, $line, $name, $arguments); if ($this->descriptionLess) { Exporter::default(); + if ($this->testCaseMethod->description !== null) { $this->testCaseMethod->description .= ' → '; } @@ -338,6 +344,10 @@ final class TestCall */ public function __destruct() { - $this->testSuite->tests->set($this->testCaseMethod); + PendingCalls::test($this, function () { + $this->testCaseMethod->describing = $this->describing; + + $this->testSuite->tests->set($this->testCaseMethod); + }); } } diff --git a/src/Repositories/AfterEachRepository.php b/src/Repositories/AfterEachRepository.php index affc4493..b5a55135 100644 --- a/src/Repositories/AfterEachRepository.php +++ b/src/Repositories/AfterEachRepository.php @@ -6,7 +6,7 @@ namespace Pest\Repositories; use Closure; use Mockery; -use Pest\Exceptions\AfterEachAlreadyExist; +use Pest\PendingCalls\AfterEachCall; use Pest\Support\ChainableClosure; use Pest\Support\NullClosure; @@ -23,13 +23,18 @@ final class AfterEachRepository /** * Sets a after each closure. */ - public function set(string $filename, Closure $closure): void + public function set(string $filename, AfterEachCall $afterEachCall, Closure $afterEachTestCase): void { if (array_key_exists($filename, $this->state)) { - throw new AfterEachAlreadyExist($filename); + $fromAfterEachTestCase = $this->state[$filename]; + + $afterEachTestCase = ChainableClosure::from($fromAfterEachTestCase, $afterEachTestCase) + ->bindTo($afterEachCall, $afterEachCall::class); } - $this->state[$filename] = $closure; + assert($afterEachTestCase instanceof Closure); + + $this->state[$filename] = $afterEachTestCase; } /** diff --git a/src/Repositories/BeforeEachRepository.php b/src/Repositories/BeforeEachRepository.php index e74f78ed..d5454d32 100644 --- a/src/Repositories/BeforeEachRepository.php +++ b/src/Repositories/BeforeEachRepository.php @@ -5,7 +5,8 @@ declare(strict_types=1); namespace Pest\Repositories; use Closure; -use Pest\Exceptions\BeforeEachAlreadyExist; +use Pest\PendingCalls\BeforeEachCall; +use Pest\Support\ChainableClosure; use Pest\Support\NullClosure; /** @@ -21,12 +22,18 @@ final class BeforeEachRepository /** * Sets a before each closure. */ - public function set(string $filename, Closure $beforeEachTestCall, Closure $beforeEachTestCase): void + public function set(string $filename, BeforeEachCall $beforeEachCall, Closure $beforeEachTestCall, Closure $beforeEachTestCase): void { if (array_key_exists($filename, $this->state)) { - throw new BeforeEachAlreadyExist($filename); + [$fromBeforeEachTestCall, $fromBeforeEachTestCase] = $this->state[$filename]; + + $beforeEachTestCall = ChainableClosure::from($fromBeforeEachTestCall, $beforeEachTestCall)->bindTo($beforeEachCall, $beforeEachCall::class); + $beforeEachTestCase = ChainableClosure::from($fromBeforeEachTestCase, $beforeEachTestCase)->bindTo($beforeEachCall, $beforeEachCall::class); } + assert($beforeEachTestCall instanceof Closure); + assert($beforeEachTestCase instanceof Closure); + $this->state[$filename] = [$beforeEachTestCall, $beforeEachTestCase]; } diff --git a/src/Support/ChainableClosure.php b/src/Support/ChainableClosure.php index 55dcca3a..7acc0953 100644 --- a/src/Support/ChainableClosure.php +++ b/src/Support/ChainableClosure.php @@ -12,6 +12,22 @@ use Pest\Exceptions\ShouldNotHappen; */ final class ChainableClosure { + /** + * Calls the given `$closure` when the given condition is true. + */ + public static function when(Closure $condition, Closure $next): Closure + { + return function () use ($condition, $next): void { + if (! is_object($this)) { // @phpstan-ignore-line + throw ShouldNotHappen::fromMessage('$this not bound to chainable closure.'); + } + + if (\Pest\Support\Closure::bind($condition, $this, self::class)(...func_get_args())) { + \Pest\Support\Closure::bind($next, $this, self::class)(...func_get_args()); + } + }; + } + /** * Calls the given `$closure` and chains the `$next` closure. */ diff --git a/tests/Features/Describe.php b/tests/Features/Describe.php new file mode 100644 index 00000000..f3740abe --- /dev/null +++ b/tests/Features/Describe.php @@ -0,0 +1,61 @@ + $this->count = 1); + +test('before each', function () { + expect($this->count)->toBe(1); +}); + +describe('describable', function () { + beforeEach(function () { + $this->count++; + }); + + test('basic', function () { + expect(true)->toBeTrue(); + }); + + test('before each', function () { + expect($this->count)->toBe(2); + }); + + afterEach(function () { + expect($this->count)->toBe(2); + }); +}); + +describe('another describable', function () { + beforeEach(function () { + $this->count++; + }); + + test('basic', function () { + expect(true)->toBeTrue(); + }); + + test('before each', function () { + expect($this->count)->toBe(2); + }); + + afterEach(function () { + expect($this->count)->toBe(2); + }); +}); + +test('should not fail')->todo()->shouldNotRun(); + +test('previous describable before each does not get applied here', function () { + expect($this->count)->toBe(1); +}); + +afterEach(fn () => expect($this->count)->toBe(is_null($this->__describeDescription) ? 1 : 2)); + +describe('todo', function () { + beforeEach()->todo(); + + test('should not fail')->shouldNotRun(); +}); + +describe('todo after describe', function () { + test('should not fail')->shouldNotRun(); +})->todo();