Compare commits

...

79 Commits

Author SHA1 Message Date
89006d83a9 chore: env 2026-04-10 16:27:44 +01:00
a8e974d64a chore: updates snapshots 2026-04-10 14:42:34 +01:00
617b074049 Merge pull request #1626 from SimonBroekaert/feat/add_only-covered_option
feat: add '--only-covered' option to '--coverage'
2026-04-10 12:53:17 +01:00
2eea71a664 Merge pull request #1624 from Vmadmax/fix/unicode-filename-filter
fix: preserve Unicode characters in filenames for --filter matching
2026-04-10 12:29:40 +01:00
4b5374d507 Merge branch '4.x' into fix/unicode-filename-filter 2026-04-10 12:29:30 +01:00
9085561ece chore: runs at 9am 2026-04-10 12:24:30 +01:00
b71bfc513a chore: guards 2026-04-10 12:23:49 +01:00
75938ac9eb ci: updates deps 2026-04-10 12:18:28 +01:00
e766825f5b chore: fixes test:unit 2026-04-10 12:15:00 +01:00
8a83a1a1a9 Merge pull request #1655 from stsepelin/fix/parallel-empty-suite-reporting
fix: nested dataset discovery and parallel invalid-dataset reporting
2026-04-10 11:59:48 +01:00
109bb22c5e Merge pull request #1615 from smirok/parallel-teamcity-concurrency-fix
fix: enhance support for --parallel and --teamcity arguments by restoring --teamcity for ParaTest and fixing teamcity output concurrency
2026-04-10 11:42:45 +01:00
89dd212d84 Merge pull request #1580 from bibrokhim/patch-1
Add Rules to Laravel preset
2026-04-10 11:42:13 +01:00
cd07c6d966 Merge pull request #1569 from treyssatvincent/patch-1
add missing classes before toExtend on laravel preset
2026-04-10 11:41:53 +01:00
3a6c2fab37 Merge pull request #1515 from yondifon/fix-trait-inheritance-detection
BugFix: Fix toUseTrait to detect inherited and nested traits
2026-04-10 11:39:56 +01:00
281dbf6cf4 Merge pull request #1455 from SimonBroekaert/feat/to_be_cased_correctly_arch_test_assertion
feat: add toBeCasedCorrectly arch test assertion
2026-04-10 11:38:46 +01:00
40c8429058 Merge pull request #1653 from orphanedrecord/fix/pest-php-stub-typo
Fix typo in Pest.php stubs
2026-04-10 11:35:41 +01:00
d9d46c73f8 chore: stores statically the result 2026-04-09 21:36:49 +01:00
e44c554a0b chore: bumps dependencies 2026-04-06 21:57:05 +01:00
9797a71dbc feat(ai): allow temporary namesapce 2026-04-03 14:43:28 +01:00
c1a54df233 feat: --ai work in progress 2026-04-03 14:04:20 +01:00
ce05ee9aad release: v4.4.4 2026-04-03 12:00:04 +01:00
3d2ebdb273 bump: dependencies 2026-04-03 11:59:54 +01:00
f47b74445b Merge pull request #1657 from morpheus7CS/pint-formatting-rules-applied-to-stubs 2026-04-02 13:04:00 +01:00
6c42e7f4ea Laravel Pint default formatting applied to Pest-php.stub 2026-04-01 16:48:14 +02:00
a087555383 bump: dependencies 2026-03-26 14:30:03 +00:00
4b50cb486d Restore success snapshot coverage with lower memory limit 2026-03-25 23:59:29 +02:00
f7175ecfd7 Fix parallel dataset reporting and nested fixtures 2026-03-25 23:59:29 +02:00
07737bc0b2 Fix parallel file selection and empty-suite reporting 2026-03-25 23:59:28 +02:00
e6ab897594 release: v4.4.3 2026-03-21 13:14:39 +00:00
a753b41409 chore: bumps phpunit 2026-03-21 13:14:35 +00:00
1a4c06bd6e Fix Pest comment typo while still honoring the Otwellian Waterfall 2026-03-18 20:51:45 -07:00
5d42e8fe3a release: v4.4.2 2026-03-10 21:09:12 +00:00
9d17b872dd chore: update stubs 2026-03-10 21:09:02 +00:00
2a80101f42 chore: update styling 2026-03-10 21:06:28 +00:00
f7015fe59c chore: adjusts sponsors 2026-02-24 10:44:48 +00:00
7281e0ded7 Add SerpApi to the list of sponsors
Add SerpApi as a sponsor in the README.
2026-02-20 01:18:43 +00:00
1675dd1d41 chore: add tests for toBeCasedCorrectly() arch test 2026-02-17 19:03:46 +01:00
df7b6c8454 feat: add toBeCasedCorrectly arch test assertion 2026-02-17 17:31:02 +01:00
5de8693e3b chore: style 2026-02-17 16:15:58 +00:00
7d80f1d20e chore: removes non used files 2026-02-17 15:34:32 +00:00
b3119cc120 Merge pull request #1562 from michaelw85/patch-1
[Fix] Pass test dir to worker
2026-02-17 15:33:16 +00:00
4e294edf76 Merge pull request #1639 from imliam/patch-1
[Laravel Preset] Allow App\Http to be used in providers
2026-02-17 15:31:01 +00:00
f96a1b2786 release: v4.4.1 2026-02-17 15:27:18 +00:00
a49cf7edc5 ci: speed up ci 2026-02-17 15:21:20 +00:00
b0f6a74cb6 ci: makes jobs faster 2026-02-17 15:18:33 +00:00
aaa226f6a6 chore: tests against symfony 8 2026-02-17 15:14:45 +00:00
69cb752d02 chore: bumps dependencies 2026-02-17 15:01:37 +00:00
cf00e58b7d chore: bumps dependencies 2026-02-17 11:22:04 +00:00
1f39b28e2c Allow App\Http to be used in providers 2026-02-16 00:25:47 +01:00
9fcbca69d4 Update README.md 2026-02-13 10:41:22 +00:00
c3620840b4 feat: add '--only-covered' option to '--coverage' 2026-02-06 11:57:21 +01:00
10a19f16ba refactor: simplify regex to use Unicode properties \p{L} and \p{N 2026-02-02 09:41:54 +01:00
a956de5446 fix: preserve Unicode characters in class names for --filter matching 2026-02-02 09:01:24 +01:00
3a4329ddc7 release: 4.3.2 2026-01-28 01:01:19 +00:00
e6f511302b fix: enhance support for --parallel and --teamcity arguments by restoring --teamcity for ParaTest and fixing teamcity output concurrency 2026-01-27 16:47:24 +01:00
dd01229d7b Merge pull request #1606 from smirok/teamcity-fix-for-describe-tests-with-dataset
fix: replace `substr` with `mb_substr` in Str::beforeLast to ensure multibyte string compatibility and correct TeamCity test names for datasets in "describe" blocks
2026-01-15 01:52:01 +00:00
c7e4efcea4 fix: replace substr with mb_substr in Str::beforeLast to ensure multibyte string compatibility and correct TeamCity test names for datasets in "describe" blocks 2026-01-14 00:55:35 +01:00
df3205e814 Merge pull request #1554 from pindab0ter/feature/extend-closure-this
Specify closure this for extend
2026-01-13 01:19:47 +00:00
bc57a84e77 release: 4.3.1 2026-01-04 11:29:59 -05:00
bc39830d8a chore: removes toHaveSuspiciousCharacters from php preset 2026-01-04 11:25:57 -05:00
3a566b100e docs: why php 2026-01-04 11:04:03 -05:00
9fe61e0e56 docs: update sponsors
Removed and updated sponsor links in the README.
2026-01-03 18:07:02 -05:00
e86bec3e68 release: 4.3.0 2025-12-30 14:48:33 -05:00
58b8f3cc5d Merge pull request #1598 from Willem-Jaap/willem-jaap/pest-only-file-level
feat: add pest only function to mark each test in a file as only
2025-12-30 14:40:04 -05:00
c157b661f2 style: format 2025-12-30 09:26:35 +01:00
be90610f17 feat: add pest only function to mark each test in a file as only 2025-12-30 09:24:05 +01:00
1701a306c3 Merge pull request #1590 from pestphp/feature/intl-exception
feat: show more useful exception when `intl` extension not found
2025-12-29 22:09:16 -05:00
064ab3fc2e Merge pull request #1595 from bilboque/dirty-flag-not-found-in-help
feat: add --dirty documentation in --help
2025-12-29 22:08:12 -05:00
leo
44e315df98 feat: add --dirty documentation in --help 2025-12-22 11:02:28 +01:00
62694c14b9 chore: style 2025-12-15 11:54:24 +00:00
7c43c1c583 Merge pull request #1586 from jackbayliss/bump-checkout-action
[4.x] Bump checkout version from 5 to 6
2025-12-15 11:49:28 +00:00
6a96aed654 feat: adds phpunit@12.5 support 2025-12-15 11:48:43 +00:00
b1c997a869 feat: show more useful exception when intl extension not found 2025-12-12 12:02:00 +00:00
b4172e2c2e bump checkout version from 5 to 6 2025-12-10 14:08:06 +00:00
0e7c2abe8b Add Rules to Laravel preset 2025-11-25 15:32:36 +05:00
bd5fed9e12 add missing classes before toExtend on laravel preset
Add missing `->classes()` before `->toExtend()` on the laravel preset for 2 namespaces.

Otherwise you can't use interfaces on theses namespace.

For example, if you create an interface `YourSuperRequestContract` on the `app/Http/Requests` namespace you will get this error:

```
Expecting 'app/Http/Requests/YourSuperRequestContract.php' to extend 'Illuminate\Foundation\Http\FormRequest'.
```
2025-11-10 15:26:56 +00:00
ae1da79ac1 Pass test dir to worker
#1444 
Test directory argument is lost when spawning workers, add it again.
2025-11-05 17:15:51 +01:00
b7b16096db Specify closure this for extend 2025-10-29 11:20:08 +01:00
dc9a1e8ace BugFix: Fix toUseTrait to detect inherited and nested traits 2025-09-20 19:06:23 +01:00
83 changed files with 809 additions and 175 deletions

View File

@ -2,9 +2,14 @@ name: Static Analysis
on:
push:
branches: [4.x]
pull_request:
schedule:
- cron: '0 0 * * *'
- cron: '0 9 * * *'
concurrency:
group: static-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
static:
@ -12,6 +17,7 @@ jobs:
name: Static Tests
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
fail-fast: true
matrix:
@ -19,7 +25,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Setup PHP
uses: shivammathur/setup-php@v2
@ -29,8 +35,22 @@ jobs:
coverage: none
extensions: sockets
- name: Get Composer cache directory
id: composer-cache
shell: bash
run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
- name: Cache Composer dependencies
uses: actions/cache@v5
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: static-php-8.3-${{ matrix.dependency-version }}-composer-${{ hashFiles('**/composer.json') }}
restore-keys: |
static-php-8.3-${{ matrix.dependency-version }}-composer-
static-php-8.3-composer-
- name: Install Dependencies
run: composer update --prefer-stable --no-interaction --no-progress --ansi
run: composer update --${{ matrix.dependency-version }} --no-interaction --no-progress --ansi
- name: Profanity Check
run: composer test:profanity
@ -41,8 +61,5 @@ jobs:
- name: Type Coverage
run: composer test:type:coverage
- name: Refacto
run: composer test:refacto
- name: Style
run: composer test:lint

View File

@ -2,26 +2,37 @@ name: Tests
on:
push:
branches: [4.x]
pull_request:
schedule:
- cron: '0 9 * * *'
concurrency:
group: tests-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
tests:
if: github.event_name != 'schedule' || github.repository == 'pestphp/pest'
runs-on: ${{ matrix.os }}
timeout-minutes: 15
strategy:
fail-fast: true
matrix:
os: [ubuntu-latest, macos-latest] # windows-latest
symfony: ['7.3']
symfony: ['7.4', '8.0']
php: ['8.3', '8.4', '8.5']
dependency_version: [prefer-stable]
exclude:
- php: '8.3'
symfony: '8.0'
name: PHP ${{ matrix.php }} - Symfony ^${{ matrix.symfony }} - ${{ matrix.os }} - ${{ matrix.dependency_version }}
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Setup PHP
uses: shivammathur/setup-php@v2
@ -31,6 +42,20 @@ jobs:
coverage: none
extensions: sockets
- name: Get Composer cache directory
id: composer-cache
shell: bash
run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
- name: Cache Composer dependencies
uses: actions/cache@v5
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ matrix.os }}-php-${{ matrix.php }}-symfony-${{ matrix.symfony }}-composer-${{ hashFiles('**/composer.json') }}
restore-keys: |
${{ matrix.os }}-php-${{ matrix.php }}-symfony-${{ matrix.symfony }}-composer-
${{ matrix.os }}-php-${{ matrix.php }}-composer-
- name: Setup Problem Matches
run: |
echo "::add-matcher::${{ runner.tool_cache }}/php.json"

