diff --git a/.github/workflows/static.yml b/.github/workflows/static.yml index 0e0f614e..bea844f1 100644 --- a/.github/workflows/static.yml +++ b/.github/workflows/static.yml @@ -5,7 +5,7 @@ on: branches: [5.x] pull_request: schedule: - - cron: '0 0 * * *' + - cron: '0 9 * * *' concurrency: group: static-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} @@ -17,6 +17,7 @@ jobs: name: Static Tests runs-on: ubuntu-latest + timeout-minutes: 15 strategy: fail-fast: true matrix: @@ -40,7 +41,7 @@ jobs: run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache Composer dependencies - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ${{ steps.composer-cache.outputs.dir }} key: static-php-8.4-${{ matrix.dependency-version }}-composer-${{ hashFiles('**/composer.json') }} @@ -60,8 +61,5 @@ jobs: - name: Type Coverage run: composer test:type:coverage - - name: Refacto - run: composer test:refacto - - name: Style run: composer test:lint diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 65b6a809..42a0cce9 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -4,6 +4,8 @@ on: push: branches: [5.x] pull_request: + schedule: + - cron: '0 9 * * *' concurrency: group: tests-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} @@ -14,6 +16,7 @@ jobs: if: github.event_name != 'schedule' || github.repository == 'pestphp/pest' runs-on: ${{ matrix.os }} + timeout-minutes: 15 strategy: fail-fast: true matrix: @@ -42,7 +45,7 @@ jobs: run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache Composer dependencies - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ${{ steps.composer-cache.outputs.dir }} key: ${{ matrix.os }}-php-${{ matrix.php }}-symfony-${{ matrix.symfony }}-composer-${{ hashFiles('**/composer.json') }} diff --git a/bin/pest b/bin/pest index 9e6e703c..8cd27788 100755 --- a/bin/pest +++ b/bin/pest @@ -10,6 +10,7 @@ use Pest\TestCaseMethodFilters\AssigneeTestCaseFilter; use Pest\TestCaseMethodFilters\IssueTestCaseFilter; use Pest\TestCaseMethodFilters\NotesTestCaseFilter; use Pest\TestCaseMethodFilters\PrTestCaseFilter; +use Pest\TestCaseMethodFilters\FlakyTestCaseFilter; use Pest\TestCaseMethodFilters\TodoTestCaseFilter; use Pest\TestSuite; use Symfony\Component\Console\Input\ArgvInput; @@ -23,6 +24,7 @@ use Symfony\Component\Console\Output\ConsoleOutput; $dirty = false; $todo = false; + $flaky = false; $notes = false; foreach ($arguments as $key => $value) { @@ -57,6 +59,11 @@ use Symfony\Component\Console\Output\ConsoleOutput; unset($arguments[$key]); } + if ($value === '--flaky') { + $flaky = true; + unset($arguments[$key]); + } + if ($value === '--notes') { $notes = true; unset($arguments[$key]); @@ -150,6 +157,10 @@ use Symfony\Component\Console\Output\ConsoleOutput; $testSuite->tests->addTestCaseMethodFilter(new TodoTestCaseFilter); } + if ($flaky) { + $testSuite->tests->addTestCaseMethodFilter(new FlakyTestCaseFilter); + } + if ($notes) { $testSuite->tests->addTestCaseMethodFilter(new NotesTestCaseFilter); } diff --git a/composer.json b/composer.json index ada0aeec..869fac6d 100644 --- a/composer.json +++ b/composer.json @@ -50,11 +50,15 @@ "Tests\\Fixtures\\Arch\\": "tests/Fixtures/Arch", "Tests\\": "tests/PHPUnit/" }, + "classmap": [ + "tests/Fixtures/Arch/ToBeCasedCorrectly/IncorrectCasing/incorrectCasing.php" + ], "files": [ "tests/Autoload.php" ] }, "require-dev": { + "mrpunyapal/peststan": "^0.2.5", "nunomaduro/pao": "0.x-dev", "pestphp/pest-dev-tools": "^5.0.0", "pestphp/pest-plugin-browser": "^5.0.0", @@ -74,10 +78,14 @@ "bin/pest" ], "scripts": { - "refacto": "rector", - "lint": "pint --parallel", - "test:refacto": "rector --dry-run", - "test:lint": "pint --parallel --test", + "lint": [ + "rector", + "pint --parallel" + ], + "test:lint": [ + "rector --dry-run", + "pint --parallel --test" + ], "test:profanity": "php bin/pest --profanity --compact", "test:type:check": "phpstan analyse --ansi --memory-limit=-1 --debug", "test:type:coverage": "php -d memory_limit=-1 bin/pest --type-coverage --min=100", @@ -87,7 +95,6 @@ "test:integration": "php bin/pest --group=integration -v", "update:snapshots": "REBUILD_SNAPSHOTS=true php bin/pest --update-snapshots", "test": [ - "@test:refacto", "@test:lint", "@test:type:check", "@test:type:coverage", diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 99bbdec9..8374d3bd 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1,11 +1,5 @@ parameters: ignoreErrors: - - - message: '#^Parameter \#1 of callable callable\(Pest\\Expectation\\)\: Pest\\Arch\\Contracts\\ArchExpectation expects Pest\\Expectation\, Pest\\Expectation\ given\.$#' - identifier: argument.type - count: 1 - path: src/ArchPresets/AbstractPreset.php - - message: '#^Trait Pest\\Concerns\\Expectable is used zero times and is not analysed\.$#' identifier: trait.unused @@ -24,12 +18,6 @@ parameters: count: 1 path: src/Concerns/Testable.php - - - message: '#^Loose comparison using \!\= between \(Closure\|null\) and false will always evaluate to false\.$#' - identifier: notEqual.alwaysFalse - count: 1 - path: src/Expectation.php - - message: '#^Method Pest\\Expectation\:\:and\(\) should return Pest\\Expectation\ but returns \(Pest\\Expectation&TAndValue\)\|Pest\\Expectation\\.$#' identifier: return.type @@ -102,78 +90,12 @@ parameters: count: 1 path: src/PendingCalls/TestCall.php - - - message: '#^Parameter \#1 \$argv of class Symfony\\Component\\Console\\Input\\ArgvInput constructor expects list\\|null, array\ given\.$#' - identifier: argument.type - count: 1 - path: src/Plugins/Parallel.php - - - - message: '#^Parameter \#13 \$testRunnerTriggeredDeprecationEvents of class PHPUnit\\TestRunner\\TestResult\\TestResult constructor expects list\, array given\.$#' - identifier: argument.type - count: 1 - path: src/Plugins/Parallel/Paratest/WrapperRunner.php - - - - message: '#^Parameter \#14 \$testRunnerTriggeredWarningEvents of class PHPUnit\\TestRunner\\TestResult\\TestResult constructor expects list\, array given\.$#' - identifier: argument.type - count: 1 - path: src/Plugins/Parallel/Paratest/WrapperRunner.php - - - - message: '#^Parameter \#15 \$errors of class PHPUnit\\TestRunner\\TestResult\\TestResult constructor expects list\, array given\.$#' - identifier: argument.type - count: 1 - path: src/Plugins/Parallel/Paratest/WrapperRunner.php - - - - message: '#^Parameter \#16 \$deprecations of class PHPUnit\\TestRunner\\TestResult\\TestResult constructor expects list\, array given\.$#' - identifier: argument.type - count: 1 - path: src/Plugins/Parallel/Paratest/WrapperRunner.php - - - - message: '#^Parameter \#17 \$notices of class PHPUnit\\TestRunner\\TestResult\\TestResult constructor expects list\, array given\.$#' - identifier: argument.type - count: 1 - path: src/Plugins/Parallel/Paratest/WrapperRunner.php - - - - message: '#^Parameter \#18 \$warnings of class PHPUnit\\TestRunner\\TestResult\\TestResult constructor expects list\, array given\.$#' - identifier: argument.type - count: 1 - path: src/Plugins/Parallel/Paratest/WrapperRunner.php - - - - message: '#^Parameter \#19 \$phpDeprecations of class PHPUnit\\TestRunner\\TestResult\\TestResult constructor expects list\, array given\.$#' - identifier: argument.type - count: 1 - path: src/Plugins/Parallel/Paratest/WrapperRunner.php - - - - message: '#^Parameter \#20 \$phpNotices of class PHPUnit\\TestRunner\\TestResult\\TestResult constructor expects list\, array given\.$#' - identifier: argument.type - count: 1 - path: src/Plugins/Parallel/Paratest/WrapperRunner.php - - - - message: '#^Parameter \#21 \$phpWarnings of class PHPUnit\\TestRunner\\TestResult\\TestResult constructor expects list\, array given\.$#' - identifier: argument.type - count: 1 - path: src/Plugins/Parallel/Paratest/WrapperRunner.php - - message: '#^Parameter \#4 \$testErroredEvents of class PHPUnit\\TestRunner\\TestResult\\TestResult constructor expects list\, array given\.$#' identifier: argument.type count: 1 path: src/Plugins/Parallel/Paratest/WrapperRunner.php - - - message: '#^Parameter \#5 \$testFailedEvents of class PHPUnit\\TestRunner\\TestResult\\TestResult constructor expects list\, array given\.$#' - identifier: argument.type - count: 1 - path: src/Plugins/Parallel/Paratest/WrapperRunner.php - - message: '#^Parameter \#7 \$testSuiteSkippedEvents of class PHPUnit\\TestRunner\\TestResult\\TestResult constructor expects list\, array given\.$#' identifier: argument.type diff --git a/phpstan-pest-extension.neon b/phpstan-pest-extension.neon new file mode 100644 index 00000000..af649b7f --- /dev/null +++ b/phpstan-pest-extension.neon @@ -0,0 +1,5 @@ +services: + - + class: Pest\PHPStan\HigherOrderExpectationTypeExtension + tags: + - phpstan.broker.expressionTypeResolverExtension diff --git a/phpstan.neon b/phpstan.neon index 391daf0b..19f6ae4e 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,5 +1,7 @@ includes: - phpstan-baseline.neon + - phpstan-pest-extension.neon + - vendor/mrpunyapal/peststan/extension.neon parameters: level: 7 @@ -7,6 +9,3 @@ parameters: - src reportUnmatchedIgnoredErrors: false - - ignoreErrors: - - "#type mixed is not subtype of native#" diff --git a/src/ArchPresets/AbstractPreset.php b/src/ArchPresets/AbstractPreset.php index 3b612812..0dc9ce30 100644 --- a/src/ArchPresets/AbstractPreset.php +++ b/src/ArchPresets/AbstractPreset.php @@ -53,7 +53,7 @@ abstract class AbstractPreset // @pest-arch-ignore-line /** * Runs the given callback for each namespace. * - * @param callable(Expectation): ArchExpectation ...$callbacks + * @param callable(Expectation): ArchExpectation ...$callbacks */ final public function eachUserNamespace(callable ...$callbacks): void { diff --git a/src/ArchPresets/Laravel.php b/src/ArchPresets/Laravel.php index 167725b6..406611a3 100644 --- a/src/ArchPresets/Laravel.php +++ b/src/ArchPresets/Laravel.php @@ -69,6 +69,7 @@ final class Laravel extends AbstractPreset ->toHaveSuffix('Request'); $this->expectations[] = expect('App\Http\Requests') + ->classes() ->toExtend('Illuminate\Foundation\Http\FormRequest'); $this->expectations[] = expect('App\Http\Requests') @@ -118,6 +119,7 @@ final class Laravel extends AbstractPreset ->toHaveMethod('handle'); $this->expectations[] = expect('App\Notifications') + ->classes() ->toExtend('Illuminate\Notifications\Notification'); $this->expectations[] = expect('App') @@ -128,6 +130,7 @@ final class Laravel extends AbstractPreset ->toHaveSuffix('ServiceProvider'); $this->expectations[] = expect('App\Providers') + ->classes() ->toExtend('Illuminate\Support\ServiceProvider'); $this->expectations[] = expect('App\Providers') @@ -173,5 +176,9 @@ final class Laravel extends AbstractPreset ->toImplement('Illuminate\Contracts\Container\ContextualAttribute') ->toHaveAttribute('Attribute') ->toHaveMethod('resolve'); + + $this->expectations[] = expect('App\Rules') + ->classes() + ->toImplement('Illuminate\Contracts\Validation\ValidationRule'); } } diff --git a/src/Concerns/Testable.php b/src/Concerns/Testable.php index 7ed0887d..3f7e3b77 100644 --- a/src/Concerns/Testable.php +++ b/src/Concerns/Testable.php @@ -14,6 +14,8 @@ use Pest\Support\Reflection; use Pest\Support\Shell; use Pest\TestSuite; use PHPUnit\Framework\Attributes\PostCondition; +use PHPUnit\Framework\IncompleteTest; +use PHPUnit\Framework\SkippedTest; use PHPUnit\Framework\TestCase; use ReflectionException; use ReflectionFunction; @@ -328,7 +330,80 @@ trait Testable $arguments = $this->__resolveTestArguments($args); $this->__ensureDatasetArgumentNameAndNumberMatches($arguments); - return $this->__callClosure($closure, $arguments); + $method = TestSuite::getInstance()->tests->get(self::$__filename)->getMethod($this->name()); + + if ($method->flakyTries === null) { + return $this->__callClosure($closure, $arguments); + } + + $lastException = null; + $initialProperties = get_object_vars($this); + + for ($attempt = 1; $attempt <= $method->flakyTries; $attempt++) { + try { + return $this->__callClosure($closure, $arguments); + } catch (Throwable $e) { + if ($e instanceof SkippedTest + || $e instanceof IncompleteTest + || $this->__isExpectedException($e)) { + throw $e; + } + + $lastException = $e; + + if ($attempt < $method->flakyTries) { + if ($this->__snapshotChanges !== []) { + throw $e; + } + + $this->tearDown(); + + Closure::bind(fn (): array => $this->mockObjects = [], $this, TestCase::class)(); + + foreach (array_keys(array_diff_key(get_object_vars($this), $initialProperties)) as $property) { + unset($this->{$property}); + } + + $hasOutputExpectation = Closure::bind(fn (): bool => is_string($this->outputExpectedString) || is_string($this->outputExpectedRegex), $this, TestCase::class)(); + + if ($hasOutputExpectation) { + ob_clean(); + } + + $this->setUp(); + } + } + } + + throw $lastException; + } + + /** + * Determines if the given exception matches PHPUnit's expected exception. + */ + private function __isExpectedException(Throwable $e): bool + { + $read = fn (string $property): mixed => Closure::bind(fn () => $this->{$property}, $this, TestCase::class)(); + + $expectedClass = $read('expectedException'); + + if ($expectedClass !== null) { + return $e instanceof $expectedClass; + } + + $expectedMessage = $read('expectedExceptionMessage'); + + if ($expectedMessage !== null) { + return str_contains($e->getMessage(), (string) $expectedMessage); + } + + $expectedCode = $read('expectedExceptionCode'); + + if ($expectedCode !== null) { + return $e->getCode() === $expectedCode; + } + + return false; } /** @@ -350,7 +425,8 @@ trait Testable } $underlyingTest = Reflection::getFunctionVariable($this->__test, 'closure'); - $testParameterTypes = array_values(Reflection::getFunctionArguments($underlyingTest)); + $testParameterTypesByName = Reflection::getFunctionArguments($underlyingTest); + $testParameterTypes = array_values($testParameterTypesByName); if (count($arguments) !== 1) { foreach ($arguments as $argumentIndex => $argumentValue) { @@ -358,7 +434,11 @@ trait Testable continue; } - if (in_array($testParameterTypes[$argumentIndex], [Closure::class, 'callable', 'mixed'])) { + $parameterType = is_string($argumentIndex) + ? $testParameterTypesByName[$argumentIndex] + : $testParameterTypes[$argumentIndex]; + + if (in_array($parameterType, [Closure::class, 'callable', 'mixed'])) { continue; } @@ -384,7 +464,7 @@ trait Testable return [$boundDatasetResult]; } - return array_values($boundDatasetResult); + return $boundDatasetResult; } /** diff --git a/src/Expectation.php b/src/Expectation.php index 487a0d46..22b244f8 100644 --- a/src/Expectation.php +++ b/src/Expectation.php @@ -18,6 +18,7 @@ use Pest\Arch\Expectations\ToOnlyUse; use Pest\Arch\Expectations\ToUse; use Pest\Arch\Expectations\ToUseNothing; use Pest\Arch\PendingArchExpectation; +use Pest\Arch\Support\Composer; use Pest\Arch\Support\FileLineFinder; use Pest\Concerns\Extendable; use Pest\Concerns\Pipeable; @@ -669,6 +670,41 @@ final class Expectation throw InvalidExpectation::fromMethods(['toHavePrivateMethods']); } + /** + * Asserts that the given expectation target is cased correctly. + */ + public function toBeCasedCorrectly(): ArchExpectation + { + return Targeted::make( + $this, + function (ObjectDescription $object): bool { + if (! isset($object->reflectionClass)) { + return false; + } + + $realPath = realpath($object->path); + + if ($realPath === false) { + return false; + } + + foreach (Composer::allNamespacesWithDirectories() as $directory => $namespace) { + if (str_starts_with($realPath, $directory)) { + $relativePath = substr($realPath, strlen($directory) + 1); + $relativePath = explode('.', $relativePath)[0]; + $classFromPath = $namespace.'\\'.str_replace(DIRECTORY_SEPARATOR, '\\', $relativePath); + + return $classFromPath === $object->reflectionClass->getName(); + } + } + + return false; + }, + 'to be cased correctly', + FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')), + ); + } + /** * Asserts that the given expectation target is enum. */ @@ -783,7 +819,22 @@ final class Expectation return false; } - if (! in_array($trait, $object->reflectionClass->getTraitNames(), true)) { + $currentClass = $object->reflectionClass; + $usedTraits = []; + + do { + $classTraits = $currentClass->getTraits(); + foreach ($classTraits as $traitReflection) { + $usedTraits[$traitReflection->getName()] = $traitReflection->getName(); + + $nestedTraits = $traitReflection->getTraits(); + foreach ($nestedTraits as $nestedTrait) { + $usedTraits[$nestedTrait->getName()] = $nestedTrait->getName(); + } + } + } while ($currentClass = $currentClass->getParentClass()); + + if (! array_key_exists($trait, $usedTraits)) { return false; } } diff --git a/src/Factories/TestCaseFactory.php b/src/Factories/TestCaseFactory.php index c2f61e40..372653dd 100644 --- a/src/Factories/TestCaseFactory.php +++ b/src/Factories/TestCaseFactory.php @@ -116,8 +116,8 @@ final class TestCaseFactory $relativePath = (string) preg_replace('|%[a-fA-F0-9][a-fA-F0-9]|', '', $relativePath); // Remove escaped quote sequences (maintain namespace) $relativePath = str_replace(array_map(fn (string $quote): string => sprintf('\\%s', $quote), ['\'', '"']), '', $relativePath); - // Limit to A-Z, a-z, 0-9, '_', '-'. - $relativePath = (string) preg_replace('/[^A-Za-z0-9\\\\]/', '', $relativePath); + // Limit to Unicode letters and numbers. + $relativePath = (string) preg_replace('/[^\p{L}\p{N}\\\\]/u', '', $relativePath); $classFQN = 'P\\'.$relativePath; diff --git a/src/Factories/TestCaseMethodFactory.php b/src/Factories/TestCaseMethodFactory.php index 9438f837..f0a73401 100644 --- a/src/Factories/TestCaseMethodFactory.php +++ b/src/Factories/TestCaseMethodFactory.php @@ -50,6 +50,11 @@ final class TestCaseMethodFactory */ public int $repetitions = 1; + /** + * The test's number of flaky retry tries. + */ + public ?int $flakyTries = null; + /** * Determines if the test is a "todo". */ diff --git a/src/Functions.php b/src/Functions.php index 42331a56..cce5566b 100644 --- a/src/Functions.php +++ b/src/Functions.php @@ -4,7 +4,6 @@ declare(strict_types=1); use Pest\Browser\Api\ArrayablePendingAwaitablePage; use Pest\Browser\Api\PendingAwaitablePage; -use Pest\Concerns\Expectable; use Pest\Configuration; use Pest\Exceptions\AfterAllWithinDescribe; use Pest\Exceptions\BeforeAllWithinDescribe; @@ -48,7 +47,7 @@ if (! function_exists('beforeAll')) { function beforeAll(Closure $closure): void { if (DescribeCall::describing() !== []) { - $filename = Backtrace::file(); + $filename = Backtrace::testFile(); throw new BeforeAllWithinDescribe($filename); } @@ -61,13 +60,11 @@ if (! function_exists('beforeEach')) { /** * Runs the given closure before each test in the current file. * - * @param-closure-this TestCase $closure - * - * @return HigherOrderTapProxy|Expectable|TestCall|TestCase|mixed + * @param-closure-this TestCall $closure */ function beforeEach(?Closure $closure = null): BeforeEachCall { - $filename = Backtrace::file(); + $filename = Backtrace::testFile(); return new BeforeEachCall(TestSuite::getInstance(), $filename, $closure); } @@ -92,8 +89,6 @@ 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|Expectable|TestCall|TestCase|mixed */ function describe(string $description, Closure $tests): DescribeCall { @@ -112,7 +107,7 @@ if (! function_exists('uses')) { */ function uses(string ...$classAndTraits): UsesCall { - $filename = Backtrace::file(); + $filename = Backtrace::testFile(); return new UsesCall($filename, array_values($classAndTraits)); } @@ -124,7 +119,7 @@ if (! function_exists('pest')) { */ function pest(): Configuration { - return new Configuration(Backtrace::file()); + return new Configuration(Backtrace::testFile()); } } @@ -134,9 +129,9 @@ if (! function_exists('test')) { * is the test description; the second argument is * a closure that contains the test expectations. * - * @param-closure-this TestCase $closure + * @param-closure-this TestCall $closure * - * @return Expectable|TestCall|TestCase|mixed + * @return ($description is string ? TestCall : HigherOrderTapProxy|TestCall) */ function test(?string $description = null, ?Closure $closure = null): HigherOrderTapProxy|TestCall { @@ -156,34 +151,23 @@ if (! function_exists('it')) { * is the test description; the second argument is * a closure that contains the test expectations. * - * @param-closure-this TestCase $closure - * - * @return Expectable|TestCall|TestCase|mixed + * @param-closure-this TestCall $closure */ function it(string $description, ?Closure $closure = null): TestCall { $description = sprintf('it %s', $description); - /** @var TestCall $test */ - $test = test($description, $closure); - - return $test; + return test($description, $closure); } } if (! function_exists('todo')) { /** * Creates a new test that is marked as "todo". - * - * @return Expectable|TestCall|TestCase|mixed */ function todo(string $description): TestCall { - $test = test($description); - - assert($test instanceof TestCall); - - return $test->todo(); + return test($description)->todo(); } } @@ -191,13 +175,11 @@ if (! function_exists('afterEach')) { /** * Runs the given closure after each test in the current file. * - * @param-closure-this TestCase $closure - * - * @return Expectable|HigherOrderTapProxy|TestCall|mixed + * @param-closure-this TestCall $closure */ function afterEach(?Closure $closure = null): AfterEachCall { - $filename = Backtrace::file(); + $filename = Backtrace::testFile(); return new AfterEachCall(TestSuite::getInstance(), $filename, $closure); } @@ -210,7 +192,7 @@ if (! function_exists('afterAll')) { function afterAll(Closure $closure): void { if (DescribeCall::describing() !== []) { - $filename = Backtrace::file(); + $filename = Backtrace::testFile(); throw new AfterAllWithinDescribe($filename); } @@ -227,7 +209,7 @@ if (! function_exists('covers')) { */ function covers(array|string ...$classesOrFunctions): void { - $filename = Backtrace::file(); + $filename = Backtrace::testFile(); $beforeEachCall = (new BeforeEachCall(TestSuite::getInstance(), $filename)); @@ -256,7 +238,7 @@ if (! function_exists('mutates')) { */ function mutates(array|string ...$targets): void { - $filename = Backtrace::file(); + $filename = Backtrace::testFile(); $beforeEachCall = (new BeforeEachCall(TestSuite::getInstance(), $filename)); $beforeEachCall->group('__pest_mutate_only'); diff --git a/src/PHPStan/HigherOrderExpectationTypeExtension.php b/src/PHPStan/HigherOrderExpectationTypeExtension.php new file mode 100644 index 00000000..7b7cdfec --- /dev/null +++ b/src/PHPStan/HigherOrderExpectationTypeExtension.php @@ -0,0 +1,57 @@ +name instanceof Identifier) { + return null; + } + + $varType = $scope->getType($expr->var); + + if (! (new ObjectType(HigherOrderExpectation::class))->isSuperTypeOf($varType)->yes()) { + return null; + } + + if (! $this->reflectionProvider->hasClass(HigherOrderExpectation::class)) { + return null; + } + + $propertyName = $expr->name->name; + $classReflection = $this->reflectionProvider->getClass(HigherOrderExpectation::class); + + if (! $classReflection->hasNativeProperty($propertyName)) { + return null; + } + + return $varType->getProperty($propertyName, $scope)->getReadableType(); + } +} diff --git a/src/PendingCalls/DescribeCall.php b/src/PendingCalls/DescribeCall.php index 712aedcb..0964bc65 100644 --- a/src/PendingCalls/DescribeCall.php +++ b/src/PendingCalls/DescribeCall.php @@ -5,7 +5,6 @@ declare(strict_types=1); namespace Pest\PendingCalls; use Closure; -use Pest\Support\Backtrace; use Pest\Support\Description; use Pest\TestSuite; @@ -53,7 +52,11 @@ final class DescribeCall */ public function __destruct() { - unset($this->currentBeforeEachCall); + // Ensure BeforeEachCall destructs before creating tests + // by moving to local scope and clearing the reference + $beforeEach = $this->currentBeforeEachCall; + $this->currentBeforeEachCall = null; + unset($beforeEach); // Trigger destructor immediately self::$describing[] = $this->description; @@ -71,12 +74,13 @@ final class DescribeCall */ public function __call(string $name, array $arguments): self { - $filename = Backtrace::file(); - if (! $this->currentBeforeEachCall instanceof BeforeEachCall) { - $this->currentBeforeEachCall = new BeforeEachCall(TestSuite::getInstance(), $filename); + $this->currentBeforeEachCall = new BeforeEachCall(TestSuite::getInstance(), $this->filename); - $this->currentBeforeEachCall->describing[] = $this->description; + $this->currentBeforeEachCall->describing = array_merge( + DescribeCall::describing(), + [$this->description] + ); } $this->currentBeforeEachCall->{$name}(...$arguments); diff --git a/src/PendingCalls/TestCall.php b/src/PendingCalls/TestCall.php index 79264596..ccf9b4f9 100644 --- a/src/PendingCalls/TestCall.php +++ b/src/PendingCalls/TestCall.php @@ -412,6 +412,20 @@ final class TestCall // @phpstan-ignore-line return $this; } + /** + * Marks the test as flaky, retrying it up to the given number of times. + */ + public function flaky(int $tries = 3): self + { + if ($tries < 1) { + throw new InvalidArgumentException('The number of tries must be greater than 0.'); + } + + $this->testCaseMethod->flakyTries = $tries; + + return $this; + } + /** * Marks the test as "todo". */ diff --git a/src/Plugins/Coverage.php b/src/Plugins/Coverage.php index 712f5de5..ed7b1214 100644 --- a/src/Plugins/Coverage.php +++ b/src/Plugins/Coverage.php @@ -23,6 +23,8 @@ final class Coverage implements AddsOutput, HandlesArguments private const string EXACTLY_OPTION = 'exactly'; + private const string ONLY_COVERED_OPTION = 'only-covered'; + /** * Whether it should show the coverage or not. */ @@ -43,6 +45,11 @@ final class Coverage implements AddsOutput, HandlesArguments */ public ?float $coverageExactly = null; + /** + * Whether it should show only covered files. + */ + public bool $showOnlyCovered = false; + /** * Creates a new Plugin instance. */ @@ -57,7 +64,7 @@ final class Coverage implements AddsOutput, HandlesArguments public function handleArguments(array $originals): array { $arguments = [...[''], ...array_values(array_filter($originals, function (string $original): bool { - foreach ([self::COVERAGE_OPTION, self::MIN_OPTION, self::EXACTLY_OPTION] as $option) { + foreach ([self::COVERAGE_OPTION, self::MIN_OPTION, self::EXACTLY_OPTION, self::ONLY_COVERED_OPTION] as $option) { if ($original === sprintf('--%s', $option)) { return true; } @@ -80,6 +87,7 @@ final class Coverage implements AddsOutput, HandlesArguments $inputs[] = new InputOption(self::COVERAGE_OPTION, null, InputOption::VALUE_NONE); $inputs[] = new InputOption(self::MIN_OPTION, null, InputOption::VALUE_REQUIRED); $inputs[] = new InputOption(self::EXACTLY_OPTION, null, InputOption::VALUE_REQUIRED); + $inputs[] = new InputOption(self::ONLY_COVERED_OPTION, null, InputOption::VALUE_NONE); $input = new ArgvInput($arguments, new InputDefinition($inputs)); if ((bool) $input->getOption(self::COVERAGE_OPTION)) { @@ -120,6 +128,10 @@ final class Coverage implements AddsOutput, HandlesArguments $this->coverageExactly = (float) $exactlyOption; } + if ((bool) $input->getOption(self::ONLY_COVERED_OPTION)) { + $this->showOnlyCovered = true; + } + if ($_SERVER['COLLISION_PRINTER_COMPACT'] ?? false) { $this->compact = true; } @@ -144,7 +156,7 @@ final class Coverage implements AddsOutput, HandlesArguments exit(1); } - $coverage = \Pest\Support\Coverage::report($this->output, $this->compact); + $coverage = \Pest\Support\Coverage::report($this->output, $this->compact, $this->showOnlyCovered); $exitCode = (int) ($coverage < $this->coverageMin); if ($exitCode === 0 && $this->coverageExactly !== null) { diff --git a/src/Plugins/Help.php b/src/Plugins/Help.php index a2fb1ef0..0795c806 100644 --- a/src/Plugins/Help.php +++ b/src/Plugins/Help.php @@ -152,6 +152,9 @@ final readonly class Help implements HandlesArguments ], [ 'arg' => '--dirty', 'desc' => 'Only run tests that have uncommitted changes according to Git', + ], [ + 'arg' => '--flaky', + 'desc' => 'Output to standard output tests marked as flaky', ], ...$content['Selection']]; $content['Reporting'] = [...$content['Reporting'], ...[ @@ -167,6 +170,12 @@ final readonly class Help implements HandlesArguments ], [ 'arg' => '--coverage --min', 'desc' => 'Set the minimum required coverage percentage, and fail if not met', + ], [ + 'arg' => '--coverage --exactly', + 'desc' => 'Set the exact required coverage percentage, and fail if not met', + ], [ + 'arg' => '--coverage --only-covered', + 'desc' => 'Hide files with 0% coverage from the code coverage report', ], ...$content['Code Coverage']]; $content['Mutation Testing'] = [[ diff --git a/src/Plugins/Parallel.php b/src/Plugins/Parallel.php index 75b77bb4..eb1d754a 100644 --- a/src/Plugins/Parallel.php +++ b/src/Plugins/Parallel.php @@ -34,7 +34,7 @@ final class Parallel implements HandlesArguments /** * @var string[] */ - private const array UNSUPPORTED_ARGUMENTS = ['--todo', '--todos', '--retry', '--notes', '--issue', '--pr', '--pull-request']; + private const array UNSUPPORTED_ARGUMENTS = ['--todo', '--todos', '--retry', '--notes', '--issue', '--pr', '--pull-request', '--flaky']; /** * Whether the given command line arguments indicate that the test suite should be run in parallel. @@ -127,7 +127,9 @@ final class Parallel implements HandlesArguments $arguments ); - $exitCode = $this->paratestCommand()->run(new ArgvInput($filteredArguments), new CleanConsoleOutput); + $filteredArguments = $this->processTeamcityArguments($filteredArguments); + + $exitCode = $this->paratestCommand()->run(new ArgvInput(array_values($filteredArguments)), new CleanConsoleOutput); return CallsAddsOutput::execute($exitCode); } @@ -191,4 +193,18 @@ final class Parallel implements HandlesArguments return $this->popArgument('-p', $arguments); } + + /** + * @param string[] $arguments + * @return string[] + */ + public function processTeamcityArguments(array $arguments): array + { + $argv = new ArgvInput; + if ($argv->hasParameterOption('--teamcity')) { + $arguments[] = '--teamcity'; + } + + return $arguments; + } } diff --git a/src/Plugins/Parallel/Paratest/ResultPrinter.php b/src/Plugins/Parallel/Paratest/ResultPrinter.php index e7a1c24d..2041b44e 100644 --- a/src/Plugins/Parallel/Paratest/ResultPrinter.php +++ b/src/Plugins/Parallel/Paratest/ResultPrinter.php @@ -81,7 +81,9 @@ final class ResultPrinter public function flush(): void {} }; - $this->compactPrinter = CompactPrinter::default(); + $this->compactPrinter = CompactPrinter::default( + decorated: ! in_array('--colors=never', $_SERVER['argv'] ?? [], true), + ); if (! $this->options->configuration->hasLogfileTeamcity()) { return; @@ -92,14 +94,13 @@ final class ResultPrinter $this->teamcityLogFileHandle = $teamcityLogFileHandle; } - /** @param list $teamcityFiles */ public function printFeedback( SplFileInfo $progressFile, SplFileInfo $outputFile, - array $teamcityFiles + ?SplFileInfo $teamcityFile, ): void { - if ($this->options->needsTeamcity) { - $teamcityProgress = $this->tailMultiple($teamcityFiles); + if ($this->options->needsTeamcity && $teamcityFile instanceof SplFileInfo) { + $teamcityProgress = $this->tailMultiple([$teamcityFile]); if ($this->teamcityLogFileHandle !== null) { fwrite($this->teamcityLogFileHandle, $teamcityProgress); @@ -171,8 +172,18 @@ final class ResultPrinter $state = (new StateGenerator)->fromPhpUnitTestResult($this->passedTests, $testResult); - $this->compactPrinter->errors($state); - $this->compactPrinter->recap($state, $testResult, $duration, $this->options); + if ($testResult->numberOfTestsRun() === 0 && $state->testSuiteTestsCount() === 0) { + $this->output->writeln([ + '', + ' INFO No tests found.', + '', + ]); + } + + if (! isset($_SERVER['PEST_PARALLEL_NO_OUTPUT'])) { + $this->compactPrinter->errors($state); + $this->compactPrinter->recap($state, $testResult, $duration, $this->options); + } } private function printFeedbackItem(string $item): void diff --git a/src/Plugins/Parallel/Paratest/WrapperRunner.php b/src/Plugins/Parallel/Paratest/WrapperRunner.php index 5ac7efae..9cccbeb9 100644 --- a/src/Plugins/Parallel/Paratest/WrapperRunner.php +++ b/src/Plugins/Parallel/Paratest/WrapperRunner.php @@ -44,6 +44,7 @@ use function dirname; use function file_get_contents; use function max; use function realpath; +use function str_starts_with; use function unlink; use function unserialize; use function usleep; @@ -236,7 +237,7 @@ final class WrapperRunner implements RunnerInterface $this->printer->printFeedback( $worker->progressFile, $worker->unexpectedOutputFile, - $this->teamcityFiles, + $worker->teamcityFile ?? null, ); $worker->reset(); } @@ -509,15 +510,61 @@ final class WrapperRunner implements RunnerInterface */ private function getTestFiles(SuiteLoader $suiteLoader): array { - /** @var array $files */ - $files = [ - ...array_values(array_filter( - $suiteLoader->tests, - fn (string $filename): bool => ! str_ends_with($filename, "eval()'d code") - )), - ...TestSuite::getInstance()->tests->getFilenames(), - ]; + /** @var array $files */ + $files = []; - return $files; // @phpstan-ignore-line + foreach (array_filter( + $suiteLoader->tests, + fn (string $filename): bool => ! str_ends_with($filename, "eval()'d code") + ) as $filename) { + $resolved = realpath($filename) ?: $filename; + $files[$resolved] = null; + } + + foreach (TestSuite::getInstance()->tests->getFilenames() as $filename) { + if ($this->shouldIncludeBootstrappedTestFile($filename)) { + $resolved = realpath($filename) + ?: realpath($this->options->cwd.DIRECTORY_SEPARATOR.$filename) + ?: $filename; + $files[$resolved] = null; + } + } + + return array_keys($files); // @phpstan-ignore-line + } + + private function shouldIncludeBootstrappedTestFile(string $filename): bool + { + if (! $this->options->configuration->hasCliArguments()) { + return true; + } + + $resolvedFilename = realpath($filename); + + if ($resolvedFilename === false) { + $resolvedFilename = realpath($this->options->cwd.DIRECTORY_SEPARATOR.$filename); + } + + if ($resolvedFilename === false) { + return false; + } + + foreach ($this->options->configuration->cliArguments() as $path) { + $resolvedPath = realpath($path); + + if ($resolvedPath === false) { + continue; + } + + if ($resolvedFilename === $resolvedPath) { + return true; + } + + if (is_dir($resolvedPath) && str_starts_with($resolvedFilename, $resolvedPath.DIRECTORY_SEPARATOR)) { + return true; + } + } + + return false; } } diff --git a/src/Plugins/Parallel/Support/CompactPrinter.php b/src/Plugins/Parallel/Support/CompactPrinter.php index aa2da210..2119646e 100644 --- a/src/Plugins/Parallel/Support/CompactPrinter.php +++ b/src/Plugins/Parallel/Support/CompactPrinter.php @@ -62,12 +62,12 @@ final class CompactPrinter /** * Creates a new instance of the Compact Printer. */ - public static function default(): self + public static function default(bool $decorated = true): self { return new self( terminal(), - new ConsoleOutput(decorated: true), - new Style(new ConsoleOutput(decorated: true)), + new ConsoleOutput(decorated: $decorated), + new Style(new ConsoleOutput(decorated: $decorated)), terminal()->width() - 4, ); } diff --git a/src/Support/Backtrace.php b/src/Support/Backtrace.php index 652eb442..86957bcb 100644 --- a/src/Support/Backtrace.php +++ b/src/Support/Backtrace.php @@ -23,7 +23,9 @@ final class Backtrace $current = null; foreach (debug_backtrace(self::BACKTRACE_OPTIONS) as $trace) { - assert(array_key_exists(self::FILE, $trace)); + if (array_key_exists(self::FILE, $trace) === false) { + break; + } $traceFile = str_replace(DIRECTORY_SEPARATOR, '/', $trace[self::FILE]); diff --git a/src/Support/Coverage.php b/src/Support/Coverage.php index cf49242a..a11b6ff2 100644 --- a/src/Support/Coverage.php +++ b/src/Support/Coverage.php @@ -75,7 +75,7 @@ final class Coverage * Reports the code coverage report to the * console and returns the result in float. */ - public static function report(OutputInterface $output, bool $compact = false): float + public static function report(OutputInterface $output, bool $compact = false, bool $showOnlyCovered = false): float { if (! file_exists($reportPath = self::getPath())) { if (self::usingXdebug()) { @@ -118,6 +118,10 @@ final class Coverage $basename, ]); + if ($showOnlyCovered && $file->percentageOfExecutedLines()->asFloat() === 0.0) { + continue; + } + $percentage = $file->numberOfExecutableLines() === 0 ? '100.0' : number_format($file->percentageOfExecutedLines()->asFloat(), 1, '.', ''); diff --git a/src/Support/DatasetInfo.php b/src/Support/DatasetInfo.php index c67f317c..70ca6df5 100644 --- a/src/Support/DatasetInfo.php +++ b/src/Support/DatasetInfo.php @@ -17,7 +17,7 @@ final class DatasetInfo public static function isInsideADatasetsDirectory(string $file): bool { - return basename(dirname($file)) === self::DATASETS_DIR_NAME; + return in_array(self::DATASETS_DIR_NAME, self::directorySegmentsInsideTestsDirectory($file), true); } public static function isADatasetsFile(string $file): bool @@ -32,7 +32,23 @@ final class DatasetInfo } if (self::isInsideADatasetsDirectory($file)) { - return dirname($file, 2); + $scope = []; + + foreach (self::directorySegmentsInsideTestsDirectory($file) as $segment) { + if ($segment === self::DATASETS_DIR_NAME) { + break; + } + + $scope[] = $segment; + } + + $testsDirectoryPath = self::testsDirectoryPath($file); + + if ($scope === []) { + return $testsDirectoryPath; + } + + return $testsDirectoryPath.DIRECTORY_SEPARATOR.implode(DIRECTORY_SEPARATOR, $scope); } if (self::isADatasetsFile($file)) { @@ -41,4 +57,45 @@ final class DatasetInfo return $file; } + + /** + * @return list + */ + private static function directorySegmentsInsideTestsDirectory(string $file): array + { + $directory = dirname(self::pathInsideTestsDirectory($file)); + + if ($directory === '.' || $directory === DIRECTORY_SEPARATOR) { + return []; + } + + return array_values(array_filter( + explode(DIRECTORY_SEPARATOR, trim($directory, DIRECTORY_SEPARATOR)), + static fn (string $segment): bool => $segment !== '', + )); + } + + private static function pathInsideTestsDirectory(string $file): string + { + $testsDirectory = DIRECTORY_SEPARATOR.trim(testDirectory(), DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR; + $position = strrpos($file, $testsDirectory); + + if ($position === false) { + return $file; + } + + return substr($file, $position + strlen($testsDirectory)); + } + + private static function testsDirectoryPath(string $file): string + { + $testsDirectory = DIRECTORY_SEPARATOR.trim(testDirectory(), DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR; + $position = strrpos($file, $testsDirectory); + + if ($position === false) { + return dirname($file); + } + + return substr($file, 0, $position + strlen($testsDirectory) - 1); + } } diff --git a/src/Support/StateGenerator.php b/src/Support/StateGenerator.php index a7ddba1a..9872f52d 100644 --- a/src/Support/StateGenerator.php +++ b/src/Support/StateGenerator.php @@ -11,6 +11,10 @@ use PHPUnit\Event\Code\TestDoxBuilder; use PHPUnit\Event\Code\TestMethod; use PHPUnit\Event\Code\ThrowableBuilder; use PHPUnit\Event\Test\Errored; +use PHPUnit\Event\Test\PhpunitDeprecationTriggered; +use PHPUnit\Event\Test\PhpunitErrorTriggered; +use PHPUnit\Event\Test\PhpunitNoticeTriggered; +use PHPUnit\Event\Test\PhpunitWarningTriggered; use PHPUnit\Event\TestData\TestDataCollection; use PHPUnit\Framework\SkippedWithMessageException; use PHPUnit\Metadata\MetadataCollection; @@ -43,6 +47,8 @@ final class StateGenerator )); } + $this->addTriggeredPhpunitEvents($state, $testResult->testTriggeredPhpunitErrorEvents(), TestResult::FAIL); + foreach ($testResult->testMarkedIncompleteEvents() as $testResultEvent) { $state->add(TestResult::fromPestParallelTestCase( $testResultEvent->test(), @@ -99,6 +105,8 @@ final class StateGenerator } } + $this->addTriggeredPhpunitEvents($state, $testResult->testTriggeredPhpunitDeprecationEvents(), TestResult::DEPRECATED); + foreach ($testResult->notices() as $testResultEvent) { foreach ($testResultEvent->triggeringTests() as $triggeringTest) { ['test' => $test] = $triggeringTest; @@ -123,6 +131,8 @@ final class StateGenerator } } + $this->addTriggeredPhpunitEvents($state, $testResult->testTriggeredPhpunitNoticeEvents(), TestResult::NOTICE); + foreach ($testResult->warnings() as $testResultEvent) { foreach ($testResultEvent->triggeringTests() as $triggeringTest) { ['test' => $test] = $triggeringTest; @@ -135,6 +145,8 @@ final class StateGenerator } } + $this->addTriggeredPhpunitEvents($state, $testResult->testTriggeredPhpunitWarningEvents(), TestResult::WARN); + foreach ($testResult->phpWarnings() as $testResultEvent) { foreach ($testResultEvent->triggeringTests() as $triggeringTest) { ['test' => $test] = $triggeringTest; @@ -165,4 +177,24 @@ final class StateGenerator return $state; } + + /** + * @param array> $testResultEvents + */ + private function addTriggeredPhpunitEvents(State $state, array $testResultEvents, string $type): void + { + foreach ($testResultEvents as $events) { + foreach ($events as $event) { + if (! $event->test()->isTestMethod()) { + continue; + } + + $state->add(TestResult::fromPestParallelTestCase( + $event->test(), + $type, + ThrowableBuilder::from(new TestOutcome($event->message())) + )); + } + } + } } diff --git a/src/TestCaseMethodFilters/FlakyTestCaseFilter.php b/src/TestCaseMethodFilters/FlakyTestCaseFilter.php new file mode 100644 index 00000000..fac0640e --- /dev/null +++ b/src/TestCaseMethodFilters/FlakyTestCaseFilter.php @@ -0,0 +1,19 @@ +flakyTries !== null; + } +} diff --git a/stubs/init-laravel/Pest.php.stub b/stubs/init-laravel/Pest.php.stub index cb09b7f3..2c5012ca 100644 --- a/stubs/init-laravel/Pest.php.stub +++ b/stubs/init-laravel/Pest.php.stub @@ -10,7 +10,7 @@ use Tests\TestCase; | | The closure you provide to your test functions is always bound to a specific PHPUnit test | case class. By default, that class is "PHPUnit\Framework\TestCase". Of course, you may -| need to change it using the "pest()" function to bind a different classes or traits. +| need to change it using the "pest()" function to bind different classes or traits. | */ diff --git a/stubs/init/Pest.php.stub b/stubs/init/Pest.php.stub index fbc6d9ae..d4ffffba 100644 --- a/stubs/init/Pest.php.stub +++ b/stubs/init/Pest.php.stub @@ -9,7 +9,7 @@ use Tests\TestCase; | | The closure you provide to your test functions is always bound to a specific PHPUnit test | case class. By default, that class is "PHPUnit\Framework\TestCase". Of course, you may -| need to change it using the "pest()" function to bind a different classes or traits. +| need to change it using the "pest()" function to bind different classes or traits. | */ diff --git a/tests/.pest/snapshots/Visual/Help/visual_snapshot_of_help_command_output.snap b/tests/.pest/snapshots/Visual/Help/visual_snapshot_of_help_command_output.snap index 7eeddd2f..f5307875 100644 --- a/tests/.pest/snapshots/Visual/Help/visual_snapshot_of_help_command_output.snap +++ b/tests/.pest/snapshots/Visual/Help/visual_snapshot_of_help_command_output.snap @@ -28,6 +28,7 @@ --pull-request Output to standard output tests with the given pull request number (alias for --pr) --retry Run non-passing tests first and stop execution upon first error or failure --dirty ...... Only run tests that have uncommitted changes according to Git + --flaky .................... Output to standard output tests marked as flaky --all .................... Ignore test selection from XML configuration file --list-suites ................................... List available test suites --testsuite [name] ......... Only run tests from the specified test suite(s) @@ -131,6 +132,8 @@ CODE COVERAGE OPTIONS: --coverage ..... Generate code coverage report and output to standard output --coverage --min Set the minimum required coverage percentage, and fail if not met + --coverage --exactly Set the exact required coverage percentage, and fail if not met + --coverage --only-covered Hide files with 0% coverage from the code coverage report --coverage-clover [file] Write code coverage report in Clover XML format to file --coverage-openclover [file] Write code coverage report in OpenClover XML format to file --coverage-cobertura [file] Write code coverage report in Cobertura XML format to file diff --git a/tests/.pest/snapshots/Visual/Todo/todo.snap b/tests/.pest/snapshots/Visual/Todo/todo.snap index c50794f7..09710340 100644 --- a/tests/.pest/snapshots/Visual/Todo/todo.snap +++ b/tests/.pest/snapshots/Visual/Todo/todo.snap @@ -15,6 +15,9 @@ ↓ todo on describe → should not fail ↓ todo on describe → should run + TODO Tests\Features\Flaky - 1 todo + ↓ it does not retry todo tests + TODO Tests\Features\Todo - 29 todos ↓ something todo later ↓ something todo later chained @@ -81,6 +84,6 @@ PASS Tests\CustomTestCase\ParentTest ✓ override method - Tests: 39 todos, 3 passed (21 assertions) + Tests: 40 todos, 3 passed (21 assertions) Duration: x.xxs diff --git a/tests/.pest/snapshots/Visual/Todo/todo_in_parallel.snap b/tests/.pest/snapshots/Visual/Todo/todo_in_parallel.snap index c50794f7..09710340 100644 --- a/tests/.pest/snapshots/Visual/Todo/todo_in_parallel.snap +++ b/tests/.pest/snapshots/Visual/Todo/todo_in_parallel.snap @@ -15,6 +15,9 @@ ↓ todo on describe → should not fail ↓ todo on describe → should run + TODO Tests\Features\Flaky - 1 todo + ↓ it does not retry todo tests + TODO Tests\Features\Todo - 29 todos ↓ something todo later ↓ something todo later chained @@ -81,6 +84,6 @@ PASS Tests\CustomTestCase\ParentTest ✓ override method - Tests: 39 todos, 3 passed (21 assertions) + Tests: 40 todos, 3 passed (21 assertions) Duration: x.xxs diff --git a/tests/.pest/snapshots/Visual/Todo/todos.snap b/tests/.pest/snapshots/Visual/Todo/todos.snap index c50794f7..09710340 100644 --- a/tests/.pest/snapshots/Visual/Todo/todos.snap +++ b/tests/.pest/snapshots/Visual/Todo/todos.snap @@ -15,6 +15,9 @@ ↓ todo on describe → should not fail ↓ todo on describe → should run + TODO Tests\Features\Flaky - 1 todo + ↓ it does not retry todo tests + TODO Tests\Features\Todo - 29 todos ↓ something todo later ↓ something todo later chained @@ -81,6 +84,6 @@ PASS Tests\CustomTestCase\ParentTest ✓ override method - Tests: 39 todos, 3 passed (21 assertions) + Tests: 40 todos, 3 passed (21 assertions) Duration: x.xxs diff --git a/tests/.pest/snapshots/Visual/Todo/todos_in_parallel.snap b/tests/.pest/snapshots/Visual/Todo/todos_in_parallel.snap index c50794f7..09710340 100644 --- a/tests/.pest/snapshots/Visual/Todo/todos_in_parallel.snap +++ b/tests/.pest/snapshots/Visual/Todo/todos_in_parallel.snap @@ -15,6 +15,9 @@ ↓ todo on describe → should not fail ↓ todo on describe → should run + TODO Tests\Features\Flaky - 1 todo + ↓ it does not retry todo tests + TODO Tests\Features\Todo - 29 todos ↓ something todo later ↓ something todo later chained @@ -81,6 +84,6 @@ PASS Tests\CustomTestCase\ParentTest ✓ override method - Tests: 39 todos, 3 passed (21 assertions) + Tests: 40 todos, 3 passed (21 assertions) Duration: x.xxs diff --git a/tests/.snapshots/success.txt b/tests/.snapshots/success.txt index 7b3d6f9b..b87c2279 100644 --- a/tests/.snapshots/success.txt +++ b/tests/.snapshots/success.txt @@ -95,6 +95,48 @@ PASS Tests\Features\Covers\TraitCoverage ✓ it uses the correct PHPUnit attribute for trait + PASS Tests\Features\DatasetMethodChaining + ✓ beforeEach()->with() applies dataset to tests → receives the dataset value with (10) + ✓ beforeEach()->with() applies dataset to tests → it also receives the dataset value in it() with (10) + ✓ beforeEach()->with() with multiple dataset values → receives each value from the dataset with (1) + ✓ beforeEach()->with() with multiple dataset values → receives each value from the dataset with (2) + ✓ beforeEach()->with() with multiple dataset values → receives each value from the dataset with (3) + ✓ beforeEach()->with() with keyed dataset → receives keyed dataset values with dataset "first" + ✓ beforeEach()->with() with keyed dataset → receives keyed dataset values with dataset "second" + ✓ beforeEach()->with() with closure dataset → receives values from closure dataset with (100) + ✓ beforeEach()->with() with closure dataset → receives values from closure dataset with (200) + ✓ describe()->with() passes dataset to tests → receives the dataset value with (42) + ✓ describe()->with() passes dataset to tests → it also receives it in it() with (42) + ✓ describe()->with() with multiple values → receives each value with (5) + ✓ describe()->with() with multiple values → receives each value with (10) + ✓ describe()->with() with multiple values → receives each value with (15) + ✓ describe()->with() with keyed dataset → receives keyed values with dataset "alpha" + ✓ describe()->with() with keyed dataset → receives keyed values with dataset "beta" + ✓ describe()->with() with closure dataset → receives closure dataset values with (7) + ✓ describe()->with() with closure dataset → receives closure dataset values with (14) + ✓ outer with dataset → inner without dataset → inherits outer dataset with (1) + ✓ nested describe blocks with datasets at multiple levels → level 1 → receives level 1 dataset with (10) + ✓ nested describe blocks with datasets at multiple levels → level 1 → level 2 → receives datasets from all ancestor levels with (10) / (20) + ✓ deeply nested describe with datasets → a → b → c → receives all ancestor datasets with (1) / (2) / (3) + ✓ beforeEach()->with() combined with test->with() → receives both datasets as cross product with (10) / (1) + ✓ beforeEach()->with() combined with test->with() → receives both datasets as cross product with (10) / (2) + ✓ describe()->with() combined with test->with() → receives both datasets with (5) / (50) + ✓ describe()->with() combined with test->with() → receives both datasets with (5) / (60) + ✓ beforeEach closure and beforeEach()->with() coexist → has both the closure state and dataset with (99) + ✓ beforeEach()->with() does not interfere with closure hooks → closures run in order and dataset is applied with (42) + ✓ first describe with dataset → gets its own dataset with (111) + ✓ second describe with different dataset → gets its own dataset, not the sibling with (222) + ✓ third describe without dataset → has no dataset leaking from siblings + ✓ describe()->with() with beforeEach closure → both hook and dataset work with (77) + ✓ describe()->with() with afterEach closure → dataset is available and afterEach runs with (88) + ✓ multiple tests share the same beforeEach dataset → first test gets the dataset with (33) + ✓ multiple tests share the same beforeEach dataset → second test also gets the dataset with (33) + ✓ multiple tests share the same beforeEach dataset → it third test with it() also gets the dataset with (33) + ✓ outer describe → inner describe with dataset on hook → inherits outer beforeEach and has inner dataset with (55) + ✓ outer describe → outer test is unaffected by inner dataset + ✓ describe()->with() preserves depends → first with (9) + ✓ describe()->with() preserves depends → second with (9) + PASS Tests\Features\DatasetsTests - 1 todo ✓ it throws exception if dataset does not exist ✓ it throws exception if dataset already exist @@ -215,6 +257,20 @@ ✓ it may be used with high order after describe block with dataset "formal" ✓ it may be used with high order after describe block with dataset "informal" ✓ after describe block with named dataset with ('after') + ✓ named parameters match by parameter name with ('Taylor', 'taylor@laravel.com') + ✓ named parameters work with multiple dataset items with ('Taylor', 'taylor@laravel.com') + ✓ named parameters work with multiple dataset items with ('James', 'james@laravel.com') + ✓ named parameters work in different order than closure params with ('a', 'b', 'c') + ✓ named parameters work with named dataset keys with dataset "taylor" + ✓ named parameters work with named dataset keys with dataset "james" + ✓ named parameters work with closures that should be resolved with (Closure Object (), Closure Object ()) + ✓ named parameters work with closure type hints with ('Taylor', Closure Object ()) + ✓ named parameters work with registered datasets with ('Taylor', 'taylor@laravel.com') + ✓ named parameters work with registered datasets with ('James', 'james@laravel.com') + ✓ named parameters work with bound closure returning associative array with (Closure Object ()) + ✓ dataset items can mix named and sequential styles with ('Taylor', 'taylor@laravel.com') + ✓ dataset items can mix named and sequential styles with ('James', 'james@laravel.com') #1 + ✓ dataset items can mix named and sequential styles with ('James', 'james@laravel.com') #2 PASS Tests\Features\Depends ✓ first @@ -448,6 +504,10 @@ ✓ failures with custom message ✓ not failures + PASS Tests\Features\Expect\toBeCasedCorrectly + ✓ pass + ✓ failure + PASS Tests\Features\Expect\toBeDigits ✓ pass ✓ failures @@ -1034,6 +1094,10 @@ ✓ pass ✓ failures ✓ not failures + ✓ trait inheritance - direct usage + ✓ trait inheritance - inherited usage + ✓ trait inheritance - negative case + ✓ nested trait inheritance PASS Tests\Features\Expect\unless ✓ it pass @@ -1065,6 +1129,40 @@ ✓ it may return a file path ✓ it may throw an exception if the file does not exist + WARN Tests\Features\Flaky - 1 todo + ✓ it passes on first try + ✓ it passes on a subsequent try + ✓ it has a default of 3 tries + ✓ it succeeds on the last possible try + ✓ it works with tries of 1 + ✓ it retries assertion failures + ✓ it works with a dataset with (1) + ✓ it works with a dataset with (2) + ✓ it works with a dataset with (3) + ✓ it retries each dataset independently with ('alpha') + ✓ it retries each dataset independently with ('beta') + ✓ within a describe block → it retries inside describe + ✓ lifecycle hooks with flaky → it re-runs beforeEach on each retry + ✓ afterEach with flaky → it runs afterEach between retries + - it does not retry skipped tests → intentionally skipped + ✓ it works with repeat and flaky @ repetition 1 of 2 + ✓ it works with repeat and flaky @ repetition 2 of 2 + ✓ it works as higher order test + ✓ it fails after exhausting all retries + ✓ it throws when tries is less than 1 + ✓ it throws when tries is negative + ↓ it does not retry todo tests + ✓ it retries php errors + ✓ it works with throws and flaky + ✓ it does not retry expected exceptions + ✓ it does not retry fails() + ✓ it retries unexpected exceptions even with throws set + ✓ it does not leak mock objects between retries + ✓ it does not stop retrying when snapshot changes are absent + ✓ it does not leak dynamic properties between retries + ✓ it clears output buffer between retries when expectOutputString is used + ✓ it preserves output between retries when no output expectation is set + WARN Tests\Features\Helpers ✓ it can set/get properties on $this ! it gets null if property do not exist → Undefined property Tests\Features\Helpers::$wqdwqdqw @@ -1490,6 +1588,10 @@ PASS Tests\Fixtures\ExampleTest ✓ it example 2 + PASS Tests\Fixtures\ParallelNestedDatasets\TestFileWithNestedDataset + ✓ loads nested dataset with ('alice') + ✓ loads nested dataset with ('bob') + WARN Tests\Fixtures\UnexpectedOutput - output @@ -1640,9 +1742,14 @@ ✓ it cannot resolve a parameter without type PASS Tests\Unit\Support\DatasetInfo - ✓ it can check if dataset is defined inside a Datasets directory with ('/var/www/project/tests/Datase…rs.php', true) + ✓ it can check if dataset is defined inside a Datasets directory with ('/var/www/Datasets/project/tes…rs.php', true) #1 + ✓ it can check if dataset is defined inside a Datasets directory with ('/var/www/Datasets/project/tes…rs.php', true) #2 + ✓ it can check if dataset is defined inside a Datasets directory with ('/var/www/project/tests/Datase…rs.php', true) #1 + ✓ it can check if dataset is defined inside a Datasets directory with ('/var/www/project/tests/Datase…rs.php', true) #2 ✓ it can check if dataset is defined inside a Datasets directory with ('/var/www/project/tests/Datasets.php', false) - ✓ it can check if dataset is defined inside a Datasets directory with ('/var/www/project/tests/Featur…rs.php', true) + ✓ it can check if dataset is defined inside a Datasets directory with ('/var/www/project/tests/Featur…rs.php', true) #1 + ✓ it can check if dataset is defined inside a Datasets directory with ('/var/www/project/tests/Featur…rs.php', true) #2 + ✓ it can check if dataset is defined inside a Datasets directory with ('/var/www/project/tests/Featur…rs.php', true) #3 ✓ it can check if dataset is defined inside a Datasets directory with ('/var/www/project/tests/Featur…rs.php', false) ✓ it can check if dataset is defined inside a Datasets directory with ('/var/www/project/tests/Featur…ts.php', false) ✓ it can check if dataset is defined inside a Datasets.php file with ('/var/www/project/tests/Datase…rs.php', false) @@ -1650,12 +1757,18 @@ ✓ it can check if dataset is defined inside a Datasets.php file with ('/var/www/project/tests/Featur…rs.php', false) #1 ✓ it can check if dataset is defined inside a Datasets.php file with ('/var/www/project/tests/Featur…rs.php', false) #2 ✓ it can check if dataset is defined inside a Datasets.php file with ('/var/www/project/tests/Featur…ts.php', true) - ✓ it computes the dataset scope with ('/var/www/project/tests/Datase…rs.php', '/var/www/project/tests') + ✓ it computes the dataset scope with ('/var/www/Datasets/project/tes…rs.php', '/var/www/Datasets/project/tests') + ✓ it computes the dataset scope with ('/var/www/Datasets/project/tes…rs.php', '/var/www/Datasets/project/tes…atures') + ✓ it computes the dataset scope with ('/var/www/project/tests/Datase…rs.php', '/var/www/project/tests') #1 + ✓ it computes the dataset scope with ('/var/www/project/tests/Datase…rs.php', '/var/www/project/tests') #2 ✓ it computes the dataset scope with ('/var/www/project/tests/Datasets.php', '/var/www/project/tests') - ✓ it computes the dataset scope with ('/var/www/project/tests/Featur…rs.php', '/var/www/project/tests/Features') + ✓ it computes the dataset scope with ('/var/www/project/tests/Featur…rs.php', '/var/www/project/tests/Features') #1 + ✓ it computes the dataset scope with ('/var/www/project/tests/Featur…rs.php', '/var/www/project/tests/Features') #2 ✓ it computes the dataset scope with ('/var/www/project/tests/Featur…rs.php', '/var/www/project/tests/Featur…rs.php') #1 ✓ it computes the dataset scope with ('/var/www/project/tests/Featur…ts.php', '/var/www/project/tests/Features') - ✓ it computes the dataset scope with ('/var/www/project/tests/Featur…rs.php', '/var/www/project/tests/Featur…ollers') + ✓ it computes the dataset scope with ('/var/www/project/tests/Featur…rs.php', '/var/www/project/tests/Featur…ollers') #1 + ✓ it computes the dataset scope with ('/var/www/project/tests/Featur…rs.php', '/var/www/project/tests/Featur…ollers') #2 + ✓ it computes the dataset scope with ('/var/www/project/tests/Featur…rs.php', '/var/www/project/tests/Features') #3 ✓ it computes the dataset scope with ('/var/www/project/tests/Featur…rs.php', '/var/www/project/tests/Featur…rs.php') #2 ✓ it computes the dataset scope with ('/var/www/project/tests/Featur…ts.php', '/var/www/project/tests/Featur…ollers') @@ -1749,13 +1862,17 @@ PASS Tests\Visual\Help ✓ visual snapshot of help command output - WARN Tests\Visual\JUnit + PASS Tests\Visual\JUnit ✓ junit output - - junit with parallel → Not working yet + ✓ junit with parallel PASS Tests\Visual\Parallel ✓ parallel ✓ a parallel test can extend another test with same name + ✓ parallel reports invalid datasets as failures + + PASS Tests\Visual\ParallelNestedDatasets + ✓ parallel loads nested datasets from nested directories PASS Tests\Visual\SingleTestOrDirectory ✓ allows to run a single test @@ -1775,6 +1892,10 @@ - todo - todo in parallel + PASS Tests\Visual\UnicodeFilename + ✓ filter works with unicode characters in filename + ✓ filter with unicode regex matches unicode filename + WARN Tests\Visual\Version - visual snapshot of help command output @@ -1782,4 +1903,4 @@ ✓ pass with dataset with ('my-datas-set-value') ✓ within describe → pass with dataset with ('my-datas-set-value') - Tests: 2 deprecated, 4 warnings, 5 incomplete, 2 notices, 39 todos, 35 skipped, 1188 passed (2813 assertions) \ No newline at end of file + Tests: 2 deprecated, 4 warnings, 5 incomplete, 2 notices, 40 todos, 35 skipped, 1296 passed (2977 assertions) \ No newline at end of file diff --git a/tests/.tests/FlakyFailure.php b/tests/.tests/FlakyFailure.php new file mode 100644 index 00000000..61ad770b --- /dev/null +++ b/tests/.tests/FlakyFailure.php @@ -0,0 +1,5 @@ +flaky(tries: 2); diff --git a/tests/.tests/ParallelInvalidDataset/MissingDatasetTest.php b/tests/.tests/ParallelInvalidDataset/MissingDatasetTest.php new file mode 100644 index 00000000..c7a1245f --- /dev/null +++ b/tests/.tests/ParallelInvalidDataset/MissingDatasetTest.php @@ -0,0 +1,5 @@ +toBe('x'); +})->with('missing.dataset'); diff --git a/tests/.tests/ParallelInvalidDataset/PassingTest.php b/tests/.tests/ParallelInvalidDataset/PassingTest.php new file mode 100644 index 00000000..915336ab --- /dev/null +++ b/tests/.tests/ParallelInvalidDataset/PassingTest.php @@ -0,0 +1,3 @@ +assertTrue(true); diff --git a/tests/.tests/StraßenTest.php b/tests/.tests/StraßenTest.php new file mode 100644 index 00000000..682d1062 --- /dev/null +++ b/tests/.tests/StraßenTest.php @@ -0,0 +1,3 @@ +assertTrue(true); diff --git a/tests/Features/DatasetMethodChaining.php b/tests/Features/DatasetMethodChaining.php new file mode 100644 index 00000000..d1474f46 --- /dev/null +++ b/tests/Features/DatasetMethodChaining.php @@ -0,0 +1,287 @@ +with(), describe()->with(), + * and nested describe blocks with datasets. + */ + +// --------------------------------------------------------------- +// beforeEach()->with() inside describe blocks +// --------------------------------------------------------------- + +describe('beforeEach()->with() applies dataset to tests', function () { + beforeEach()->with([10]); + + test('receives the dataset value', function ($value) { + expect($value)->toBe(10); + }); + + it('also receives the dataset value in it()', function ($value) { + expect($value)->toBe(10); + }); +}); + +describe('beforeEach()->with() with multiple dataset values', function () { + beforeEach()->with([1, 2, 3]); + + test('receives each value from the dataset', function ($value) { + expect($value)->toBeIn([1, 2, 3]); + }); +}); + +describe('beforeEach()->with() with keyed dataset', function () { + beforeEach()->with(['first' => [10], 'second' => [20]]); + + test('receives keyed dataset values', function ($value) { + expect($value)->toBeIn([10, 20]); + }); +}); + +describe('beforeEach()->with() with closure dataset', function () { + beforeEach()->with(function () { + yield [100]; + yield [200]; + }); + + test('receives values from closure dataset', function ($value) { + expect($value)->toBeIn([100, 200]); + }); +}); + +// --------------------------------------------------------------- +// describe()->with() method chaining +// --------------------------------------------------------------- + +describe('describe()->with() passes dataset to tests', function () { + test('receives the dataset value', function ($value) { + expect($value)->toBe(42); + }); + + it('also receives it in it()', function ($value) { + expect($value)->toBe(42); + }); +})->with([42]); + +describe('describe()->with() with multiple values', function () { + test('receives each value', function ($value) { + expect($value)->toBeIn([5, 10, 15]); + }); +})->with([5, 10, 15]); + +describe('describe()->with() with keyed dataset', function () { + test('receives keyed values', function ($value) { + expect($value)->toBeIn([100, 200]); + }); +})->with(['alpha' => [100], 'beta' => [200]]); + +describe('describe()->with() with closure dataset', function () { + test('receives closure dataset values', function ($value) { + expect($value)->toBeIn([7, 14]); + }); +})->with(function () { + yield [7]; + yield [14]; +}); + +// --------------------------------------------------------------- +// Nested describe blocks with datasets +// --------------------------------------------------------------- + +describe('outer with dataset', function () { + describe('inner without dataset', function () { + test('inherits outer dataset', function (...$args) { + expect($args)->toBe([1]); + }); + }); +})->with([1]); + +describe('nested describe blocks with datasets at multiple levels', function () { + describe('level 1', function () { + test('receives level 1 dataset', function (...$args) { + expect($args)->toBe([10]); + }); + + describe('level 2', function () { + test('receives datasets from all ancestor levels', function (...$args) { + expect($args)->toBe([10, 20]); + }); + })->with([20]); + })->with([10]); +}); + +describe('deeply nested describe with datasets', function () { + describe('a', function () { + describe('b', function () { + describe('c', function () { + test('receives all ancestor datasets', function (...$args) { + expect($args)->toBe([1, 2, 3]); + }); + })->with([3]); + })->with([2]); + })->with([1]); +}); + +// --------------------------------------------------------------- +// Combining hook datasets with test-level datasets +// --------------------------------------------------------------- + +describe('beforeEach()->with() combined with test->with()', function () { + beforeEach()->with([10]); + + test('receives both datasets as cross product', function ($hookValue, $testValue) { + expect($hookValue)->toBe(10); + expect($testValue)->toBeIn([1, 2]); + })->with([1, 2]); +}); + +describe('describe()->with() combined with test->with()', function () { + test('receives both datasets', function ($describeValue, $testValue) { + expect($describeValue)->toBe(5); + expect($testValue)->toBeIn([50, 60]); + })->with([50, 60]); +})->with([5]); + +// --------------------------------------------------------------- +// beforeEach()->with() combined with beforeEach closure +// --------------------------------------------------------------- + +describe('beforeEach closure and beforeEach()->with() coexist', function () { + beforeEach(function () { + $this->setupValue = 'initialized'; + }); + + beforeEach()->with([99]); + + test('has both the closure state and dataset', function ($value) { + expect($this->setupValue)->toBe('initialized'); + expect($value)->toBe(99); + }); +}); + +describe('beforeEach()->with() does not interfere with closure hooks', function () { + beforeEach(function () { + $this->counter = 1; + }); + + beforeEach(function () { + $this->counter++; + }); + + beforeEach()->with([42]); + + test('closures run in order and dataset is applied', function ($value) { + expect($this->counter)->toBe(2); + expect($value)->toBe(42); + }); +}); + +// --------------------------------------------------------------- +// Dataset isolation between describe blocks +// --------------------------------------------------------------- + +describe('first describe with dataset', function () { + beforeEach()->with([111]); + + test('gets its own dataset', function ($value) { + expect($value)->toBe(111); + }); +}); + +describe('second describe with different dataset', function () { + beforeEach()->with([222]); + + test('gets its own dataset, not the sibling', function ($value) { + expect($value)->toBe(222); + }); +}); + +describe('third describe without dataset', function () { + test('has no dataset leaking from siblings', function () { + expect(true)->toBeTrue(); + }); +}); + +// --------------------------------------------------------------- +// describe()->with() combined with beforeEach hooks +// --------------------------------------------------------------- + +describe('describe()->with() with beforeEach closure', function () { + beforeEach(function () { + $this->hookRan = true; + }); + + test('both hook and dataset work', function ($value) { + expect($this->hookRan)->toBeTrue(); + expect($value)->toBe(77); + }); +})->with([77]); + +describe('describe()->with() with afterEach closure', function () { + afterEach(function () { + expect($this->value)->toBe(88); + }); + + test('dataset is available and afterEach runs', function ($value) { + $this->value = $value; + expect($value)->toBe(88); + }); +})->with([88]); + +// --------------------------------------------------------------- +// Multiple tests in a describe with beforeEach()->with() +// --------------------------------------------------------------- + +describe('multiple tests share the same beforeEach dataset', function () { + beforeEach()->with([33]); + + test('first test gets the dataset', function ($value) { + expect($value)->toBe(33); + }); + + test('second test also gets the dataset', function ($value) { + expect($value)->toBe(33); + }); + + it('third test with it() also gets the dataset', function ($value) { + expect($value)->toBe(33); + }); +}); + +// --------------------------------------------------------------- +// Nested describe with beforeEach()->with() at inner level +// --------------------------------------------------------------- + +describe('outer describe', function () { + beforeEach(function () { + $this->outer = true; + }); + + describe('inner describe with dataset on hook', function () { + beforeEach()->with([55]); + + test('inherits outer beforeEach and has inner dataset', function ($value) { + expect($this->outer)->toBeTrue(); + expect($value)->toBe(55); + }); + }); + + test('outer test is unaffected by inner dataset', function () { + expect($this->outer)->toBeTrue(); + }); +}); + +// --------------------------------------------------------------- +// describe()->with() with depends +// --------------------------------------------------------------- + +describe('describe()->with() preserves depends', function () { + test('first', function ($value) { + expect($value)->toBe(9); + }); + + test('second', function ($value) { + expect($value)->toBe(9); + })->depends('first'); +})->with([9]); diff --git a/tests/Features/DatasetsTests.php b/tests/Features/DatasetsTests.php index 837b56f6..99682015 100644 --- a/tests/Features/DatasetsTests.php +++ b/tests/Features/DatasetsTests.php @@ -457,3 +457,88 @@ dataset('after-describe', ['after']); test('after describe block with named dataset', function (...$args) { expect($args)->toBe(['after']); })->with('after-describe'); + +test('named parameters match by parameter name', function (string $email, string $name) { + expect($name)->toBe('Taylor'); + expect($email)->toBe('taylor@laravel.com'); +})->with([ + ['name' => 'Taylor', 'email' => 'taylor@laravel.com'], +]); + +test('named parameters work with multiple dataset items', function (string $email, string $name) { + expect($name)->toBeString(); + expect($email)->toContain('@'); +})->with([ + ['name' => 'Taylor', 'email' => 'taylor@laravel.com'], + ['name' => 'James', 'email' => 'james@laravel.com'], +]); + +test('named parameters work in different order than closure params', function (string $third, string $first, string $second) { + expect($first)->toBe('a'); + expect($second)->toBe('b'); + expect($third)->toBe('c'); +})->with([ + ['first' => 'a', 'second' => 'b', 'third' => 'c'], +]); + +test('named parameters work with named dataset keys', function (string $email, string $name) { + expect($name)->toBeString(); + expect($email)->toContain('@'); +})->with([ + 'taylor' => ['name' => 'Taylor', 'email' => 'taylor@laravel.com'], + 'james' => ['name' => 'James', 'email' => 'james@laravel.com'], +]); + +test('named parameters work with closures that should be resolved', function (string $email, string $name) { + expect($name)->toBe('bar'); + expect($email)->toBe('bar@example.com'); +})->with([ + [ + 'name' => function () { + return $this->foo; + }, + 'email' => function () { + return $this->foo.'@example.com'; + }, + ], +]); + +test('named parameters work with closure type hints', function (Closure $callback, string $name) { + expect($name)->toBe('Taylor'); + expect($callback())->toBe('resolved'); +})->with([ + [ + 'name' => 'Taylor', + 'callback' => function () { + return 'resolved'; + }, + ], +]); + +dataset('named-params-dataset', [ + ['name' => 'Taylor', 'email' => 'taylor@laravel.com'], + ['name' => 'James', 'email' => 'james@laravel.com'], +]); + +test('named parameters work with registered datasets', function (string $email, string $name) { + expect($name)->toBeString(); + expect($email)->toContain('@'); +})->with('named-params-dataset'); + +test('named parameters work with bound closure returning associative array', function (string $email, string $name) { + expect($name)->toBe('bar'); + expect($email)->toBe('test@example.com'); +})->with([ + function () { + return ['name' => $this->foo, 'email' => 'test@example.com']; + }, +]); + +test('dataset items can mix named and sequential styles', function (string $name, string $email) { + expect($name)->toBeString(); + expect($email)->toContain('@'); +})->with([ + ['name' => 'Taylor', 'email' => 'taylor@laravel.com'], + ['James', 'james@laravel.com'], + ['James', 'email' => 'james@laravel.com'], +]); diff --git a/tests/Features/Expect/toBeCasedCorrectly.php b/tests/Features/Expect/toBeCasedCorrectly.php new file mode 100644 index 00000000..6a8432d2 --- /dev/null +++ b/tests/Features/Expect/toBeCasedCorrectly.php @@ -0,0 +1,12 @@ +expect('Tests\Fixtures\Arch\ToBeCasedCorrectly\CorrectCasing') + ->toBeCasedCorrectly(); + +test('failure') + ->expect('Tests\Fixtures\Arch\ToBeCasedCorrectly\IncorrectCasing') + ->toBeCasedCorrectly() + ->throws(ArchExpectationFailedException::class); diff --git a/tests/Features/Expect/toUseTrait.php b/tests/Features/Expect/toUseTrait.php index fd9c933f..806490b3 100644 --- a/tests/Features/Expect/toUseTrait.php +++ b/tests/Features/Expect/toUseTrait.php @@ -14,3 +14,19 @@ test('failures', function () { test('not failures', function () { expect('Pest\Expectations\HigherOrderExpectation')->not->toUseTrait('Pest\Concerns\Retrievable'); })->throws(ArchExpectationFailedException::class); + +test('trait inheritance - direct usage', function () { + expect('Tests\Fixtures\Arch\ToUseTrait\HasTrait\ParentClassWithTrait')->toUseTrait('Tests\Fixtures\Arch\ToUseTrait\HasTrait\TestTraitForInheritance'); +}); + +test('trait inheritance - inherited usage', function () { + expect('Tests\Fixtures\Arch\ToUseTrait\HasInheritedTrait\ChildClassExtendingParent')->toUseTrait('Tests\Fixtures\Arch\ToUseTrait\HasTrait\TestTraitForInheritance'); +}); + +test('trait inheritance - negative case', function () { + expect('Tests\Fixtures\Arch\ToUseTrait\HasInheritedTrait\ChildClassExtendingParent')->not->toUseTrait('NonExistentTrait'); +}); + +test('nested trait inheritance', function () { + expect('Tests\Fixtures\Arch\ToUseTrait\HasInheritedTrait\ChildClassExtendingParent')->toUseTrait('Tests\Fixtures\Arch\ToUseTrait\HasNestedTrait\NestedTrait'); +}); diff --git a/tests/Features/Flaky.php b/tests/Features/Flaky.php new file mode 100644 index 00000000..2a55bad1 --- /dev/null +++ b/tests/Features/Flaky.php @@ -0,0 +1,300 @@ +toBeTrue(); +})->flaky(); + +it('passes on a subsequent try', function () { + $file = sys_get_temp_dir().'/pest_flaky_'.crc32(__FILE__.__LINE__); + $count = file_exists($file) ? (int) file_get_contents($file) : 0; + file_put_contents($file, (string) ++$count); + + if ($count < 2) { + throw new Exception('Flaky failure'); + } + + @unlink($file); + expect(true)->toBeTrue(); +})->flaky(tries: 3); + +it('has a default of 3 tries', function () { + expect(true)->toBeTrue(); +})->flaky(); + +it('succeeds on the last possible try', function () { + $file = sys_get_temp_dir().'/pest_flaky_last_try'; + $count = file_exists($file) ? (int) file_get_contents($file) : 0; + file_put_contents($file, (string) ++$count); + + if ($count < 3) { + throw new Exception('Not yet'); + } + + @unlink($file); + expect(true)->toBeTrue(); +})->flaky(tries: 3); + +it('works with tries of 1', function () { + expect(true)->toBeTrue(); +})->flaky(tries: 1); + +it('retries assertion failures', function () { + $file = sys_get_temp_dir().'/pest_flaky_assertion'; + $count = file_exists($file) ? (int) file_get_contents($file) : 0; + file_put_contents($file, (string) ++$count); + + if ($count < 2) { + expect(false)->toBeTrue(); + } + + @unlink($file); + expect(true)->toBeTrue(); +})->flaky(tries: 3); + +it('works with a dataset', function (int $number) { + expect($number)->toBeGreaterThan(0); +})->flaky(tries: 2)->with([1, 2, 3]); + +it('retries each dataset independently', function (string $label) { + $file = sys_get_temp_dir().'/pest_flaky_dataset_'.md5($label); + $count = file_exists($file) ? (int) file_get_contents($file) : 0; + file_put_contents($file, (string) ++$count); + + if ($count < 2) { + throw new Exception("Flaky for $label"); + } + + @unlink($file); + expect(true)->toBeTrue(); +})->flaky(tries: 3)->with(['alpha', 'beta']); + +describe('within a describe block', function () { + it('retries inside describe', function () { + $file = sys_get_temp_dir().'/pest_flaky_describe'; + $count = file_exists($file) ? (int) file_get_contents($file) : 0; + file_put_contents($file, (string) ++$count); + + if ($count < 2) { + throw new Exception('Flaky inside describe'); + } + + @unlink($file); + expect(true)->toBeTrue(); + })->flaky(tries: 2); +}); + +describe('lifecycle hooks with flaky', function () { + beforeEach(function () { + $this->setupCount = ($this->setupCount ?? 0) + 1; + }); + + it('re-runs beforeEach on each retry', function () { + $file = sys_get_temp_dir().'/pest_flaky_lifecycle'; + $count = file_exists($file) ? (int) file_get_contents($file) : 0; + file_put_contents($file, (string) ++$count); + + if ($count < 2) { + throw new Exception('Flaky lifecycle'); + } + + @unlink($file); + // After retry: setUp ran for initial + retry = setupCount should be 2 + expect($this->setupCount)->toBe(2); + })->flaky(tries: 3); +}); + +describe('afterEach with flaky', function () { + $state = new stdClass; + $state->teardownCount = 0; + + afterEach(function () use ($state) { + $state->teardownCount++; + }); + + it('runs afterEach between retries', function () use ($state) { + $file = sys_get_temp_dir().'/pest_flaky_aftereach'; + $count = file_exists($file) ? (int) file_get_contents($file) : 0; + file_put_contents($file, (string) ++$count); + + if ($count < 2) { + throw new Exception('Flaky afterEach'); + } + + @unlink($file); + // tearDown was called once between retries + expect($state->teardownCount)->toBe(1); + })->flaky(tries: 3); +}); + +it('does not retry skipped tests') + ->skip('intentionally skipped') + ->flaky(tries: 3); + +it('works with repeat and flaky', function () { + expect(true)->toBeTrue(); +})->repeat(times: 2)->flaky(tries: 2); + +it('works as higher order test') + ->assertTrue(true) + ->flaky(tries: 2); + +it('fails after exhausting all retries', function () { + $process = new Process( + ['php', 'bin/pest', 'tests/.tests/FlakyFailure.php'], + dirname(__DIR__, 2), + ['COLLISION_PRINTER' => 'DefaultPrinter', 'COLLISION_IGNORE_DURATION' => 'true'], + ); + + $process->run(); + + expect($process->getExitCode())->not->toBe(0); + expect(removeAnsiEscapeSequences($process->getOutput())) + ->toContain('FAILED') + ->toContain('Always fails'); +}); + +it('throws when tries is less than 1', function () { + it('invalid', function () {})->flaky(tries: 0); +})->throws(InvalidArgumentException::class, 'The number of tries must be greater than 0.'); + +it('throws when tries is negative', function () { + it('invalid negative', function () {})->flaky(tries: -1); +})->throws(InvalidArgumentException::class, 'The number of tries must be greater than 0.'); + +it('does not retry todo tests') + ->todo() + ->flaky(tries: 3); + +it('retries php errors', function () { + $file = sys_get_temp_dir().'/pest_flaky_error'; + $count = file_exists($file) ? (int) file_get_contents($file) : 0; + file_put_contents($file, (string) ++$count); + + if ($count < 2) { + throw new TypeError('type error'); + } + + @unlink($file); + expect(true)->toBeTrue(); +})->flaky(tries: 3); + +it('works with throws and flaky', function () { + throw new RuntimeException('Expected exception'); +})->throws(RuntimeException::class, 'Expected exception')->flaky(tries: 2); + +it('does not retry expected exceptions', function () { + // If flaky retried this, the temp file counter would reach 2 and + // the test would NOT throw — causing PHPUnit's "expected exception + // was not raised" to fail. The test passes only if we don't retry. + $file = sys_get_temp_dir().'/pest_flaky_expected'; + $count = file_exists($file) ? (int) file_get_contents($file) : 0; + file_put_contents($file, (string) ++$count); + + if ($count >= 2) { + @unlink($file); + + // Second call means flaky retried — don't throw, which will FAIL + // because PHPUnit expects the exception + return; + } + + @unlink($file); + throw new RuntimeException('Expected on first attempt'); +})->throws(RuntimeException::class)->flaky(tries: 3); + +it('does not retry fails()', function () { + $this->fail('Expected failure'); +})->fails('Expected failure')->flaky(tries: 2); + +it('retries unexpected exceptions even with throws set', function () { + $file = sys_get_temp_dir().'/pest_flaky_unexpected'; + $count = file_exists($file) ? (int) file_get_contents($file) : 0; + file_put_contents($file, (string) ++$count); + + if ($count < 2) { + throw new LogicException('Unexpected flaky error'); + } + + @unlink($file); + throw new RuntimeException('Expected exception'); +})->throws(RuntimeException::class)->flaky(tries: 3); + +it('does not leak mock objects between retries', function () { + $mock = $this->createMock(Countable::class); + $mock->expects($this->once())->method('count')->willReturn(1); + + $file = sys_get_temp_dir().'/pest_flaky_mock'; + $count = file_exists($file) ? (int) file_get_contents($file) : 0; + file_put_contents($file, (string) ++$count); + + if ($count < 2) { + @unlink(sys_get_temp_dir().'/pest_flaky_mock'); // clean before retry writes again + file_put_contents($file, '1'); + throw new Exception('Flaky mock failure'); + } + + @unlink($file); + // Call mock — only the mock from THIS attempt should be verified + expect($mock->count())->toBe(1); +})->flaky(tries: 3); + +it('does not stop retrying when snapshot changes are absent', function () { + // Ensures the snapshot guard only triggers when __snapshotChanges is non-empty + $file = sys_get_temp_dir().'/pest_flaky_no_snapshot'; + $count = file_exists($file) ? (int) file_get_contents($file) : 0; + file_put_contents($file, (string) ++$count); + + if ($count < 2) { + throw new Exception('No snapshots here'); + } + + @unlink($file); + expect(true)->toBeTrue(); +})->flaky(tries: 3); + +it('does not leak dynamic properties between retries', function () { + $file = sys_get_temp_dir().'/pest_flaky_props'; + $count = file_exists($file) ? (int) file_get_contents($file) : 0; + file_put_contents($file, (string) ++$count); + + if ($count < 2) { + $this->leakedProperty = 'from attempt 1'; + throw new Exception('Flaky props'); + } + + @unlink($file); + expect(isset($this->leakedProperty))->toBeFalse(); +})->flaky(tries: 3); + +it('clears output buffer between retries when expectOutputString is used', function () { + $file = sys_get_temp_dir().'/pest_flaky_output'; + $count = file_exists($file) ? (int) file_get_contents($file) : 0; + file_put_contents($file, (string) ++$count); + + $this->expectOutputString('clean'); + + if ($count < 2) { + echo 'stale'; + throw new Exception('Flaky output'); + } + + @unlink($file); + echo 'clean'; +})->flaky(tries: 3); + +it('preserves output between retries when no output expectation is set', function () { + $file = sys_get_temp_dir().'/pest_flaky_output_no_expect'; + $count = file_exists($file) ? (int) file_get_contents($file) : 0; + file_put_contents($file, (string) ++$count); + + if ($count < 2) { + echo 'from attempt 1'; + throw new Exception('Flaky output no expect'); + } + + @unlink($file); + // Output from attempt 1 is still in the buffer + $this->expectOutputString('from attempt 1'); +})->flaky(tries: 3); diff --git a/tests/Fixtures/Arch/ToBeCasedCorrectly/CorrectCasing/CorrectCasing.php b/tests/Fixtures/Arch/ToBeCasedCorrectly/CorrectCasing/CorrectCasing.php new file mode 100644 index 00000000..8418d969 --- /dev/null +++ b/tests/Fixtures/Arch/ToBeCasedCorrectly/CorrectCasing/CorrectCasing.php @@ -0,0 +1,5 @@ +not->toBeEmpty(); +})->with('nested.users'); diff --git a/tests/Unit/Support/DatasetInfo.php b/tests/Unit/Support/DatasetInfo.php index b00c7b94..4feb6121 100644 --- a/tests/Unit/Support/DatasetInfo.php +++ b/tests/Unit/Support/DatasetInfo.php @@ -5,9 +5,14 @@ use Pest\Support\DatasetInfo; it('can check if dataset is defined inside a Datasets directory', function (string $file, bool $inside) { expect(DatasetInfo::isInsideADatasetsDirectory($file))->toBe($inside); })->with([ + ['file' => '/var/www/Datasets/project/tests/Datasets/Nested/Numbers.php', 'inside' => true], + ['file' => '/var/www/Datasets/project/tests/Features/Datasets/Nested/Numbers.php', 'inside' => true], ['file' => '/var/www/project/tests/Datasets/Numbers.php', 'inside' => true], + ['file' => '/var/www/project/tests/Datasets/Nested/Numbers.php', 'inside' => true], ['file' => '/var/www/project/tests/Datasets.php', 'inside' => false], ['file' => '/var/www/project/tests/Features/Datasets/Numbers.php', 'inside' => true], + ['file' => '/var/www/project/tests/Features/Datasets/Nested/Numbers.php', 'inside' => true], + ['file' => '/var/www/project/tests/Features/Datasets/Nested/Datasets/Numbers.php', 'inside' => true], ['file' => '/var/www/project/tests/Features/Numbers.php', 'inside' => false], ['file' => '/var/www/project/tests/Features/Datasets.php', 'inside' => false], ]); @@ -25,12 +30,18 @@ it('can check if dataset is defined inside a Datasets.php file', function (strin it('computes the dataset scope', function (string $file, string $scope) { expect(DatasetInfo::scope($file))->toBe($scope); })->with([ + ['file' => '/var/www/Datasets/project/tests/Datasets/Nested/Numbers.php', 'scope' => '/var/www/Datasets/project/tests'], + ['file' => '/var/www/Datasets/project/tests/Features/Datasets/Nested/Numbers.php', 'scope' => '/var/www/Datasets/project/tests/Features'], ['file' => '/var/www/project/tests/Datasets/Numbers.php', 'scope' => '/var/www/project/tests'], + ['file' => '/var/www/project/tests/Datasets/Nested/Numbers.php', 'scope' => '/var/www/project/tests'], ['file' => '/var/www/project/tests/Datasets.php', 'scope' => '/var/www/project/tests'], ['file' => '/var/www/project/tests/Features/Datasets/Numbers.php', 'scope' => '/var/www/project/tests/Features'], + ['file' => '/var/www/project/tests/Features/Datasets/Nested/Numbers.php', 'scope' => '/var/www/project/tests/Features'], ['file' => '/var/www/project/tests/Features/Numbers.php', 'scope' => '/var/www/project/tests/Features/Numbers.php'], ['file' => '/var/www/project/tests/Features/Datasets.php', 'scope' => '/var/www/project/tests/Features'], ['file' => '/var/www/project/tests/Features/Controllers/Datasets/Numbers.php', 'scope' => '/var/www/project/tests/Features/Controllers'], + ['file' => '/var/www/project/tests/Features/Controllers/Datasets/Nested/Numbers.php', 'scope' => '/var/www/project/tests/Features/Controllers'], + ['file' => '/var/www/project/tests/Features/Datasets/Nested/Datasets/Numbers.php', 'scope' => '/var/www/project/tests/Features'], ['file' => '/var/www/project/tests/Features/Controllers/Numbers.php', 'scope' => '/var/www/project/tests/Features/Controllers/Numbers.php'], ['file' => '/var/www/project/tests/Features/Controllers/Datasets.php', 'scope' => '/var/www/project/tests/Features/Controllers'], ]); diff --git a/tests/Visual/JUnit.php b/tests/Visual/JUnit.php index fd34ea7a..74cfe584 100644 --- a/tests/Visual/JUnit.php +++ b/tests/Visual/JUnit.php @@ -60,20 +60,17 @@ test('junit with parallel', function () use ($normalizedPath, $run) { expect($result['testsuite']['@attributes']) ->name->toBe('Tests\tests\SuccessOnly') ->file->toBe($normalizedPath('tests/.tests/SuccessOnly.php')) - ->tests->toBe('2') - ->assertions->toBe('2') + ->tests->toBe('1') + ->assertions->toBe('1') ->errors->toBe('0') ->failures->toBe('0') ->skipped->toBe('0'); - expect($result['testsuite']['testcase']) - ->toHaveCount(2); - - expect($result['testsuite']['testcase'][0]['@attributes']) + expect($result['testsuite']['testcase']['@attributes']) ->name->toBe('it can pass with comparison') ->file->toBe($normalizedPath('tests/.tests/SuccessOnly.php::it can pass with comparison')) ->class->toBe('Tests\tests\SuccessOnly') ->classname->toBe('Tests.tests.SuccessOnly') ->assertions->toBe('1') ->time->toStartWith('0.0'); -})->skip('Not working yet'); +}); diff --git a/tests/Visual/Parallel.php b/tests/Visual/Parallel.php index 1aced21d..be17ebde 100644 --- a/tests/Visual/Parallel.php +++ b/tests/Visual/Parallel.php @@ -15,11 +15,34 @@ $run = function () { }; test('parallel', function () use ($run) { - expect($run('--exclude-group=integration')) - ->toContain('Tests: 2 deprecated, 4 warnings, 5 incomplete, 2 notices, 39 todos, 26 skipped, 1177 passed (2789 assertions)') + $output = $run('--exclude-group=integration'); + + if (getenv('REBUILD_SNAPSHOTS')) { + preg_match('/Tests:\s+(.+\(\d+ assertions\))/', $output, $matches); + + $file = file_get_contents(__FILE__); + $file = preg_replace( + '/\$expected = \'.*?\';/', + "\$expected = '2 deprecated, 4 warnings, 5 incomplete, 3 notices, 40 todos, 27 skipped, 1280 passed (2926 assertions)';", + $file, + ); + file_put_contents(__FILE__, $file); + } + + $expected = '2 deprecated, 4 warnings, 5 incomplete, 3 notices, 40 todos, 27 skipped, 1280 passed (2926 assertions)'; + + expect($output) + ->toContain("Tests: {$expected}") ->toContain('Parallel: 3 processes'); })->skipOnWindows(); test('a parallel test can extend another test with same name', function () use ($run) { - expect($run('tests/Fixtures/Inheritance'))->toContain('Tests: 1 skipped, 2 passed (2 assertions)'); -}); + expect($run('tests/Fixtures/Inheritance'))->toContain('Tests: 1 skipped, 1 passed (1 assertions)'); +})->skipOnWindows(); + +test('parallel reports invalid datasets as failures', function () use ($run) { + expect($run('tests/.tests/ParallelInvalidDataset')) + ->toContain("A dataset with the name `missing.dataset` does not exist. You can create it using `dataset('missing.dataset', ['a', 'b']);`.") + ->toContain('Tests: 1 failed, 1 passed (1 assertions)') + ->toContain('Parallel: 3 processes'); +})->skipOnWindows(); diff --git a/tests/Visual/ParallelNestedDatasets.php b/tests/Visual/ParallelNestedDatasets.php new file mode 100644 index 00000000..37052556 --- /dev/null +++ b/tests/Visual/ParallelNestedDatasets.php @@ -0,0 +1,40 @@ + 'DefaultPrinter', 'COLLISION_IGNORE_DURATION' => 'true'], + ); + + $process->run(); + + return [ + 'exitCode' => $process->getExitCode(), + 'output' => removeAnsiEscapeSequences($process->getOutput().$process->getErrorOutput()), + ]; +}; + +test('parallel loads nested datasets from nested directories', function () use ($run) { + $serial = $run(); + $parallel = $run(true); + + expect($serial['exitCode'])->toBe(0) + ->and($parallel['exitCode'])->toBe(0) + ->and($serial['output'])->toContain('Tests: 2 passed (2 assertions)') + ->and($parallel['output'])->toContain('Tests: 2 passed (2 assertions)') + ->and($parallel['output'])->toContain('Parallel: 2 processes') + ->and($parallel['output'])->not->toContain('No tests found.'); +})->skipOnWindows(); diff --git a/tests/Visual/Success.php b/tests/Visual/Success.php index 16d49a38..7906378f 100644 --- a/tests/Visual/Success.php +++ b/tests/Visual/Success.php @@ -12,7 +12,7 @@ test('visual snapshot of test suite on success', function () { $output = function () use ($testsPath) { $process = (new Process( - ['php', 'bin/pest'], + ['php', '-d', 'memory_limit=256M', 'bin/pest'], dirname($testsPath), ['EXCLUDE' => 'integration', '--exclude-group' => 'integration', 'REBUILD_SNAPSHOTS' => false, 'PARATEST' => 0, 'COLLISION_PRINTER' => 'DefaultPrinter', 'COLLISION_IGNORE_DURATION' => 'true'], )); diff --git a/tests/Visual/UnicodeFilename.php b/tests/Visual/UnicodeFilename.php new file mode 100644 index 00000000..d12ed20f --- /dev/null +++ b/tests/Visual/UnicodeFilename.php @@ -0,0 +1,37 @@ + 'DefaultPrinter', 'COLLISION_IGNORE_DURATION' => 'true']); + + $process->run(); + + $output = $process->getOutput(); + + expect($output)->toContain('StraßenTest'); + expect($output)->toContain('tests unicode filename'); + expect($output)->toContain('1 passed'); +})->skipOnWindows(); + +test('filter with unicode regex matches unicode filename', function () { + $process = new Process([ + 'php', + 'bin/pest', + '--filter=.*Straß.*', + 'tests/.tests/', + '--colors=never', + ], dirname(__DIR__, 2), ['COLLISION_PRINTER' => 'DefaultPrinter', 'COLLISION_IGNORE_DURATION' => 'true']); + + $process->run(); + + $output = $process->getOutput(); + + expect($output)->toContain('StraßenTest'); + expect($output)->toContain('1 passed'); +})->skipOnWindows();