diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d8a53e4..51343a9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,33 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [v1.11.0 (2021-07-21)](https://github.com/pestphp/pest/compare/v1.10.0...v1.11.0) +### Added +- Support for interacting with datasets in higher order tests ([#352](https://github.com/pestphp/pest/pull/352)) + +### Changed +- The unit test stub now uses the expectation API ([#348](https://github.com/pestphp/pest/pull/348)) + +### Fixed +- PhpStorm will no longer show 0 assertions in the output ([#349](https://github.com/pestphp/pest/pull/349)) + +## [v1.10.0 (2021-07-12)](https://github.com/pestphp/pest/compare/v1.9.1...v1.10.0) +### Added +- The ability to use higher order expectations inside higher order tests ([#341](https://github.com/pestphp/pest/pull/341)) + +## [v1.9.1 (2021-07-11)](https://github.com/pestphp/pest/compare/v1.9.0...v1.9.1) +### Fixed +- Callable `expect` values in higher order tests failing if the value was an existing method name ([#334](https://github.com/pestphp/pest/pull/344)) + +## [v1.9.0 (2021-07-09)](https://github.com/pestphp/pest/compare/v1.8.0...v1.9.0) +### Changed +- You may now pass just an exception message when using the `throws` method ([#339](https://github.com/pestphp/pest/pull/339)) + +## [v1.8.0 (2021-07-08)](https://github.com/pestphp/pest/compare/v1.7.1...v1.8.0) +### Added +- A new `tap` and test case aware `expect` methods for higher order tests ([#331](https://github.com/pestphp/pest/pull/331)) +- Access to test case methods and properties when using `skip` ([#338](https://github.com/pestphp/pest/pull/338)) + ## [v1.7.1 (2021-06-24)](https://github.com/pestphp/pest/compare/v1.7.0...v1.7.1) ### Fixed - The `and` method not being usable in Higher Order expectations ([#330](https://github.com/pestphp/pest/pull/330)) diff --git a/README.md b/README.md index 765fcafe..6823ffee 100644 --- a/README.md +++ b/README.md @@ -21,8 +21,9 @@ We would like to extend our thanks to the following sponsors for funding Pest de ### Premium Sponsors -- **[Scout APM](https://scoutapm.com)** - **[Akaunting](https://akaunting.com)** +- **[Scout APM](https://scoutapm.com)** - **[Meema](https://meema.io/)** +- **[Spatie](https://spatie.be/)** Pest was created by **[Nuno Maduro](https://twitter.com/enunomaduro)** under the **[Sponsorware license](https://github.com/sponsorware/docs)**. It got open-sourced and is now licensed under the **[MIT license](https://opensource.org/licenses/MIT)**. diff --git a/src/Logging/TeamCity.php b/src/Logging/TeamCity.php index fded41e4..14704aab 100644 --- a/src/Logging/TeamCity.php +++ b/src/Logging/TeamCity.php @@ -8,9 +8,11 @@ use function getmypid; use Pest\Concerns\Testable; use PHPUnit\Framework\AssertionFailedError; use PHPUnit\Framework\Test; +use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestResult; use PHPUnit\Framework\TestSuite; use PHPUnit\Framework\Warning; +use PHPUnit\Runner\PhptTestCase; use PHPUnit\TextUI\DefaultResultPrinter; use function round; use function str_replace; @@ -137,6 +139,12 @@ final class TeamCity extends DefaultResultPrinter return; } + if ($test instanceof TestCase) { + $this->numAssertions += $test->getNumAssertions(); + } elseif ($test instanceof PhptTestCase) { + $this->numAssertions++; + } + $this->printEvent('testFinished', [ self::NAME => $test->getName(), self::DURATION => self::toMilliseconds($time), diff --git a/src/PendingObjects/TestCall.php b/src/PendingObjects/TestCall.php index 6cf9f0f3..4a4b78d5 100644 --- a/src/PendingObjects/TestCall.php +++ b/src/PendingObjects/TestCall.php @@ -7,14 +7,15 @@ namespace Pest\PendingObjects; use Closure; use Pest\Factories\TestCaseFactory; use Pest\Support\Backtrace; +use Pest\Support\HigherOrderCallables; use Pest\Support\NullClosure; use Pest\TestSuite; use SebastianBergmann\Exporter\Exporter; /** - * @method \Pest\Expectations\Expectation expect(mixed $value) - * * @internal + * + * @mixin HigherOrderCallables */ final class TestCall { @@ -58,11 +59,15 @@ final class TestCall /** * Asserts that the test throws the given `$exceptionClass` when called. */ - public function throws(string $exceptionClass, string $exceptionMessage = null): TestCall + public function throws(string $exception, string $exceptionMessage = null): TestCall { - $this->testCaseFactory - ->proxies - ->add(Backtrace::file(), Backtrace::line(), 'expectException', [$exceptionClass]); + if (class_exists($exception)) { + $this->testCaseFactory + ->proxies + ->add(Backtrace::file(), Backtrace::line(), 'expectException', [$exception]); + } else { + $exceptionMessage = $exception; + } if (is_string($exceptionMessage)) { $this->testCaseFactory @@ -145,21 +150,37 @@ final class TestCall ? $conditionOrMessage : $message; - if ($condition() !== false) { - $this->testCaseFactory - ->chains - ->add(Backtrace::file(), Backtrace::line(), 'markTestSkipped', [$message]); - } + $this->testCaseFactory + ->chains + ->addWhen($condition, Backtrace::file(), Backtrace::line(), 'markTestSkipped', [$message]); return $this; } + /** + * Saves the property accessors to be used on the target. + */ + public function __get(string $name): self + { + return $this->addChain($name); + } + /** * Saves the calls to be used on the target. * * @param array $arguments */ public function __call(string $name, array $arguments): self + { + return $this->addChain($name, $arguments); + } + + /** + * Add a chain to the test case factory. Omitting the arguments will treat it as a property accessor. + * + * @param array|null $arguments + */ + private function addChain(string $name, array $arguments = null): self { $this->testCaseFactory ->chains @@ -170,7 +191,9 @@ final class TestCall if ($this->testCaseFactory->description !== null) { $this->testCaseFactory->description .= ' → '; } - $this->testCaseFactory->description .= sprintf('%s %s', $name, $exporter->shortenedRecursiveExport($arguments)); + $this->testCaseFactory->description .= $arguments === null + ? $name + : sprintf('%s %s', $name, $exporter->shortenedRecursiveExport($arguments)); } return $this; diff --git a/src/Pest.php b/src/Pest.php index d54a4413..cdc57677 100644 --- a/src/Pest.php +++ b/src/Pest.php @@ -6,7 +6,7 @@ namespace Pest; function version(): string { - return '1.7.1'; + return '1.11.0'; } function testDirectory(string $file = ''): string diff --git a/src/Plugins/Coverage.php b/src/Plugins/Coverage.php index cc03bd17..6d993717 100644 --- a/src/Plugins/Coverage.php +++ b/src/Plugins/Coverage.php @@ -81,7 +81,6 @@ final class Coverage implements AddsOutput, HandlesArguments } if ($input->getOption(self::MIN_OPTION) !== null) { - /* @phpstan-ignore-next-line */ $this->coverageMin = (float) $input->getOption(self::MIN_OPTION); } diff --git a/src/Support/HigherOrderCallables.php b/src/Support/HigherOrderCallables.php new file mode 100644 index 00000000..b3bb633e --- /dev/null +++ b/src/Support/HigherOrderCallables.php @@ -0,0 +1,66 @@ +target = $target; + } + + /** + * @template TValue + * + * Create a new expectation. Callable values will be executed prior to returning the new expectation. + * + * @param callable|TValue $value + * + * @return Expectation + */ + public function expect($value) + { + return new Expectation($value instanceof Closure ? Reflection::bindCallableWithData($value) : $value); + } + + /** + * @template TValue + * + * Create a new expectation. Callable values will be executed prior to returning the new expectation. + * + * @param callable|TValue $value + * + * @return Expectation + */ + public function and($value) + { + return $this->expect($value); + } + + /** + * Tap into the test case to perform an action and return the test case. + * + * @return TestCall|TestCase|object + */ + public function tap(callable $callable) + { + Reflection::bindCallableWithData($callable); + + return $this->target; + } +} diff --git a/src/Support/HigherOrderMessage.php b/src/Support/HigherOrderMessage.php index 9f363b5a..67993bc5 100644 --- a/src/Support/HigherOrderMessage.php +++ b/src/Support/HigherOrderMessage.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Pest\Support; +use Closure; use ReflectionClass; use Throwable; @@ -33,33 +34,40 @@ final class HigherOrderMessage public $line; /** - * The method name. + * The method or property name to access. * * @readonly * * @var string */ - public $methodName; + public $name; /** * The arguments. * - * @var array + * @var array|null * * @readonly */ public $arguments; + /** + * An optional condition that will determine if the message will be executed. + * + * @var callable(): bool|null + */ + public $condition = null; + /** * Creates a new higher order message. * - * @param array $arguments + * @param array|null $arguments */ - public function __construct(string $filename, int $line, string $methodName, array $arguments) + public function __construct(string $filename, int $line, string $methodName, $arguments) { $this->filename = $filename; $this->line = $line; - $this->methodName = $methodName; + $this->name = $methodName; $this->arguments = $arguments; } @@ -70,24 +78,58 @@ final class HigherOrderMessage */ public function call(object $target) { + /* @phpstan-ignore-next-line */ + if (is_callable($this->condition) && call_user_func(Closure::bind($this->condition, $target)) === false) { + return $target; + } + + if ($this->hasHigherOrderCallable()) { + /* @phpstan-ignore-next-line */ + return (new HigherOrderCallables($target))->{$this->name}(...$this->arguments); + } + try { - return Reflection::call($target, $this->methodName, $this->arguments); + return is_array($this->arguments) + ? Reflection::call($target, $this->name, $this->arguments) + : $target->{$this->name}; /* @phpstan-ignore-line */ } catch (Throwable $throwable) { Reflection::setPropertyValue($throwable, 'file', $this->filename); Reflection::setPropertyValue($throwable, 'line', $this->line); - if ($throwable->getMessage() === self::getUndefinedMethodMessage($target, $this->methodName)) { + if ($throwable->getMessage() === self::getUndefinedMethodMessage($target, $this->name)) { /** @var ReflectionClass $reflection */ $reflection = new ReflectionClass($target); /* @phpstan-ignore-next-line */ $reflection = $reflection->getParentClass() ?: $reflection; - Reflection::setPropertyValue($throwable, 'message', sprintf('Call to undefined method %s::%s()', $reflection->getName(), $this->methodName)); + Reflection::setPropertyValue($throwable, 'message', sprintf('Call to undefined method %s::%s()', $reflection->getName(), $this->name)); } throw $throwable; } } + /** + * Indicates that this message should only be called when the given condition is true. + * + * @param callable(): bool $condition + */ + public function when(callable $condition): self + { + $this->condition = $condition; + + return $this; + } + + /** + * Determines whether or not there exists a higher order callable with the message name. + * + * @return bool + */ + private function hasHigherOrderCallable() + { + return in_array($this->name, get_class_methods(HigherOrderCallables::class), true); + } + private static function getUndefinedMethodMessage(object $target, string $methodName): string { if (\PHP_MAJOR_VERSION >= 8) { diff --git a/src/Support/HigherOrderMessageCollection.php b/src/Support/HigherOrderMessageCollection.php index f16765e1..b107bdba 100644 --- a/src/Support/HigherOrderMessageCollection.php +++ b/src/Support/HigherOrderMessageCollection.php @@ -17,11 +17,21 @@ final class HigherOrderMessageCollection /** * Adds a new higher order message to the collection. * - * @param array $arguments + * @param array|null $arguments */ - public function add(string $filename, int $line, string $methodName, array $arguments): void + public function add(string $filename, int $line, string $name, array $arguments = null): void { - $this->messages[] = new HigherOrderMessage($filename, $line, $methodName, $arguments); + $this->messages[] = new HigherOrderMessage($filename, $line, $name, $arguments); + } + + /** + * Adds a new higher order message to the collection if the callable condition is does not return false. + * + * @param array|null $arguments + */ + public function addWhen(callable $condition, string $filename, int $line, string $name, array $arguments = null): void + { + $this->messages[] = (new HigherOrderMessage($filename, $line, $name, $arguments))->when($condition); } /** diff --git a/src/Support/HigherOrderTapProxy.php b/src/Support/HigherOrderTapProxy.php index 4b5664d3..3c4f3968 100644 --- a/src/Support/HigherOrderTapProxy.php +++ b/src/Support/HigherOrderTapProxy.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Pest\Support; +use PHPUnit\Framework\TestCase; use ReflectionClass; use Throwable; @@ -17,16 +18,14 @@ final class HigherOrderTapProxy /** * The target being tapped. * - * @var mixed + * @var TestCase */ public $target; /** * Create a new tap proxy instance. - * - * @param mixed $target */ - public function __construct($target) + public function __construct(TestCase $target) { $this->target = $target; } diff --git a/src/Support/Reflection.php b/src/Support/Reflection.php index 1719b9f6..44cd754c 100644 --- a/src/Support/Reflection.php +++ b/src/Support/Reflection.php @@ -42,15 +42,40 @@ final class Reflection } if (is_callable($method)) { - return Closure::fromCallable($method)->bindTo( - TestSuite::getInstance()->test - )(...$args); + return static::bindCallable($method, $args); } throw $exception; } } + /** + * Bind a callable to the TestCase and return the result. + * + * @param array $args + * + * @return mixed + */ + public static function bindCallable(callable $callable, array $args = []) + { + return Closure::fromCallable($callable)->bindTo(TestSuite::getInstance()->test)(...$args); + } + + /** + * Bind a callable to the TestCase and return the result, + * passing in the current dataset values as arguments. + * + * @return mixed + */ + public static function bindCallableWithData(callable $callable) + { + $test = TestSuite::getInstance()->test; + + return $test === null + ? static::bindCallable($callable) + : Closure::fromCallable($callable)->bindTo($test)(...$test->getProvidedData()); + } + /** * Infers the file name from the given closure. */ @@ -85,10 +110,6 @@ final class Reflection } } - if ($reflectionProperty === null) { - throw ShouldNotHappen::fromMessage('Reflection property not found.'); - } - $reflectionProperty->setAccessible(true); return $reflectionProperty->getValue($object); @@ -119,10 +140,6 @@ final class Reflection } } - if ($reflectionProperty === null) { - throw ShouldNotHappen::fromMessage('Reflection property not found.'); - } - $reflectionProperty->setAccessible(true); $reflectionProperty->setValue($object, $value); } diff --git a/stubs/Unit.php b/stubs/Unit.php index 0f429e88..8c2702d6 100644 --- a/stubs/Unit.php +++ b/stubs/Unit.php @@ -1,5 +1,5 @@ toBeTrue(); }); diff --git a/tests/.snapshots/success.txt b/tests/.snapshots/success.txt index aa5966c7..66fbdcbd 100644 --- a/tests/.snapshots/success.txt +++ b/tests/.snapshots/success.txt @@ -410,6 +410,8 @@ PASS Tests\Features\HigherOrderTests ✓ it proxies calls to object ✓ it is capable doing multiple assertions + ✓ it resolves expect callables correctly + ✓ it can tap into the test WARN Tests\Features\Incompleted … incompleted @@ -430,8 +432,8 @@ PASS Tests\Features\PendingHigherOrderTests ✓ get 'foo' - ✓ get 'foo' → get 'bar' → expect true → toBeTrue - ✓ get 'foo' → expect true → toBeTrue + ✓ get 'foo' → get 'bar' → expect true → toBeTrue + ✓ get 'foo' → expect true → toBeTrue WARN Tests\Features\Skip ✓ it do not skips @@ -580,5 +582,5 @@ ✓ it is a test ✓ it uses correct parent class - Tests: 4 incompleted, 7 skipped, 364 passed - \ No newline at end of file + Tests: 4 incompleted, 7 skipped, 365 passed + diff --git a/tests/Features/Exceptions.php b/tests/Features/Exceptions.php index 5e7e51a9..37eaaeb9 100644 --- a/tests/Features/Exceptions.php +++ b/tests/Features/Exceptions.php @@ -13,3 +13,7 @@ it('catch exceptions', function () { it('catch exceptions and messages', function () { throw new Exception('Something bad happened'); })->throws(Exception::class, 'Something bad happened'); + +it('can just define the message', function () { + throw new Exception('Something bad happened'); +})->throws('Something bad happened'); diff --git a/tests/Features/Expect/HigherOrder/methods.php b/tests/Features/Expect/HigherOrder/methods.php index 66329b73..d6da636b 100644 --- a/tests/Features/Expect/HigherOrder/methods.php +++ b/tests/Features/Expect/HigherOrder/methods.php @@ -67,6 +67,13 @@ it('can handle nested method calls', function () { ->books()->each->toBeArray(); }); +it('works with higher order tests') + ->expect(new HasMethods()) + ->newInstance()->newInstance()->name()->toEqual('Has Methods')->toBeString() + ->newInstance()->name()->toEqual('Has Methods')->not->toBeArray + ->name()->toEqual('Has Methods') + ->books()->each->toBeArray; + class HasMethods { public function name() diff --git a/tests/Features/Expect/HigherOrder/methodsAndProperties.php b/tests/Features/Expect/HigherOrder/methodsAndProperties.php index 08c4a3bc..b98c9f16 100644 --- a/tests/Features/Expect/HigherOrder/methodsAndProperties.php +++ b/tests/Features/Expect/HigherOrder/methodsAndProperties.php @@ -22,6 +22,13 @@ it('can handle nested methods and properties', function () { ->newInstance()->books()->toBeArray(); }); +it('works with higher order tests') + ->expect(new HasMethodsAndProperties()) + ->meta->foo->bar->toBeString()->toEqual('baz')->not->toBeInt + ->newInstance()->meta->foo->toBeArray + ->newInstance()->multiply(2, 2)->toEqual(4)->not->toEqual(5) + ->newInstance()->books()->toBeArray(); + it('can start a new higher order expectation using the and syntax', function () { expect(new HasMethodsAndProperties()) ->toBeInstanceOf(HasMethodsAndProperties::class) @@ -33,6 +40,14 @@ it('can start a new higher order expectation using the and syntax', function () expect(static::getCount())->toEqual(4); }); +it('can start a new higher order expectation using the and syntax in higher order tests') + ->expect(new HasMethodsAndProperties()) + ->toBeInstanceOf(HasMethodsAndProperties::class) + ->meta->toBeArray + ->and(['foo' => 'bar']) + ->toBeArray() + ->foo->toEqual('bar'); + class HasMethodsAndProperties { public $name = 'Has Methods and Properties'; diff --git a/tests/Features/Expect/HigherOrder/properties.php b/tests/Features/Expect/HigherOrder/properties.php index 154d17e4..5595b9ab 100644 --- a/tests/Features/Expect/HigherOrder/properties.php +++ b/tests/Features/Expect/HigherOrder/properties.php @@ -64,6 +64,11 @@ it('works with nested properties', function () { ->posts->toBeArray()->toHaveCount(2); }); +it('works with higher order tests') + ->expect(new HasProperties()) + ->nested->foo->bar->toBeString()->toEqual('baz') + ->posts->toBeArray()->toHaveCount(2); + class HasProperties { public $name = 'foo'; diff --git a/tests/Features/HigherOrderTests.php b/tests/Features/HigherOrderTests.php index 77e87ce4..e2ff0686 100644 --- a/tests/Features/HigherOrderTests.php +++ b/tests/Features/HigherOrderTests.php @@ -1,5 +1,7 @@ assertTrue(true); it('proxies calls to object')->assertTrue(true); @@ -8,4 +10,37 @@ it('is capable doing multiple assertions') ->assertTrue(true) ->assertFalse(false); +it('resolves expect callables correctly') + ->expect(function () { return 'foo'; }) + ->toBeString() + ->toBe('foo') + ->and('bar') + ->toBeString() + ->toBe('bar'); + +test('does not treat method names as callables') + ->expect('it')->toBeString(); + +it('can tap into the test') + ->expect('foo')->toBeString() + ->tap(function () { expect($this)->toBeInstanceOf(TestCase::class); }) + ->toBe('foo') + ->and('hello world')->toBeString(); + +it('can pass datasets into the expect callables') + ->with([[1, 2, 3]]) + ->expect(function (...$numbers) { return $numbers; })->toBe([1, 2, 3]) + ->and(function (...$numbers) { return $numbers; })->toBe([1, 2, 3]); + +it('can pass datasets into the tap callable') + ->with([[1, 2, 3]]) + ->tap(function (...$numbers) { expect($numbers)->toBe([1, 2, 3]); }); + +it('can pass shared datasets into callables') + ->with('numbers.closure.wrapped') + ->expect(function ($value) { return $value; }) + ->and(function ($value) { return $value; }) + ->tap(function ($value) { expect($value)->toBeInt(); }) + ->toBeInt(); + afterEach()->assertTrue(true); diff --git a/tests/Features/Skip.php b/tests/Features/Skip.php index 66d6fe62..f5d867ca 100644 --- a/tests/Features/Skip.php +++ b/tests/Features/Skip.php @@ -1,5 +1,9 @@ shouldSkip = true; +}); + it('do not skips') ->skip(false) ->assertTrue(true); @@ -31,3 +35,12 @@ it('skips with condition and message') it('skips when skip after assertion') ->assertTrue(true) ->skip(); + +it('can use something in the test case as a condition') + ->skip(function () { return $this->shouldSkip; }, 'This test was skipped') + ->assertTrue(false); + +it('can user higher order callables and skip') + ->skip(function () { return $this->shouldSkip; }) + ->expect(function () { return $this->shouldSkip; }) + ->toBeFalse();