View File

@ -1,14 +0,0 @@
# Well documented Makefiles
DEFAULT_GOAL := help
help:
@awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m<target>\033[0m\n"} /^[a-zA-Z0-9_-]+:.*?##/ { printf " \033[36m%-40s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST)
build: ## Build all docker images. Specify the command e.g. via make build ARGS="--build-arg PHP=8.2"
docker compose build $(ARGS)
##@ [Application]
install: ## Install the composer dependencies
docker compose run --rm composer install
test: ## Run the tests
docker compose run --rm composer test

View File

@ -5,6 +5,7 @@
<a href="https://packagist.org/packages/pestphp/pest"><img alt="Total Downloads" src="https://img.shields.io/packagist/dt/pestphp/pest"></a>
<a href="https://packagist.org/packages/pestphp/pest"><img alt="Latest Version" src="https://img.shields.io/packagist/v/pestphp/pest"></a>
<a href="https://packagist.org/packages/pestphp/pest"><img alt="License" src="https://img.shields.io/packagist/l/pestphp/pest"></a>
<a href="https://whyphp.dev"><img src="https://img.shields.io/badge/Why_PHP-in_2026-7A86E8?style=flat-square&labelColor=18181b" alt="Why PHP in 2026"></a>
</p>
</p>
@ -30,25 +31,23 @@ We cannot thank our sponsors enough for their incredible support in funding Pest
### Platinum Sponsors
- **[Laracasts](https://laracasts.com/?ref=pestphp)**
- **[NativePHP](https://nativephp.com/mobile?ref=pestphp.com)**
- **[CodeRabbit](https://coderabbit.ai/?ref=pestphp)**
- **[Mailtrap](https://l.rw.rw/pestphp)**
- **[SerpApi](https://serpapi.com/?ref=nunomaduro)**
- **[Tighten](https://tighten.com/?ref=nunomaduro)**
- **[Redberry](https://redberry.international/laravel-development/?utm_source=pest&utm_medium=banner&utm_campaign=pest_sponsorship)**
### Gold Sponsors
- **[CodeRabbit](https://coderabbit.ai/?ref=pestphp)**
- **[CMS Max](https://cmsmax.com/?ref=pestphp)**
### Premium Sponsors
- [Forge](https://forge.laravel.com/?ref=pestphp)
- [Zapiet](https://www.zapiet.com/?ref=pestphp)
- [Localazy](https://localazy.com/?ref=pestphp)
- [Zapiet](https://zapiet.com/?ref=pestphp)
- [Load Forge](https://loadforge.com/?ref=pestphp)
- [DocuWriter.ai](https://www.docuwriter.ai/?ref=pestphp)
- [Route4Me](https://www.route4me.com/?ref=pestphp)
- [Devtools for Livewire](https://devtools-for-livewire.com/?ref=pestphp)
- [Nerdify](https://www.getnerdify.com/?ref=pestphp)
- [Route4Me](https://route4me.com/pt?ref=pestphp)
- [Nerdify](https://getnerdify.com/?ref=pestphp)
- [Akaunting](https://akaunting.com/?ref=pestphp)
- [LambdaTest](https://lambdatest.com/?ref=pestphp)
- [TestMu AI](https://www.testmuai.com/?utm_medium=sponsor&utm_source=pest)
Pest is an open-sourced software licensed under the **[MIT license](https://opensource.org/licenses/MIT)**.

View File

@ -18,19 +18,19 @@
],
"require": {
"php": "^8.3.0",
"brianium/paratest": "^7.14.2",
"nunomaduro/collision": "^8.8.3",
"nunomaduro/termwind": "^2.3.3",
"brianium/paratest": "^7.20.0",
"nunomaduro/collision": "^8.9.3",
"nunomaduro/termwind": "^2.4.0",
"pestphp/pest-plugin": "^4.0.0",
"pestphp/pest-plugin-arch": "^4.0.0",
"pestphp/pest-plugin-mutate": "^4.0.1",
"pestphp/pest-plugin-profanity": "^4.2.0",
"phpunit/phpunit": "^12.4.4",
"symfony/process": "^7.4.0|^8.0.0"
"pestphp/pest-plugin-profanity": "^4.2.1",
"phpunit/phpunit": "^12.5.16",
"symfony/process": "^7.4.8|^8.0.8"
},
"conflict": {
"filp/whoops": "<2.18.3",
"phpunit/phpunit": ">12.4.4",
"phpunit/phpunit": ">12.5.16",
"sebastian/exporter": "<7.0.0",
"webmozart/assert": "<1.11.0"
},
@ -55,10 +55,10 @@
]
},
"require-dev": {
"pestphp/pest-dev-tools": "^4.0.0",
"pestphp/pest-plugin-browser": "^4.1.1",
"pestphp/pest-plugin-type-coverage": "^4.0.3",
"psy/psysh": "^0.12.15"
"pestphp/pest-dev-tools": "^4.1.0",
"pestphp/pest-plugin-browser": "^4.3.1",
"pestphp/pest-plugin-type-coverage": "^4.0.4",
"psy/psysh": "^0.12.22"
},
"minimum-stability": "dev",
"prefer-stable": true,
@ -73,10 +73,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",
@ -86,7 +90,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",

View File

@ -1,14 +0,0 @@
version: "3.8"
services:
php:
build:
context: ./docker
volumes:
- .:/var/www/html
composer:
build:
context: ./docker
volumes:
- .:/var/www/html
entrypoint: ["composer"]

View File

@ -14,6 +14,9 @@ namespace PHPUnit\Logging\JUnit;
use DOMDocument;
use DOMElement;
use Pest\Logging\Converter;
use Pest\Support\Container;
use Pest\TestSuite;
use PHPUnit\Event\Code\Test;
use PHPUnit\Event\Code\TestMethod;
use PHPUnit\Event\EventFacadeIsSealedException;
@ -50,7 +53,7 @@ final class JunitXmlLogger
{
private readonly Printer $printer;
private readonly \Pest\Logging\Converter $converter; // pest-added
private readonly Converter $converter; // pest-added
private DOMDocument $document;
@ -108,7 +111,7 @@ final class JunitXmlLogger
public function __construct(Printer $printer, Facade $facade)
{
$this->printer = $printer;
$this->converter = new \Pest\Logging\Converter(\Pest\Support\Container::getInstance()->get(\Pest\TestSuite::class)->rootPath); // pest-added
$this->converter = new Converter(Container::getInstance()->get(TestSuite::class)->rootPath); // pest-added
$this->registerSubscribers($facade);
$this->createDocument();

View File

@ -2,8 +2,9 @@
declare(strict_types=1);
use Rector\CodingStyle\Rector\FunctionLike\FunctionLikeToFirstClassCallableRector;
use Rector\CodingStyle\Rector\ArrowFunction\ArrowFunctionDelegatingCallToFirstClassCallableRector;
use Rector\Config\RectorConfig;
use Rector\DeadCode\Rector\ClassMethod\RemoveParentDelegatingConstructorRector;
use Rector\TypeDeclaration\Rector\ClassMethod\NarrowObjectReturnTypeRector;
use Rector\TypeDeclaration\Rector\ClassMethod\ReturnNeverTypeRector;
@ -14,8 +15,9 @@ return RectorConfig::configure()
->withSkip([
__DIR__.'/src/Plugins/Parallel/Paratest/WrapperRunner.php',
ReturnNeverTypeRector::class,
FunctionLikeToFirstClassCallableRector::class,
ArrowFunctionDelegatingCallToFirstClassCallableRector::class,
NarrowObjectReturnTypeRector::class,
RemoveParentDelegatingConstructorRector::class,
])
->withPreparedSets(
deadCode: true,

View File

@ -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')
@ -150,7 +153,7 @@ final class Laravel extends AbstractPreset
->toHaveSuffix('Controller');
$this->expectations[] = expect('App\Http')
->toOnlyBeUsedIn('App\Http');
->toOnlyBeUsedIn(['App\Http', 'App\Providers']);
$this->expectations[] = expect('App\Http\Controllers')
->not->toHavePublicMethodsBesides(['__construct', '__invoke', 'index', 'show', 'create', 'store', 'edit', 'update', 'destroy', 'middleware']);
@ -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');
}
}

View File

@ -4,9 +4,6 @@ declare(strict_types=1);
namespace Pest\ArchPresets;
use Pest\Arch\Contracts\ArchExpectation;
use Pest\Expectation;
/**
* @internal
*/
@ -92,9 +89,5 @@ final class Php extends AbstractPreset
'xdebug_var_dump',
'trap',
])->not->toBeUsed();
$this->eachUserNamespace(
fn (Expectation $namespace): ArchExpectation => $namespace->not->toHaveSuspiciousCharacters(),
);
}
}

View File

@ -8,6 +8,8 @@ use Closure;
/**
* @internal
*
* @template T of object
*/
trait Extendable
{
@ -20,6 +22,8 @@ trait Extendable
/**
* Register a new extend.
*
* @param-closure-this T $extend
*/
public function extend(string $name, Closure $extend): void
{

View File

@ -66,6 +66,6 @@ trait Pipeable
*/
private function pipes(string $name, object $context, string $scope): array
{
return array_map(fn (Closure $pipe): \Closure => $pipe->bindTo($context, $scope), self::$pipes[$name] ?? []);
return array_map(fn (Closure $pipe): Closure => $pipe->bindTo($context, $scope), self::$pipes[$name] ?? []);
}
}

View File

@ -129,7 +129,7 @@ trait Testable
*/
public function __addBeforeAll(?Closure $hook): void
{
if (! $hook instanceof \Closure) {
if (! $hook instanceof Closure) {
return;
}
@ -143,7 +143,7 @@ trait Testable
*/
public function __addAfterAll(?Closure $hook): void
{
if (! $hook instanceof \Closure) {
if (! $hook instanceof Closure) {
return;
}
@ -173,7 +173,7 @@ trait Testable
*/
private function __addHook(string $property, ?Closure $hook): void
{
if (! $hook instanceof \Closure) {
if (! $hook instanceof Closure) {
return;
}

View File

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Pest;
use Pest\PendingCalls\BeforeEachCall;
use Pest\PendingCalls\UsesCall;
/**
@ -62,6 +63,14 @@ final readonly class Configuration
return (new UsesCall($this->filename, []))->group(...$groups);
}
/**
* Marks all tests in the current file to be run exclusively.
*/
public function only(): void
{
(new BeforeEachCall(TestSuite::getInstance(), $this->filename))->only();
}
/**
* Depending on where is called, it will extend the given classes and traits globally or locally.
*/

View File

@ -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;
@ -52,7 +53,9 @@ use ReflectionProperty;
*/
final class Expectation
{
/** @use Extendable<self<TValue>> */
use Extendable;
use Pipeable;
use Retrievable;
@ -134,7 +137,7 @@ final class Expectation
/**
* Dump the expectation value when the result of the condition is truthy.
*
* @param (\Closure(TValue): bool)|bool $condition
* @param (Closure(TValue): bool)|bool $condition
* @return self<TValue>
*/
public function ddWhen(Closure|bool $condition, mixed ...$arguments): Expectation
@ -151,7 +154,7 @@ final class Expectation
/**
* Dump the expectation value when the result of the condition is falsy.
*
* @param (\Closure(TValue): bool)|bool $condition
* @param (Closure(TValue): bool)|bool $condition
* @return self<TValue>
*/
public function ddUnless(Closure|bool $condition, mixed ...$arguments): Expectation
@ -667,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::userNamespacesWithDirectories() 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.
*/
@ -781,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;
}
}

View File

@ -15,6 +15,7 @@ use Pest\Arch\PendingArchExpectation;
use Pest\Arch\SingleArchExpectation;
use Pest\Arch\Support\FileLineFinder;
use Pest\Exceptions\InvalidExpectation;
use Pest\Exceptions\MissingDependency;
use Pest\Expectation;
use Pest\Support\Arr;
use Pest\Support\Exporter;
@ -284,6 +285,10 @@ final readonly class OppositeExpectation
*/
public function toHaveSuspiciousCharacters(): ArchExpectation
{
if (! class_exists(Spoofchecker::class)) {
throw new MissingDependency(__FUNCTION__, 'ext-intl >= 2.0');
}
$checker = new Spoofchecker;
/** @var Expectation<array<int, string>|string> $original */

View File

@ -17,6 +17,7 @@ use Pest\Factories\Concerns\HigherOrderable;
use Pest\Support\Reflection;
use Pest\Support\Str;
use Pest\TestSuite;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase;
use RuntimeException;
@ -58,6 +59,11 @@ final class TestCaseFactory
Concerns\Expectable::class,
];
/**
* The namespace for the test case, overrides the path-based namespace when set.
*/
public ?string $namespace = null;
/**
* Creates a new Factory instance.
*/
@ -110,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;
@ -126,7 +132,7 @@ final class TestCaseFactory
$partsFQN = explode('\\', $classFQN);
$className = array_pop($partsFQN);
$namespace = implode('\\', $partsFQN);
$namespace = $this->namespace ?? implode('\\', $partsFQN);
$baseClass = sprintf('\%s', $this->class);
if (trim($className) === '') {
@ -135,7 +141,7 @@ final class TestCaseFactory
$this->attributes = [
new Attribute(
\PHPUnit\Framework\Attributes\TestDox::class,
TestDox::class,
[$this->filename],
),
...$this->attributes,

View File

@ -9,10 +9,14 @@ use Pest\Evaluators\Attributes;
use Pest\Exceptions\ShouldNotHappen;
use Pest\Factories\Concerns\HigherOrderable;
use Pest\Repositories\DatasetsRepository;
use Pest\Support\Description;
use Pest\Support\Str;
use Pest\TestSuite;
use PHPUnit\Framework\Assert;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Depends;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase;
/**
@ -32,7 +36,7 @@ final class TestCaseMethodFactory
/**
* The test's describing, if any.
*
* @var array<int, \Pest\Support\Description>
* @var array<int, Description>
*/
public array $describing = [];
@ -192,11 +196,11 @@ final class TestCaseMethodFactory
$this->attributes = [
new Attribute(
\PHPUnit\Framework\Attributes\Test::class,
Test::class,
[],
),
new Attribute(
\PHPUnit\Framework\Attributes\TestDox::class,
TestDox::class,
[str_replace('*/', '{@*}', $this->description)],
),
...$this->attributes,
@ -206,7 +210,7 @@ final class TestCaseMethodFactory
$depend = Str::evaluable($this->describing === [] ? $depend : Str::describe($this->describing, $depend));
$this->attributes[] = new Attribute(
\PHPUnit\Framework\Attributes\Depends::class,
Depends::class,
[$depend],
);
}

View File

@ -140,7 +140,7 @@ if (! function_exists('test')) {
*/
function test(?string $description = null, ?Closure $closure = null): HigherOrderTapProxy|TestCall
{
if ($description === null && TestSuite::getInstance()->test instanceof \PHPUnit\Framework\TestCase) {
if ($description === null && TestSuite::getInstance()->test instanceof TestCase) {
return new HigherOrderTapProxy(TestSuite::getInstance()->test);
}
@ -236,7 +236,7 @@ if (! function_exists('covers')) {
/** @var MutationTestRunner $runner */
$runner = Container::getInstance()->get(MutationTestRunner::class);
/** @var \Pest\Mutate\Repositories\ConfigurationRepository $configurationRepository */
/** @var ConfigurationRepository $configurationRepository */
$configurationRepository = Container::getInstance()->get(ConfigurationRepository::class);
$everything = $configurationRepository->cliConfiguration->toArray()['everything'] ?? false;
$classes = $configurationRepository->cliConfiguration->toArray()['classes'] ?? false;
@ -263,7 +263,7 @@ if (! function_exists('mutates')) {
/** @var MutationTestRunner $runner */
$runner = Container::getInstance()->get(MutationTestRunner::class);
/** @var \Pest\Mutate\Repositories\ConfigurationRepository $configurationRepository */
/** @var ConfigurationRepository $configurationRepository */
$configurationRepository = Container::getInstance()->get(ConfigurationRepository::class);
$everything = $configurationRepository->cliConfiguration->toArray()['everything'] ?? false;
$classes = $configurationRepository->cliConfiguration->toArray()['classes'] ?? false;
@ -320,7 +320,7 @@ if (! function_exists('visit')) {
*/
function visit(array|string $url, array $options = []): ArrayablePendingAwaitablePage|PendingAwaitablePage
{
if (! class_exists(\Pest\Browser\Configuration::class)) {
if (! class_exists(Pest\Browser\Configuration::class)) {
PluginBrowser::install();
exit(0);

View File

@ -151,7 +151,7 @@ final readonly class Converter
{
if ($testSuite instanceof TestSuiteForTestMethodWithDataProvider) {
$firstTest = $this->getFirstTest($testSuite);
if ($firstTest instanceof \PHPUnit\Event\Code\TestMethod) {
if ($firstTest instanceof TestMethod) {
return $this->getTestMethodNameWithoutDatasetSuffix($firstTest);
}
}
@ -179,7 +179,7 @@ final readonly class Converter
public function getTestSuiteLocation(TestSuite $testSuite): ?string
{
$firstTest = $this->getFirstTest($testSuite);
if (! $firstTest instanceof \PHPUnit\Event\Code\TestMethod) {
if (! $firstTest instanceof TestMethod) {
return null;
}
$path = $firstTest->testDox()->prettifiedClassName();

View File

@ -200,7 +200,7 @@ final class TeamCityLogger
public function testFinished(Finished $event): void
{
if (! $this->time instanceof \PHPUnit\Event\Telemetry\HRTime) {
if (! $this->time instanceof HRTime) {
throw ShouldNotHappen::fromMessage('Start time has not been set.');
}

View File

@ -9,6 +9,7 @@ use Closure;
use Countable;
use DateTimeInterface;
use Error;
use Illuminate\Testing\TestResponse;
use InvalidArgumentException;
use JsonSerializable;
use Pest\Exceptions\InvalidExpectationValue;
@ -842,7 +843,7 @@ final class Expectation
is_object($this->value) && method_exists($this->value, 'toSnapshot') => $this->value->toSnapshot(),
is_object($this->value) && method_exists($this->value, '__toString') => $this->value->__toString(),
is_object($this->value) && method_exists($this->value, 'toString') => $this->value->toString(),
$this->value instanceof \Illuminate\Testing\TestResponse => $this->value->getContent(), // @phpstan-ignore-line
$this->value instanceof TestResponse => $this->value->getContent(), // @phpstan-ignore-line
is_array($this->value) => json_encode($this->value, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT),
$this->value instanceof Traversable => json_encode(iterator_to_array($this->value), JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT),
$this->value instanceof JsonSerializable => json_encode($this->value->jsonSerialize(), JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT),
@ -983,7 +984,7 @@ final class Expectation
*/
private function export(mixed $value): string
{
if (! $this->exporter instanceof \Pest\Support\Exporter) {
if (! $this->exporter instanceof Exporter) {
$this->exporter = Exporter::default();
}

View File

@ -4,6 +4,8 @@ declare(strict_types=1);
namespace Pest\PendingCalls\Concerns;
use Pest\Support\Description;
/**
* @internal
*/
@ -12,14 +14,14 @@ trait Describable
/**
* Note: this is property is not used; however, it gets added automatically by rector php.
*
* @var array<int, \Pest\Support\Description>
* @var array<int, Description>
*/
public array $__describing;
/**
* The describing of the test case.
*
* @var array<int, \Pest\Support\Description>
* @var array<int, Description>
*/
public array $describing = [];
}

View File

@ -73,7 +73,7 @@ final class DescribeCall
{
$filename = Backtrace::file();
if (! $this->currentBeforeEachCall instanceof \Pest\PendingCalls\BeforeEachCall) {
if (! $this->currentBeforeEachCall instanceof BeforeEachCall) {
$this->currentBeforeEachCall = new BeforeEachCall(TestSuite::getInstance(), $filename);
$this->currentBeforeEachCall->describing[] = $this->description;

View File

@ -22,6 +22,10 @@ use Pest\Support\NullClosure;
use Pest\Support\Str;
use Pest\TestSuite;
use PHPUnit\Framework\AssertionFailedError;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\CoversFunction;
use PHPUnit\Framework\Attributes\CoversTrait;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\TestCase;
/**
@ -211,7 +215,7 @@ final class TestCall // @phpstan-ignore-line
{
foreach ($groups as $group) {
$this->testCaseMethod->attributes[] = new Attribute(
\PHPUnit\Framework\Attributes\Group::class,
Group::class,
[$group],
);
}
@ -604,7 +608,7 @@ final class TestCall // @phpstan-ignore-line
{
foreach ($classes as $class) {
$this->testCaseFactoryAttributes[] = new Attribute(
\PHPUnit\Framework\Attributes\CoversClass::class,
CoversClass::class,
[$class],
);
}
@ -627,7 +631,7 @@ final class TestCall // @phpstan-ignore-line
{
foreach ($traits as $trait) {
$this->testCaseFactoryAttributes[] = new Attribute(
\PHPUnit\Framework\Attributes\CoversTrait::class,
CoversTrait::class,
[$trait],
);
}
@ -650,7 +654,7 @@ final class TestCall // @phpstan-ignore-line
{
foreach ($functions as $function) {
$this->testCaseFactoryAttributes[] = new Attribute(
\PHPUnit\Framework\Attributes\CoversFunction::class,
CoversFunction::class,
[$function],
);
}

View File

@ -6,7 +6,7 @@ namespace Pest;
function version(): string
{
return '4.1.6';
return '4.4.4';
}
function testDirectory(string $file = ''): string

View File

@ -56,4 +56,31 @@ trait HandleArguments
return array_values(array_flip($arguments));
}
/**
* Pops the given argument and its value from the arguments, returning the value.
*
* @param array<int, string> $arguments
*/
public function popArgumentValue(string $argument, array &$arguments): ?string
{
foreach ($arguments as $key => $value) {
if (str_contains($value, "$argument=")) {
unset($arguments[$key]);
$arguments = array_values($arguments);
return substr($value, strlen($argument) + 1);
}
if ($value === $argument && isset($arguments[$key + 1])) {
$result = $arguments[$key + 1];
unset($arguments[$key], $arguments[$key + 1]);
$arguments = array_values($arguments);
return $result;
}
}
return null;
}
}

View File

@ -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) {

View File

@ -99,6 +99,7 @@ final readonly class Help implements HandlesArguments
{
$helpReflection = new PHPUnitHelp;
// @phpstan-ignore-next-line
$content = (fn (): array => $this->elements())->call($helpReflection);
$content['Configuration'] = [...[[
@ -106,6 +107,13 @@ final readonly class Help implements HandlesArguments
'desc' => 'Initialise a standard Pest configuration',
]], ...$content['Configuration']];
$content['AI'] = [
[
'arg' => '--ai',
'desc' => 'Run a code snippet as a fully scaffolded test for AI verification',
],
];
$content['Execution'] = [...[
[
'arg' => '--parallel',
@ -141,6 +149,9 @@ final readonly class Help implements HandlesArguments
], [
'arg' => '--retry',
'desc' => 'Run non-passing tests first and stop execution upon first error or failure',
], [
'arg' => '--dirty',
'desc' => 'Only run tests that have uncommitted changes according to Git',
], ...$content['Selection']];
$content['Reporting'] = [...$content['Reporting'], ...[
@ -156,6 +167,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'] = [[

View File

@ -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);
}
@ -197,4 +199,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;
}
}

View File

@ -7,6 +7,7 @@ namespace Pest\Plugins\Parallel\Handlers;
use Closure;
use Composer\InstalledVersions;
use Illuminate\Testing\ParallelRunner;
use Orchestra\Testbench\TestCase;
use ParaTest\Options;
use ParaTest\RunnerInterface;
use Pest\Contracts\Plugins\HandlesArguments;
@ -39,13 +40,13 @@ final class Laravel implements HandlesArguments
* Executes the given closure when running Laravel.
*
* @param array<int, string> $arguments
* @param CLosure(array<int, string>): array<int, string> $closure
* @param Closure(array<int, string>): array<int, string> $closure
* @return array<int, string>
*/
private function whenUsingLaravel(array $arguments, Closure $closure): array
{
$isLaravelApplication = InstalledVersions::isInstalled('laravel/framework', false);
$isLaravelPackage = class_exists(\Orchestra\Testbench\TestCase::class);
$isLaravelPackage = class_exists(TestCase::class);
if ($isLaravelApplication && ! $isLaravelPackage) {
return $closure($arguments);

View File

@ -92,14 +92,13 @@ final class ResultPrinter
$this->teamcityLogFileHandle = $teamcityLogFileHandle;
}
/** @param list<SplFileInfo> $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 +170,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([
'',
' <fg=white;options=bold;bg=blue> 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

View File

@ -39,6 +39,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;
@ -51,6 +52,11 @@ final class WrapperRunner implements RunnerInterface
/**
* The time to sleep between cycles.
*/
/**
* The merged test result from the parallel run.
*/
public static ?TestResult $result = null;
private const int CYCLE_SLEEP = 10000;
/**
@ -131,6 +137,7 @@ final class WrapperRunner implements RunnerInterface
$parameters = $this->handleLaravelHerd($parameters);
$parameters[] = $wrapper;
$parameters[] = '--test-directory='.TestSuite::getInstance()->testPath;
$this->parameters = $parameters;
$this->codeCoverageFilterRegistry = new CodeCoverageFilterRegistry;
@ -225,7 +232,7 @@ final class WrapperRunner implements RunnerInterface
$this->printer->printFeedback(
$worker->progressFile,
$worker->unexpectedOutputFile,
$this->teamcityFiles,
$worker->teamcityFile ?? null,
);
$worker->reset();
}
@ -385,6 +392,8 @@ final class WrapperRunner implements RunnerInterface
$testResultSum->numberOfIssuesIgnoredByBaseline(),
);
self::$result = $testResultSum;
if ($this->options->configuration->cacheResult()) {
$resultCacheSum = new DefaultResultCache($this->options->configuration->testResultCacheFile());
foreach ($this->resultCacheFiles as $resultCacheFile) {
@ -483,15 +492,61 @@ final class WrapperRunner implements RunnerInterface
*/
private function getTestFiles(SuiteLoader $suiteLoader): array
{
/** @var array<string, non-empty-string> $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<string, null> $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;
}
}

View File

@ -113,6 +113,16 @@ final class TestRepository
$this->testCaseMethodFilters[] = $filter;
}
/**
* Gets the class and traits configured for the given directory path.
*
* @return array<int, string>
*/
public function getUsesForPath(string $path): array
{
return $this->uses[$path][0] ?? [];
}
/**
* Gets the test case factory from the given filename.
*/

View File

@ -19,14 +19,14 @@ final class Closure
*/
public static function bind(?BaseClosure $closure, ?object $newThis, object|string|null $newScope = 'static'): BaseClosure
{
if (! $closure instanceof \Closure) {
if (! $closure instanceof BaseClosure) {
throw ShouldNotHappen::fromMessage('Could not bind null closure.');
}
// @phpstan-ignore-next-line
$closure = BaseClosure::bind($closure, $newThis, $newScope);
if (! $closure instanceof \Closure) {
if (! $closure instanceof BaseClosure) {
throw ShouldNotHappen::fromMessage('Could not bind closure.');
}

View File

@ -28,7 +28,7 @@ final class Container
*/
public static function getInstance(): self
{
if (! self::$instance instanceof \Pest\Support\Container) {
if (! self::$instance instanceof Container) {
self::$instance = new self;
}

View File

@ -74,7 +74,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()) {
@ -109,6 +109,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, '.', '');

View File

@ -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<string>
*/
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);
}
}

View File

@ -26,6 +26,7 @@ final class ExceptionTrace
return $closure();
} catch (Throwable $throwable) {
if (Str::startsWith($message = $throwable->getMessage(), self::UNDEFINED_METHOD)) {
// @phpstan-ignore-next-line
$class = preg_match('/^Call to undefined method ([^:]+)::/', $message, $matches) === false ? null : $matches[1];
$message = str_replace(self::UNDEFINED_METHOD, 'Call to undefined method ', $message);

View File

@ -46,6 +46,7 @@ final readonly class HigherOrderCallables
*/
public function and(mixed $value): Expectation
{
// @phpstan-ignore-next-line
return $this->expect($value);
}

View File

@ -8,6 +8,7 @@ use Closure;
use InvalidArgumentException;
use Pest\Exceptions\ShouldNotHappen;
use Pest\TestSuite;
use PHPUnit\Framework\TestCase;
use ReflectionClass;
use ReflectionException;
use ReflectionFunction;
@ -66,7 +67,7 @@ final class Reflection
{
$test = TestSuite::getInstance()->test;
if (! $test instanceof \PHPUnit\Framework\TestCase) {
if (! $test instanceof TestCase) {
return self::bindCallable($callable);
}
@ -221,7 +222,7 @@ final class Reflection
{
$getProperties = fn (ReflectionClass $reflectionClass): array => array_filter(
array_map(
fn (ReflectionProperty $property): \ReflectionProperty => $property,
fn (ReflectionProperty $property): ReflectionProperty => $property,
$reflectionClass->getProperties(),
), fn (ReflectionProperty $property): bool => $property->getDeclaringClass()->getName() === $reflectionClass->getName(),
);
@ -256,7 +257,7 @@ final class Reflection
{
$getMethods = fn (ReflectionClass $reflectionClass): array => array_filter(
array_map(
fn (ReflectionMethod $method): \ReflectionMethod => $method,
fn (ReflectionMethod $method): ReflectionMethod => $method,
$reflectionClass->getMethods($filter),
), fn (ReflectionMethod $method): bool => $method->getDeclaringClass()->getName() === $reflectionClass->getName(),
);

View File

@ -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<string, list<PhpunitDeprecationTriggered|PhpunitErrorTriggered|PhpunitNoticeTriggered|PhpunitWarningTriggered>> $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()))
));
}
}
}
}

View File

@ -79,7 +79,7 @@ final class Str
return $subject;
}
return substr($subject, 0, $pos);
return mb_substr($subject, 0, $pos);
}
/**

View File

@ -1,5 +1,8 @@
<?php
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/*
|--------------------------------------------------------------------------
| Test Case
@ -7,12 +10,12 @@
|
| 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.
|
*/
pest()->extend(Tests\TestCase::class)
// ->use(Illuminate\Foundation\Testing\RefreshDatabase::class)
pest()->extend(TestCase::class)
// ->use(RefreshDatabase::class)
->in('Feature');
/*

View File

@ -1,5 +1,7 @@
<?php
use Tests\TestCase;
/*
|--------------------------------------------------------------------------
| Test Case
@ -7,11 +9,11 @@
|
| 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.
|
*/
pest()->extend(Tests\TestCase::class)->in('Feature');
pest()->extend(TestCase::class)->in('Feature');
/*
|--------------------------------------------------------------------------

View File

@ -1,5 +1,5 @@
Pest Testing Framework 4.1.6.
Pest Testing Framework 4.4.4.
USAGE: pest <file> [options]
@ -27,6 +27,8 @@
--pr .... Output to standard output tests with the given pull request number
--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
--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)
--exclude-testsuite [name] .. Exclude tests from the specified test suite(s)
@ -90,7 +92,7 @@
--random-order-seed [N] Use the specified random seed when running tests in random order
REPORTING OPTIONS:
--colors [flag] ......... Use colors in output ("never", "auto" or "always")
--colors=[flag] ......... Use colors in output ("never", "auto" or "always")
--columns [n] ................. Number of columns to use for progress output
--columns max ............ Use maximum number of columns for progress output
--stderr ................................. Write to STDERR instead of STDOUT
@ -138,12 +140,16 @@
--only-summary-for-coverage-text Option for code coverage report in text format: only show summary
--show-uncovered-for-coverage-text Option for code coverage report in text format: show uncovered files
--coverage-xml [dir] . Write code coverage report in XML format to directory
--exclude-source-from-xml-coverage Exclude [source] element from code coverage report in XML format
--warm-coverage-cache ........................... Warm static analysis cache
--coverage-filter [dir] ........... Include [dir] in code coverage reporting
--path-coverage .......... Report path coverage in addition to line coverage
--disable-coverage-ignore ...... Disable metadata for ignoring code coverage
--no-coverage Ignore code coverage reporting configured in the XML configuration file
AI OPTIONS:
--ai ..... Run a code snippet as a fully scaffolded test for AI verification
MUTATION TESTING OPTIONS:
--mutate .... Runs mutation testing, to understand the quality of your tests
--mutate --parallel ...................... Runs mutation testing in parallel

View File

@ -1,3 +1,3 @@
Pest Testing Framework 4.1.6.
Pest Testing Framework 4.4.4.

View File

@ -1,5 +1,5 @@
##teamcity[testSuiteStarted name='Tests/tests/SuccessOnly' locationHint='pest_qn://tests/.tests/SuccessOnly.php' flowId='1234']
##teamcity[testCount count='3' flowId='1234']
##teamcity[testCount count='4' flowId='1234']
##teamcity[testStarted name='it can pass with comparison' locationHint='pest_qn://tests/.tests/SuccessOnly.php::it can pass with comparison' flowId='1234']
##teamcity[testFinished name='it can pass with comparison' duration='100000' flowId='1234']
##teamcity[testStarted name='can also pass' locationHint='pest_qn://tests/.tests/SuccessOnly.php::can also pass' flowId='1234']
@ -8,8 +8,12 @@
##teamcity[testStarted name='can pass with dataset with data set "(true)"' locationHint='pest_qn://tests/.tests/SuccessOnly.php::can pass with dataset with data set "(true)"' flowId='1234']
##teamcity[testFinished name='can pass with dataset with data set "(true)"' duration='100000' flowId='1234']
##teamcity[testSuiteFinished name='can pass with dataset' flowId='1234']
##teamcity[testSuiteStarted name='`block` → can pass with dataset in describe block' locationHint='pest_qn://tests/.tests/SuccessOnly.php::`block` → can pass with dataset in describe block' flowId='1234']
##teamcity[testStarted name='`block` → can pass with dataset in describe block with data set "(1)"' locationHint='pest_qn://tests/.tests/SuccessOnly.php::`block` → can pass with dataset in describe block with data set "(1)"' flowId='1234']
##teamcity[testFinished name='`block` → can pass with dataset in describe block with data set "(1)"' duration='100000' flowId='1234']
##teamcity[testSuiteFinished name='`block` → can pass with dataset in describe block' flowId='1234']
##teamcity[testSuiteFinished name='Tests/tests/SuccessOnly' flowId='1234']
Tests: 3 passed (3 assertions)
Tests: 4 passed (4 assertions)
Duration: 1.00s

View File

@ -448,6 +448,10 @@
✓ failures with custom message
✓ not failures
PASS Tests\Features\Expect\toBeCasedCorrectly
✓ pass
✓ failure
PASS Tests\Features\Expect\toBeDigits
✓ pass
✓ failures
@ -1034,6 +1038,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
@ -1490,6 +1498,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 +1652,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 +1667,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')
@ -1756,6 +1779,10 @@
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 +1802,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 +1813,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 (2814 assertions)
Tests: 2 deprecated, 4 warnings, 5 incomplete, 2 notices, 39 todos, 35 skipped, 1211 passed (2847 assertions)

View File

@ -0,0 +1,5 @@
<?php
test('missing dataset', function (string $value) {
expect($value)->toBe('x');
})->with('missing.dataset');

View File

@ -0,0 +1,3 @@
<?php
it('passes')->assertTrue(true);

View File

@ -0,0 +1,3 @@
<?php
it('tests unicode filename with ß')->assertTrue(true);

View File

@ -13,3 +13,9 @@ test('can also pass', function () {
test('can pass with dataset', function ($value) {
expect($value)->toEqual(true);
})->with([true]);
describe('block', function () {
test('can pass with dataset in describe block', function ($number) {
expect($number)->toBeInt();
})->with([1]);
});

View File

@ -1,5 +1,7 @@
<?php
use Pest\Plugin;
trait PluginTrait
{
public function assertPluginTraitGotRegistered(): void
@ -16,8 +18,8 @@ trait SecondPluginTrait
}
}
Pest\Plugin::uses(PluginTrait::class);
Pest\Plugin::uses(SecondPluginTrait::class);
Plugin::uses(PluginTrait::class);
Plugin::uses(SecondPluginTrait::class);
function _assertThat()
{

View File

@ -1,6 +1,6 @@
<?php
$foo = new \stdClass;
$foo = new stdClass;
$foo->bar = 0;
beforeAll(function () use ($foo) {

View File

@ -17,7 +17,7 @@ it('adds coverage if --coverage exist', function () {
$arguments = $plugin->handleArguments(['--coverage']);
expect($arguments)->toEqual(['--coverage-php', Coverage::getPath()])
->and($plugin->coverage)->toBeTrue();
})->skip(! \Pest\Support\Coverage::isAvailable() || ! function_exists('xdebug_info') || ! in_array('coverage', xdebug_info('mode'), true), 'Coverage is not available');
})->skip(! Coverage::isAvailable() || ! function_exists('xdebug_info') || ! in_array('coverage', xdebug_info('mode'), true), 'Coverage is not available');
it('adds coverage if --min exist', function () {
$plugin = new CoveragePlugin(new ConsoleOutput);

View File

@ -0,0 +1,12 @@
<?php
use Pest\Arch\Exceptions\ArchExpectationFailedException;
test('pass')
->expect('Tests\Fixtures\Arch\ToBeCasedCorrectly\CorrectCasing')
->toBeCasedCorrectly();
test('failure')
->expect('Tests\Fixtures\Arch\ToBeCasedCorrectly\IncorrectCasing')
->toBeCasedCorrectly()
->throws(ArchExpectationFailedException::class);

View File

@ -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');
});

View File

@ -39,7 +39,7 @@ it('allows to call underlying protected/private methods', function () {
it('throws error if method do not exist', function () {
test()->foo();
})->throws(\ReflectionException::class, 'Call to undefined method PHPUnit\Framework\TestCase::foo()');
})->throws(ReflectionException::class, 'Call to undefined method PHPUnit\Framework\TestCase::foo()');
it('can forward unexpected calls to any global function')->_assertThat();

View File

@ -0,0 +1,5 @@
<?php
namespace Tests\Fixtures\Arch\ToBeCasedCorrectly\CorrectCasing;
class CorrectCasing {}

View File

@ -0,0 +1,5 @@
<?php
namespace Tests\Fixtures\Arch\ToBeCasedCorrectly\IncorrectCasing;
class IncorrectCasing {}

View File

@ -0,0 +1,5 @@
<?php
namespace Tests\Fixtures\Arch\ToBeCasedCorrectly\IncorrectDirectoryCasing;
class CorrectCasing {}

View File

@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace Tests\Fixtures\Arch\ToUseTrait\HasInheritedTrait;
use Tests\Fixtures\Arch\ToUseTrait\HasTrait\ParentClassWithTrait;
class ChildClassExtendingParent extends ParentClassWithTrait {}

View File

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace Tests\Fixtures\Arch\ToUseTrait\HasNestedTrait;
trait NestedTrait
{
public function nestedMethod()
{
return 'nested';
}
}

View File

@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace Tests\Fixtures\Arch\ToUseTrait\HasTrait;
class ParentClassWithTrait
{
use TestTraitForInheritance;
}

View File

@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace Tests\Fixtures\Arch\ToUseTrait\HasTrait;
use Tests\Fixtures\Arch\ToUseTrait\HasNestedTrait\NestedTrait;
trait TestTraitForInheritance
{
use NestedTrait;
public function testMethod()
{
return 'test';
}
}

View File

@ -0,0 +1,6 @@
<?php
dataset('nested.users', [
['alice'],
['bob'],
]);

View File

@ -0,0 +1,5 @@
<?php
test('loads nested dataset', function (string $name) {
expect($name)->not->toBeEmpty();
})->with('nested.users');

View File

@ -1,7 +1,9 @@
<?php
use PHPUnit\Framework\TestCase;
/**
* @return \PHPUnit\Framework\TestCase
* @return TestCase
*/
function myAssertTrue($value)
{

View File

@ -1,8 +1,9 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
class MyCustomClassTest extends PHPUnit\Framework\TestCase
class MyCustomClassTest extends TestCase
{
public function assertTrueIsTrue()
{

View File

@ -1,6 +1,8 @@
<?php
pest()->use(Tests\CustomTestCase\CustomTestCase::class)->in(__DIR__);
use Tests\CustomTestCase\CustomTestCase;
pest()->use(CustomTestCase::class)->in(__DIR__);
test('closure was bound to CustomTestCase', function () {
$this->assertCustomTrue();

View File

@ -1,5 +1,7 @@
<?php
use PHPUnit\Framework\TestCase;
trait MyCustomTrait
{
public function assertFalseIsFalse()
@ -8,7 +10,7 @@ trait MyCustomTrait
}
}
abstract class MyCustomClass extends PHPUnit\Framework\TestCase
abstract class MyCustomClass extends TestCase
{
public function assertTrueIsTrue()
{

View File

@ -1,7 +1,9 @@
<?php
use Pest\Configuration\Printer;
it('creates a printer instance', function () {
$theme = pest()->printer();
expect($theme)->toBeInstanceOf(Pest\Configuration\Printer::class);
expect($theme)->toBeInstanceOf(Printer::class);
});

View File

@ -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'],
]);

View File

@ -1,8 +1,10 @@
<?php
use Symfony\Component\Process\Process;
test('collision', function (array $arguments) {
$output = function () use ($arguments) {
$process = (new Symfony\Component\Process\Process(
$process = (new Process(
array_merge(['php', 'bin/pest', 'tests/Fixtures/CollisionTest.php'], $arguments),
null,
['COLLISION_PRINTER' => 'DefaultPrinter', 'COLLISION_IGNORE_DURATION' => 'true', 'COLLISION_TEST' => true]

View File

@ -1,8 +1,10 @@
<?php
use Symfony\Component\Process\Process;
test('visual snapshot of help command output', function () {
$output = function () {
$process = (new Symfony\Component\Process\Process(['php', 'bin/pest', '--help'], null, ['COLLISION_PRINTER' => 'DefaultPrinter', 'COLLISION_IGNORE_DURATION' => 'true']));
$process = (new Process(['php', 'bin/pest', '--help'], null, ['COLLISION_PRINTER' => 'DefaultPrinter', 'COLLISION_IGNORE_DURATION' => 'true']));
$process->run();

View File

@ -36,8 +36,8 @@ test('junit output', function () use ($normalizedPath, $run) {
expect($result['testsuite']['@attributes'])
->name->toBe('Tests\tests\SuccessOnly')
->file->toBe($normalizedPath('tests/.tests/SuccessOnly.php'))
->tests->toBe('3')
->assertions->toBe('3')
->tests->toBe('4')
->assertions->toBe('4')
->errors->toBe('0')
->failures->toBe('0')
->skipped->toBe('0');

View File

@ -16,10 +16,17 @@ $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, 1178 passed (2790 assertions)')
->toContain('Tests: 2 deprecated, 4 warnings, 5 incomplete, 3 notices, 39 todos, 26 skipped, 1196 passed (2809 assertions)')
->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();

View File

@ -0,0 +1,40 @@
<?php
use Symfony\Component\Process\Process;
$run = function (bool $parallel = false): array {
$command = [
'php',
'bin/pest',
'tests/Fixtures/ParallelNestedDatasets/TestFileWithNestedDataset.php',
'--colors=never',
];
if ($parallel) {
$command[] = '--parallel';
$command[] = '--processes=2';
}
$process = new Process($command, dirname(__DIR__, 2),
['COLLISION_PRINTER' => '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();

View File

@ -1,5 +1,7 @@
<?php
use Symfony\Component\Process\Process;
test('visual snapshot of test suite on success', function () {
$testsPath = dirname(__DIR__);
$snapshot = implode(DIRECTORY_SEPARATOR, [
@ -9,8 +11,8 @@ test('visual snapshot of test suite on success', function () {
]);
$output = function () use ($testsPath) {
$process = (new Symfony\Component\Process\Process(
['php', 'bin/pest'],
$process = (new Process(
['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'],
));

View File

@ -1,5 +1,7 @@
<?php
use Symfony\Component\Process\Process;
function normalize_windows_os_output(string $text): string
{
$text = str_replace('\r', '', $text);
@ -17,7 +19,7 @@ test('visual snapshot of team city', function (string $testFile) {
]);
$output = function () use ($testsPath) {
$process = (new Symfony\Component\Process\Process(
$process = (new Process(
['php', 'bin/pest', '--teamcity', $testsPath],
dirname(__DIR__, levels: 2),
[

View File

@ -0,0 +1,37 @@
<?php
use Symfony\Component\Process\Process;
test('filter works with unicode characters in filename', function () {
$process = new Process([
'php',
'bin/pest',
'tests/.tests/StraßenTest.php',
'--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('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();

View File

@ -1,8 +1,10 @@
<?php
use Symfony\Component\Process\Process;
test('visual snapshot of help command output', function () {
$output = function () {
$process = (new Symfony\Component\Process\Process(['php', 'bin/pest', '--version'], null, ['COLLISION_PRINTER' => 'DefaultPrinter', 'COLLISION_IGNORE_DURATION' => 'true']));
$process = (new Process(['php', 'bin/pest', '--version'], null, ['COLLISION_PRINTER' => 'DefaultPrinter', 'COLLISION_IGNORE_DURATION' => 'true']));
$process->run();