diff --git a/CHANGELOG.md b/CHANGELOG.md index e602ee99..0b05a97d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,31 @@ 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.13.0 (2021-07-28)](https://github.com/pestphp/pest/compare/v1.12.0...v1.13.0) +### Added +- `toBeIn` expectation ([#363](https://github.com/pestphp/pest/pull/363)) + +### Fixed +- `skip` with false condition marking test as skipped ([22b822c](https://github.com/pestphp/pest/commit/22b822ce87a3d19d84960fa5c93eb286820b525d)) + +## [v1.12.0 (2021-07-26)](https://github.com/pestphp/pest/compare/v1.11.0...v1.12.0) +### Added +- `--force` option to override tests in `pest:test` artisan command ([#353](https://github.com/pestphp/pest/pull/353)) +- Support for PHPUnit `^9.3.7` ([ca9d783](https://github.com/pestphp/pest/commit/ca9d783cf942a2caabc85ff7a728c7f28350c67a)) + +### Fixed +- `beforeAll` and `afterAll` behind called multiple times per test ([#357](https://github.com/pestphp/pest/pull/357)) + +## [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)) diff --git a/README.md b/README.md index 765fcafe..541d9f24 100644 --- a/README.md +++ b/README.md @@ -21,8 +21,10 @@ 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)** +- **[Codecourse](https://codecourse.com/)** - **[Meema](https://meema.io/)** +- **[Scout APM](https://scoutapm.com)** +- **[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)**. +Pest is an open-sourced software licensed under the **[MIT license](https://opensource.org/licenses/MIT)**. diff --git a/composer.json b/composer.json index 2224ac7f..bee8f8b7 100644 --- a/composer.json +++ b/composer.json @@ -20,7 +20,7 @@ "php": "^7.3 || ^8.0", "nunomaduro/collision": "^5.4.0", "pestphp/pest-plugin": "^1.0.0", - "phpunit/phpunit": ">= 9.3.7 <= 9.5.6" + "phpunit/phpunit": "^9.3.7" }, "autoload": { "psr-4": { diff --git a/src/Concerns/Testable.php b/src/Concerns/Testable.php index 5c72e0f3..d8137311 100644 --- a/src/Concerns/Testable.php +++ b/src/Concerns/Testable.php @@ -73,6 +73,8 @@ trait Testable { $this->__test = $test; $this->__description = $description; + self::$beforeAll = null; + self::$afterAll = null; parent::__construct('__test', $data); } diff --git a/src/Exceptions/DatasetMissing.php b/src/Exceptions/DatasetMissing.php new file mode 100644 index 00000000..b8f0cb2d --- /dev/null +++ b/src/Exceptions/DatasetMissing.php @@ -0,0 +1,36 @@ + $args A map of argument names to their typee + */ + public function __construct(string $file, string $name, array $args) + { + parent::__construct(sprintf( + "A test with the description '%s' has %d argument(s) ([%s]) and no dataset(s) provided in %s", + $name, + count($args), + implode(', ', array_map(static function (string $arg, string $type): string { + return sprintf('%s $%s', $type, $arg); + }, array_keys($args), $args)), + $file, + )); + } +} diff --git a/src/Expectation.php b/src/Expectation.php index e41f21d0..e76a30bd 100644 --- a/src/Expectation.php +++ b/src/Expectation.php @@ -371,6 +371,18 @@ final class Expectation return $this; } + /** + * Asserts that the value is one of the given values. + * + * @param iterable $values + */ + public function toBeIn(iterable $values): Expectation + { + Assert::assertContains($this->value, $values); + + return $this; + } + /** * Asserts that the value is infinite. */ diff --git a/src/Factories/TestCaseFactory.php b/src/Factories/TestCaseFactory.php index 6efe63f4..bc75f5c1 100644 --- a/src/Factories/TestCaseFactory.php +++ b/src/Factories/TestCaseFactory.php @@ -228,4 +228,13 @@ final class TestCaseFactory return $classFQN; } + + /** + * Determine if the test case will receive argument input from Pest, or not. + */ + public function receivesArguments(): bool + { + return count($this->datasets) > 0 + || $this->factoryProxies->count('addDependencies') > 0; + } } diff --git a/src/Laravel/Commands/PestTestCommand.php b/src/Laravel/Commands/PestTestCommand.php index 55d9dc3a..ee18e076 100644 --- a/src/Laravel/Commands/PestTestCommand.php +++ b/src/Laravel/Commands/PestTestCommand.php @@ -21,7 +21,7 @@ final class PestTestCommand extends Command * * @var string */ - protected $signature = 'pest:test {name : The name of the file} {--unit : Create a unit test} {--dusk : Create a Dusk test} {--test-directory=tests : The name of the tests directory}'; + protected $signature = 'pest:test {name : The name of the file} {--unit : Create a unit test} {--dusk : Create a Dusk test} {--test-directory=tests : The name of the tests directory} {--force : Overwrite the existing test file with the same name}'; /** * The console command description. @@ -56,7 +56,7 @@ final class PestTestCommand extends Command File::makeDirectory(dirname($target), 0777, true, true); } - if (File::exists($target)) { + if (File::exists($target) && !(bool) $this->option('force')) { throw new InvalidConsoleArgument(sprintf('%s already exist', $target)); } diff --git a/src/PendingObjects/TestCall.php b/src/PendingObjects/TestCall.php index 81bf9243..be839bff 100644 --- a/src/PendingObjects/TestCall.php +++ b/src/PendingObjects/TestCall.php @@ -148,6 +148,9 @@ final class TestCall ? $conditionOrMessage : $message; + /** @var callable(): bool $condition */ + $condition = $condition->bindTo(null); + $this->testCaseFactory ->chains ->addWhen($condition, Backtrace::file(), Backtrace::line(), 'markTestSkipped', [$message]); diff --git a/src/Pest.php b/src/Pest.php index e694346c..d9efe05f 100644 --- a/src/Pest.php +++ b/src/Pest.php @@ -6,7 +6,7 @@ namespace Pest; function version(): string { - return '1.10.0'; + return '1.13.0'; } function testDirectory(string $file = ''): string diff --git a/src/Repositories/TestRepository.php b/src/Repositories/TestRepository.php index b2eb4893..47684548 100644 --- a/src/Repositories/TestRepository.php +++ b/src/Repositories/TestRepository.php @@ -5,11 +5,13 @@ declare(strict_types=1); namespace Pest\Repositories; use Closure; +use Pest\Exceptions\DatasetMissing; use Pest\Exceptions\ShouldNotHappen; use Pest\Exceptions\TestAlreadyExist; use Pest\Exceptions\TestCaseAlreadyInUse; use Pest\Exceptions\TestCaseClassOrTraitNotFound; use Pest\Factories\TestCaseFactory; +use Pest\Support\Reflection; use Pest\Support\Str; use Pest\TestSuite; use PHPUnit\Framework\TestCase; @@ -140,6 +142,14 @@ final class TestRepository throw new TestAlreadyExist($test->filename, $test->description); } + if (!$test->receivesArguments()) { + $arguments = Reflection::getFunctionArguments($test->test); + + if (count($arguments) > 0) { + throw new DatasetMissing($test->filename, $test->description, $arguments); + } + } + $this->state[sprintf('%s%s%s', $test->filename, self::SEPARATOR, $test->description)] = $test; } } diff --git a/src/Support/HigherOrderCallables.php b/src/Support/HigherOrderCallables.php index 8b4dc817..b3bb633e 100644 --- a/src/Support/HigherOrderCallables.php +++ b/src/Support/HigherOrderCallables.php @@ -35,7 +35,7 @@ final class HigherOrderCallables */ public function expect($value) { - return new Expectation($value instanceof Closure ? Reflection::bindCallable($value) : $value); + return new Expectation($value instanceof Closure ? Reflection::bindCallableWithData($value) : $value); } /** @@ -59,7 +59,7 @@ final class HigherOrderCallables */ public function tap(callable $callable) { - Reflection::bindCallable($callable); + Reflection::bindCallableWithData($callable); return $this->target; } diff --git a/src/Support/HigherOrderMessageCollection.php b/src/Support/HigherOrderMessageCollection.php index b107bdba..a6634685 100644 --- a/src/Support/HigherOrderMessageCollection.php +++ b/src/Support/HigherOrderMessageCollection.php @@ -53,4 +53,20 @@ final class HigherOrderMessageCollection $message->call($target); } } + + /** + * Count the number of messages with the given name. + * + * @param string $name A higher order message name (usually a method name) + */ + public function count(string $name): int + { + return array_reduce( + $this->messages, + static function (int $total, HigherOrderMessage $message) use ($name): int { + return $total + (int) ($name === $message->name); + }, + 0, + ); + } } diff --git a/src/Support/Reflection.php b/src/Support/Reflection.php index fbe7ba40..44cd754c 100644 --- a/src/Support/Reflection.php +++ b/src/Support/Reflection.php @@ -12,6 +12,7 @@ use ReflectionException; use ReflectionFunction; use ReflectionNamedType; use ReflectionParameter; +use ReflectionUnionType; /** * @internal @@ -60,6 +61,21 @@ final class Reflection 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. */ @@ -94,10 +110,6 @@ final class Reflection } } - if ($reflectionProperty === null) { - throw ShouldNotHappen::fromMessage('Reflection property not found.'); - } - $reflectionProperty->setAccessible(true); return $reflectionProperty->getValue($object); @@ -128,10 +140,6 @@ final class Reflection } } - if ($reflectionProperty === null) { - throw ShouldNotHappen::fromMessage('Reflection property not found.'); - } - $reflectionProperty->setAccessible(true); $reflectionProperty->setValue($object, $value); } @@ -163,4 +171,37 @@ final class Reflection return $name; } + + /** + * Receive a map of function argument names to their types. + * + * @return array + */ + public static function getFunctionArguments(Closure $function): array + { + $parameters = (new ReflectionFunction($function))->getParameters(); + $arguments = []; + + foreach ($parameters as $parameter) { + /** @var ReflectionNamedType|ReflectionUnionType|null $types */ + $types = ($parameter->hasType()) ? $parameter->getType() : null; + + if (is_null($types)) { + $arguments[$parameter->getName()] = 'mixed'; + + continue; + } + + $arguments[$parameter->getName()] = implode('|', array_map( + static function (ReflectionNamedType $type): string { + return $type->getName(); + }, + ($types instanceof ReflectionNamedType) + ? [$types] // NOTE: normalize as list of to handle unions + : $types->getTypes(), + )); + } + + return $arguments; + } } diff --git a/tests/.snapshots/success.txt b/tests/.snapshots/success.txt index d943da92..0a23a6e9 100644 --- a/tests/.snapshots/success.txt +++ b/tests/.snapshots/success.txt @@ -101,6 +101,7 @@ ✓ it gives access the the underlying expectException ✓ it catch exceptions ✓ it catch exceptions and messages + ✓ it can just define the message PASS Tests\Features\Expect\HigherOrder\methods ✓ it can access methods @@ -112,11 +113,14 @@ ✓ it works with sequence ✓ it can compose complex expectations ✓ it can handle nested method calls + ✓ it works with higher order tests PASS Tests\Features\Expect\HigherOrder\methodsAndProperties ✓ it can access methods and properties ✓ it can handle nested methods and properties + ✓ it works with higher order tests ✓ it can start a new higher order expectation using the and syntax + ✓ it can start a new higher order expectation using the and syntax in higher order tests PASS Tests\Features\Expect\HigherOrder\properties ✓ it allows properties to be accessed from the value @@ -128,6 +132,7 @@ ✓ it can compose complex expectations ✓ it works with objects ✓ it works with nested properties + ✓ it works with higher order tests PASS Tests\Features\Expect\each ✓ an exception is thrown if the the type is not iterable @@ -216,6 +221,11 @@ PASS Tests\Features\Expect\toBeGreatherThanOrEqual ✓ passes ✓ failures + ✓ not failures + + PASS Tests\Features\Expect\toBeIn + ✓ passes + ✓ failures ✓ not failures PASS Tests\Features\Expect\toBeInfinite @@ -411,7 +421,12 @@ ✓ it proxies calls to object ✓ it is capable doing multiple assertions ✓ it resolves expect callables correctly + ✓ does not treat method names as callables ✓ it can tap into the test + ✓ it can pass datasets into the expect callables with (1, 2, 3) + ✓ it can pass datasets into the tap callable with (1, 2, 3) + ✓ it can pass shared datasets into callables with (1) + ✓ it can pass shared datasets into callables with (2) WARN Tests\Features\Incompleted … incompleted @@ -444,6 +459,8 @@ ✓ it do not skips with falsy closure condition - it skips with condition and message → skipped because foo - it skips when skip after assertion + - it can use something in the test case as a condition → This test was skipped + - it can user higher order callables and skip PASS Tests\Features\Test ✓ a test @@ -457,12 +474,14 @@ PASS Tests\Hooks\AfterAllTest ✓ global afterAll execution order + ✓ it only gets called once per file PASS Tests\Hooks\AfterEachTest ✓ global afterEach execution order PASS Tests\Hooks\BeforeAllTest ✓ global beforeAll execution order + ✓ it only gets called once per file PASS Tests\Hooks\BeforeEachTest ✓ global beforeEach execution order @@ -554,6 +573,7 @@ PASS Tests\Unit\TestSuite ✓ it does not allow to add the same test description twice + ✓ it alerts users about tests with arguments but no input PASS Tests\Visual\Help ✓ visual snapshot of help command output @@ -581,5 +601,5 @@ ✓ it is a test ✓ it uses correct parent class - Tests: 4 incompleted, 7 skipped, 365 passed + Tests: 4 incompleted, 9 skipped, 381 passed \ No newline at end of file diff --git a/tests/Features/Expect/toBeIn.php b/tests/Features/Expect/toBeIn.php new file mode 100644 index 00000000..6636a51b --- /dev/null +++ b/tests/Features/Expect/toBeIn.php @@ -0,0 +1,16 @@ +toBeIn(['a', 'b', 'c']); + expect('d')->not->toBeIn(['a', 'b', 'c']); +}); + +test('failures', function () { + expect('d')->toBeIn(['a', 'b', 'c']); +})->throws(ExpectationFailedException::class); + +test('not failures', function () { + expect('a')->not->toBeIn(['a', 'b', 'c']); +})->throws(ExpectationFailedException::class); diff --git a/tests/Features/HigherOrderTests.php b/tests/Features/HigherOrderTests.php index b45568e9..e2ff0686 100644 --- a/tests/Features/HigherOrderTests.php +++ b/tests/Features/HigherOrderTests.php @@ -27,4 +27,20 @@ it('can tap into the test') ->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/Hooks/AfterAllTest.php b/tests/Hooks/AfterAllTest.php index a34a5847..b9042815 100644 --- a/tests/Hooks/AfterAllTest.php +++ b/tests/Hooks/AfterAllTest.php @@ -2,26 +2,50 @@ global $globalHook; +// NOTE: this test does not have a $globalHook->calls offset since it is first +// in the directory and thus will always run before the others. See also the +// BeforeAllTest.php for details. + uses()->afterAll(function () use ($globalHook) { expect($globalHook) ->toHaveProperty('afterAll') ->and($globalHook->afterAll) - ->toBe(0); + ->toBe(0) + ->and($globalHook->calls) + ->afterAll + ->toBe(1); $globalHook->afterAll = 1; + $globalHook->calls->afterAll++; }); afterAll(function () use ($globalHook) { expect($globalHook) ->toHaveProperty('afterAll') ->and($globalHook->afterAll) - ->toBe(1); + ->toBe(1) + ->and($globalHook->calls) + ->afterAll + ->toBe(2); $globalHook->afterAll = 2; + $globalHook->calls->afterAll++; }); test('global afterAll execution order', function () use ($globalHook) { expect($globalHook) ->not() - ->toHaveProperty('afterAll'); + ->toHaveProperty('afterAll') + ->and($globalHook->calls) + ->afterAll + ->toBe(0); +}); + +it('only gets called once per file', function () use ($globalHook) { + expect($globalHook) + ->not() + ->toHaveProperty('afterAll') + ->and($globalHook->calls) + ->afterAll + ->toBe(0); }); diff --git a/tests/Hooks/BeforeAllTest.php b/tests/Hooks/BeforeAllTest.php index 11c996c5..05e57252 100644 --- a/tests/Hooks/BeforeAllTest.php +++ b/tests/Hooks/BeforeAllTest.php @@ -1,28 +1,56 @@ beforeAll(function () use ($globalHook) { +// HACK: we have to determine our $globalHook->calls baseline. This is because +// two other tests are executed before this one due to filename ordering. +$args = $_SERVER['argv'] ?? []; +$single = isset($args[1]) && Str::endsWith(__FILE__, $args[1]); +$offset = $single ? 0 : 2; + +uses()->beforeAll(function () use ($globalHook, $offset) { expect($globalHook) ->toHaveProperty('beforeAll') ->and($globalHook->beforeAll) - ->toBe(0); + ->toBe(0) + ->and($globalHook->calls) + ->beforeAll + ->toBe(1 + $offset); $globalHook->beforeAll = 1; + $globalHook->calls->beforeAll++; }); -beforeAll(function () use ($globalHook) { +beforeAll(function () use ($globalHook, $offset) { expect($globalHook) ->toHaveProperty('beforeAll') ->and($globalHook->beforeAll) - ->toBe(1); + ->toBe(1) + ->and($globalHook->calls) + ->beforeAll + ->toBe(2 + $offset); $globalHook->beforeAll = 2; + $globalHook->calls->beforeAll++; }); -test('global beforeAll execution order', function () use ($globalHook) { +test('global beforeAll execution order', function () use ($globalHook, $offset) { expect($globalHook) ->toHaveProperty('beforeAll') ->and($globalHook->beforeAll) - ->toBe(2); + ->toBe(2) + ->and($globalHook->calls) + ->beforeAll + ->toBe(3 + $offset); +}); + +it('only gets called once per file', function () use ($globalHook, $offset) { + expect($globalHook) + ->beforeAll + ->toBe(2) + ->and($globalHook->calls) + ->beforeAll + ->toBe(3 + $offset); }); diff --git a/tests/Pest.php b/tests/Pest.php index a8cd868d..d3fe584f 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -2,7 +2,8 @@ uses()->group('integration')->in('Visual'); -$globalHook = (object) []; // NOTE: global test value container to be mutated and checked across files, as needed +// NOTE: global test value container to be mutated and checked across files, as needed +$globalHook = (object) ['calls' => (object) ['beforeAll' => 0, 'afterAll' => 0]]; uses() ->beforeEach(function () { @@ -10,11 +11,13 @@ uses() }) ->beforeAll(function () use ($globalHook) { $globalHook->beforeAll = 0; + $globalHook->calls->beforeAll++; }) ->afterEach(function () { $this->ith = 0; }) ->afterAll(function () use ($globalHook) { $globalHook->afterAll = 0; + $globalHook->calls->afterAll++; }) ->in('Hooks'); diff --git a/tests/Unit/TestSuite.php b/tests/Unit/TestSuite.php index 74d9c092..62cc724d 100644 --- a/tests/Unit/TestSuite.php +++ b/tests/Unit/TestSuite.php @@ -1,5 +1,6 @@ tests->set(new \Pest\Factories\TestCaseFactory(__FILE__, 'foo', $test)); - $this->expectException(TestAlreadyExist::class); - $this->expectExceptionMessage(sprintf('A test with the description `%s` already exist in the filename `%s`.', 'foo', __FILE__)); $testSuite->tests->set(new \Pest\Factories\TestCaseFactory(__FILE__, 'foo', $test)); -}); +})->throws( + TestAlreadyExist::class, + sprintf('A test with the description `%s` already exist in the filename `%s`.', 'foo', __FILE__), +); + +it('alerts users about tests with arguments but no input', function () { + $testSuite = new TestSuite(getcwd(), 'tests'); + $test = function (int $arg) {}; + $testSuite->tests->set(new \Pest\Factories\TestCaseFactory(__FILE__, 'foo', $test)); +})->throws( + DatasetMissing::class, + sprintf("A test with the description '%s' has %d argument(s) ([%s]) and no dataset(s) provided in %s", 'foo', 1, 'int $arg', __FILE__), +);