Compare commits

..

172 Commits
3.x ... v4.4.0

Author SHA1 Message Date
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
9fcbca69d4 Update README.md 2026-02-13 10:41:22 +00:00
3a4329ddc7 release: 4.3.2 2026-01-28 01:01:19 +00: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
ae419afd36 chore: support for symfony 8.0.0 components 2025-11-28 12:04:48 +00:00
27aa305897 Merge pull request #1576 from Chris53897/feature/ci
ci: bump actions/checkout 4 => 5
2025-11-25 11:18:28 +00:00
f5820bd670 release: 4.1.5 2025-11-24 12:46:38 +00:00
41fd831153 release: 4.1.4 2025-11-24 10:25:45 +00:00
51340439e8 ci: bump actions/checkout 4 => 5 2025-11-22 12:12:15 +01:00
1a39826935 ci: tests against php 8.5 2025-11-20 02:59:27 +00:00
00990efc97 Merge pull request #1544 from Se7en-RU/fix-testdox-columns-warning
Fix Undefined array key "testdox-columns" warning
2025-11-04 07:42:57 +00:00
477d20a54f release: 4.1.3 2025-10-29 22:45:27 +00:00
b7b16096db Specify closure this for extend 2025-10-29 11:20:08 +01:00
4105e33c39 Fix Undefined array key "testdox-columns" warning
Fix Undefined array key "testdox-columns" warning
2025-10-21 11:44:55 +03:00
08b09f2e98 release: 4.1.2 2025-10-05 20:09:49 +01:00
b0fab7e437 chore: uses phpunit@12.4 2025-10-05 20:09:42 +01:00
8e3444e1db chore: bumps requirements 2025-10-01 14:30:25 +01:00
fc7a4182b5 adjusts sponsors 2025-09-18 20:13:01 +01:00
b7406938ac release: v4.1.0 2025-09-10 14:41:09 +01:00
314caabd1d chore: improves types 2025-09-10 14:41:02 +01:00
65cabf91b1 chore: bumps dependencies 2025-09-10 14:40:52 +01:00
f91c6c1e1e Update social media links in Thanks.php 2025-09-01 00:09:31 +01:00
843dbbf18a Update README.md 2025-08-31 23:50:16 +01:00
47fb1d7763 release: v4.0.4 2025-08-28 19:19:42 +01:00
639df4cb43 chore: uses phpunit 12.3.7 2025-08-28 19:19:32 +01:00
e54e4a0178 release: 4.0.3 2025-08-24 15:17:23 +01:00
7749775f50 chore: uses phpunit 12.3.6 2025-08-24 15:17:19 +01:00
f11f3aa0a4 Merge pull request #1464 from gehrisandro/sandro/prevent-duplicate-attributes
Prevent duplicate attributes
2025-08-23 22:43:15 +01:00
33817013fe Prevent duplicate attributes 2025-08-23 12:35:32 +02:00
7f11ace329 release: 4.0.2 2025-08-22 11:34:24 +01:00
3d776f1f20 fix: revert reading coverage by chunks 2025-08-22 11:12:55 +01:00
d5ced0a5ca release: 4.0.1 2025-08-22 09:24:07 +01:00
af1e214be4 chore: bumps dependencies 2025-08-22 09:22:16 +01:00
7f9b50974a Merge pull request #1460 from Admiral-Enigma/4.x
Cast "testdox-columns" to an int
2025-08-22 09:20:44 +01:00
cd5272d8cc Cast "testdox-columns" to an int 2025-08-22 10:00:49 +02:00
a7b2039175 Merge branch '3.x' into 4.x 2025-08-20 20:14:15 +01:00
50960a96e9 docs: adjusts release script 2025-08-20 15:30:59 +01:00
507df757a1 release: 4.0.0 2025-08-20 15:29:23 +01:00
8722b3fc3c docs: adjusts readme for 4.x 2025-08-20 15:03:36 +01:00
19eca6e338 fix: skip windows for now 2025-08-20 14:50:15 +01:00
6b523d6963 fix: puts back windows 2025-08-20 14:27:53 +01:00
a350545803 fix: windows check 2025-08-20 14:25:49 +01:00
71c2e97c9f chore: bumps dependencies 2025-08-20 14:23:16 +01:00
98a12012bf Merge branch '3.x' into 4.x 2025-08-20 14:22:03 +01:00
92523a6f39 chore: uses phpunit v12.3.5 2025-08-20 08:01:45 +01:00
ed38fb644f release: 4.0.0 rc 1 2025-08-20 07:58:51 +01:00
39b66bf01d Merge branch '3.x' into 4.x 2025-08-20 07:04:50 +01:00
1ee36f584d Merge branch '3.x' into 4.x 2025-08-15 17:12:37 +01:00
e3e518747f release: beta 2 2025-08-11 15:45:37 +01:00
0b96b8f630 Allows old version of phpunit for now 2025-08-06 13:17:17 +01:00
711a60c2db release: beta 1 2025-08-05 17:44:12 +01:00
e7132fa012 fix: removes parse_str from security preset 2025-08-03 16:09:59 -06:00
3b72bbd7fe fix: toMatchObject accept objects 2025-08-03 11:08:11 -06:00
273edb864c chore: adjusts tests 2025-08-03 10:32:14 -06:00
fcb60f3c4a chore: improves wording 2025-08-03 09:57:52 -06:00
91bb7589e2 fix: wording on exception 2025-08-03 09:31:31 -06:00
e524bf5f73 fix: filter by dataset name 2025-07-30 15:52:40 -06:00
27414ce19f Merge pull request #1372 from soyuka/patch-1
throw exception instead of using Panic in the TestSuite.php
2025-07-30 18:27:13 +01:00
fbc9e704e2 Merge pull request #1424 from joelbutcher/fix/throw-if-tests-directory-does-not-exist
[3.x] Throw a fatal exception if the tests directory does not exist
2025-07-30 18:26:31 +01:00
ee6b3ed062 fix: throw a fatal exception if the tests directory does not exist 2025-07-29 19:13:33 +01:00
4c88590b89 feat: not.toHaveSuspiciousCharacters 2025-07-26 07:47:00 -06:00
66e59efec6 Merge branch '3.x' into 4.x 2025-07-26 07:35:23 -06:00
da04ba62a8 Merge pull request #1287 from mortenscheel/patch-1
Add toNotIncludeSuspiciousCharacters() expectation
2025-07-26 04:30:22 +01:00
d187566e63 chore: updates snapshots 2025-07-25 21:29:26 -06:00
3e86e158b2 Merge pull request #1304 from jshayes/fix/sibling-describe-blocks
Fix an issue with describe blocks with matching names
2025-07-26 04:26:19 +01:00
d6c6489e93 Merge branch '4.x' into fix/sibling-describe-blocks 2025-07-26 04:26:10 +01:00
ee70a3cfea Merge pull request #1319 from pestphp/allow-custom-arch-expectations
Allow custom arch expectations
2025-07-26 04:23:49 +01:00
7a6f33f139 Merge branch '3.x' into 4.x 2025-07-25 21:20:15 -06:00
e29302300f Merge branch '3.x' into 4.x 2025-07-25 21:17:48 -06:00
222ed174bc style 2025-07-25 21:10:05 -06:00
2aa32569f0 fix: adjust snapshots 2025-07-25 21:06:49 -06:00
1d8d1a046f Merge pull request #1357 from drsdre/patch-1
toMatchArray/Object wrong field fix
2025-07-26 04:06:12 +01:00
3d9ceb1cf2 Merge pull request #1373 from clementbirkle/3.x
fix: normalize snapshot paths for tests outside the main tests directory
2025-07-26 03:58:40 +01:00
520a5fe29d Merge branch '4.x' into 3.x 2025-07-26 03:56:09 +01:00
de4409e368 fix: before all 2025-07-25 20:54:37 -06:00
6d6e4e040f fix: wrong status code being used 2025-07-25 18:03:58 -06:00
aac08629f7 ci: removes testing against lowest 2025-07-23 08:26:12 +01:00
fe27012bbc style 2025-07-22 23:58:36 +01:00
f9901245f1 updates dependencies and snapshots 2025-07-22 23:51:32 +01:00
21e22decf3 Merge pull request #1299 from FaSe22/handle-c-flag
fix: Pest ignores '-c' option
2025-07-22 23:12:07 +01:00
e513f76ea9 Merge pull request #1256 from mazesec/add-slugify-method
Add slugify Method to Str Class and toBeSlug Assertion to Expectation Class
2025-07-22 23:08:15 +01:00
be9c95e3bc feat: adds see 2025-07-22 23:06:43 +01:00
9172721ce8 Merge pull request #1241 from mertasan/ide-reference
feat: Add `references` method for two-way test-source linking
2025-07-22 22:48:26 +01:00
924dc016cc feat: skipLocally 2025-07-22 22:40:38 +01:00
f49b91ec0d fixes missing condition 2025-07-22 22:31:08 +01:00
516ace85b4 fix: skipOnCI 2025-07-22 22:08:03 +01:00
f9814793dd feat: skipOnCI 2025-07-22 21:52:06 +01:00
00572f5f8e feat: improves playwright 2025-07-22 11:39:34 +01:00
fb282b184e fix: return type 2025-07-21 13:32:03 +01:00
e0695a13cb feat: adds shell 2025-07-21 13:25:50 +01:00
8f810bf2a2 Merge pull request #1408 from JonPurvis/remove-language-option
[4.x] Remove Language Option from Profanity Composer Script
2025-07-14 10:37:14 +01:00
84636cee96 Merge branch '4.x' into remove-language-option 2025-07-14 10:37:08 +01:00
0355119afc fix: snapshots feedback 2025-07-06 14:29:18 +01:00
9d0410ee0b feat: adjusts only for browser debug 2025-07-06 13:45:33 +01:00
0d148c2a67 chroe: bumps phpunit 2025-07-05 15:46:03 +01:00
0f1e87c726 Adds output about sharding 2025-07-05 15:43:43 +01:00
73bf579da3 chore: code refactor 2025-07-02 00:26:15 +01:00
5def62018b fix: shard regex 2025-07-01 11:00:05 +01:00
d8e1b27491 ci: fix included version 2025-06-30 22:50:57 +01:00
2ff4713968 ci: fix missing dep 2025-06-30 22:26:09 +01:00
3f27352560 feat: adds --shard 2025-06-30 22:15:07 +01:00
af3fdceddb feat: adds phpunit 12.2.5 support 2025-06-28 18:31:45 +01:00
3faeede1ef chore: fixes snapshots 2025-06-28 18:24:19 +01:00
0bc3219a2b feat: moves visit to the core 2025-06-28 18:18:26 +01:00
a22013a7d3 fix: with types 2025-06-28 12:14:45 +01:00
7fc69033f8 chore: adjusts style 2025-06-27 02:15:36 +01:00
ef76c04dbe feat: adds fixture 2025-06-27 02:15:28 +01:00
163479ae60 chore: style 2025-06-16 10:14:16 +01:00
c3bfdf130e chore: type checking 2025-06-16 10:14:04 +01:00
8c403a57c2 fix: upper case fix 2025-06-16 10:04:00 +01:00
49bf00024f fix: coverage when coverage file is over 2.4gb on mac os 2025-06-15 22:43:59 +01:00
dd44ac4195 remove language option from profanity composer script 2025-06-14 21:36:39 +01:00
5d2aafd2a3 feat: --profanity 2025-06-12 00:38:05 +01:00
0fc9d4dfe0 feat: adds phpunit 12.2.1 support 2025-06-08 15:29:23 +01:00
02b1ffb334 chore: bump dependencies 2025-05-27 11:37:29 +01:00
c62cc3fef0 chore: adds pokio 2025-05-23 05:19:56 +01:00
909d778da3 fix: undefined property 2025-05-21 02:00:15 +01:00
7711a52fe9 Bumps dependencies 2025-05-09 13:10:29 +01:00
99c9f4e5d8 Bumps dependencies 2025-05-03 11:58:03 +01:00
a310796165 Fixes filtering tests 2025-04-29 11:38:33 +01:00
db9243ca2e bump dependencies 2025-04-29 09:57:02 +01:00
635e3b4c41 chore: deprecates php 8.2 2025-04-20 23:02:19 +01:00
791734a29c Fixes tests 2025-04-20 22:19:25 +01:00
8cfb0acf46 bump paratest 2025-04-20 21:51:01 +01:00
bf67407ba5 Merge pull request #1391 from MrPunyapal/feat/phpunit-12
[WIP] Feat: PHPUnit 12
2025-04-20 21:35:32 +01:00
efdc84e115 Merge branch '4.x' into feat/phpunit-12 2025-04-20 21:35:25 +01:00
d1608bf33d chore: prepares for 4.x 2025-04-20 21:33:50 +01:00
4f6140fdb1 refactor: move test case initialization to a separate method in Testable trait 2025-04-20 15:37:02 +05:30
442a58d07f refactor: comment arch presets in Arch.php 2025-04-20 15:19:56 +05:30
19e9267021 fix: update PHPUnit version 2025-04-20 15:19:40 +05:30
e46d499384 add tests for snapshots external to the tests directory 2025-03-19 16:33:44 +01:00
490f321a0d fix: normalize snapshot paths for files outside tests directory 2025-03-19 15:59:45 +01:00
174645caa2 throw exception instead of using Panic 2025-03-18 14:49:32 +01:00
AFS
2c3a53f6cd Remove reset of message
Reset approach was not the right one.
2025-02-25 15:44:32 +01:00
AFS
0bdaef29e9 Fix lingering }
Remove remaining } in toMatchObject.
2025-02-25 15:41:17 +01:00
AFS
1ad30a97b3 toMatchArray/Object wrong field fix
The functions toMatchArray and toMatchObject indicate that the wrong field is mismatching from the second loop on because the 'message' is overwritten and taken into the following loop. This patch creates a $second_message for the second test (value test) to keep the error message correct.
2025-02-25 15:34:37 +01:00
50ff347b59 Pass description into describe call 2025-01-20 17:40:18 -05:00
b5b8fab09b Fix an issue where beforeEach/afterEach functions were called on other describe blocks with the same name 2025-01-20 17:36:22 -05:00
5331b44a18 Allow custom arch expectations 2024-11-20 11:54:36 +01:00
53c94600cb fix: handle -c flag same as --configuration 2024-10-16 22:19:08 +02:00
dd7d150caa Add toNotIncludeSuspiciousCharacters() expectation 2024-10-04 13:44:10 +02:00
92bc1decd9 Add tests for toBeSlug method 2024-09-16 13:41:13 +01:00
e3bfcbe5f1 Add slugify method 2024-09-16 13:36:34 +01:00
ba7eb70a5d Remove unnecessary property 2024-09-12 13:11:12 +03:00
74ff3b8cd9 Update src/PendingCalls/TestCall.php
Co-authored-by: Owen Voke <development@voke.dev>
2024-09-12 13:09:57 +03:00
ab0b4a1b4e Update src/PendingCalls/TestCall.php
Co-authored-by: Owen Voke <development@voke.dev>
2024-09-12 13:09:45 +03:00
169b76458e make the name of the method plural 2024-09-12 11:15:30 +03:00
668685498f Fix phpdoc type-hints 2024-09-12 03:51:20 +03:00
bab193e7e1 Fix property type 2024-09-12 03:24:36 +03:00
f720be862e Add reference method 2024-09-12 02:45:37 +03:00
141 changed files with 1638 additions and 390 deletions

67
.github/workflows/static.yml vendored Normal file
View File

@ -0,0 +1,67 @@
name: Static Analysis
on:
push:
branches: [4.x]
pull_request:
schedule:
- cron: '0 0 * * *'
concurrency:
group: static-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
static:
if: github.event_name != 'schedule' || github.repository == 'pestphp/pest'
name: Static Tests
runs-on: ubuntu-latest
strategy:
fail-fast: true
matrix:
dependency-version: [prefer-lowest, prefer-stable]
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: 8.3
tools: composer:v2
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@v4
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 --${{ matrix.dependency-version }} --no-interaction --no-progress --ansi
- name: Profanity Check
run: composer test:profanity
- name: Type Check
run: composer test:type:check
- name: Type Coverage
run: composer test:type:coverage
- name: Refacto
run: composer test:refacto
- name: Style
run: composer test:lint

View File

@ -2,8 +2,13 @@ name: Tests
on:
push:
branches: [4.x]
pull_request:
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'
@ -12,16 +17,19 @@ jobs:
strategy:
fail-fast: true
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
symfony: ['7.1']
php: ['8.2', '8.3', '8.4']
os: [ubuntu-latest, macos-latest] # windows-latest
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@v4
uses: actions/checkout@v6
- name: Setup PHP
uses: shivammathur/setup-php@v2
@ -29,6 +37,21 @@ jobs:
php-version: ${{ matrix.php }}
tools: composer:v2
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@v4
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: |

View File

@ -1,23 +1,24 @@
<p align="center">
<img src="https://raw.githubusercontent.com/pestphp/art/master/v3/banner.png" width="600" alt="PEST">
<img src="https://raw.githubusercontent.com/pestphp/art/master/v4/social.png" width="600" alt="PEST">
<p align="center">
<a href="https://github.com/pestphp/pest/actions"><img alt="GitHub Workflow Status (master)" src="https://img.shields.io/github/actions/workflow/status/pestphp/pest/tests.yml?branch=3.x&label=Tests%203.x"></a>
<a href="https://github.com/pestphp/pest/actions"><img alt="GitHub Workflow Status (master)" src="https://img.shields.io/github/actions/workflow/status/pestphp/pest/tests.yml?branch=4.x&label=Tests%204.x"></a>
<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>
------
> Pest v3 Now Available: **[Read the announcement »](https://pestphp.com/docs/pest3-now-available)**.
> Pest v4 Now Available: **[Read the announcement »](https://pestphp.com/docs/pest-v4-is-here-now-with-browser-testing)**.
**Pest** is an elegant PHP testing Framework with a focus on simplicity, meticulously designed to bring back the joy of testing in PHP.
- Explore our docs at **[pestphp.com »](https://pestphp.com)**
- Follow the creator Nuno Maduro:
- YouTube: **[youtube.com/@nunomaduro](https://www.youtube.com/@nunomaduro)** — Videos every weekday
- Twitch: **[twitch.tv/enunomaduro](https://www.twitch.tv/enunomaduro)** — Streams (almost) every weekday
- YouTube: **[youtube.com/@nunomaduro](https://youtube.com/@nunomaduro)** — Videos every week
- Twitch: **[twitch.tv/nunomaduro](https://twitch.tv/nunomaduro)** — Live coding on Mondays, Wednesdays, and Fridays at 9PM UTC
- Twitter / X: **[x.com/enunomaduro](https://x.com/enunomaduro)**
- LinkedIn: **[linkedin.com/in/nunomaduro](https://www.linkedin.com/in/nunomaduro)**
- Instagram: **[instagram.com/enunomaduro](https://www.instagram.com/enunomaduro)**
@ -30,23 +31,23 @@ We cannot thank our sponsors enough for their incredible support in funding Pest
### Platinum Sponsors
- **[Laracasts](https://laracasts.com/?ref=pestphp)**
- **[CodeRabbit](https://coderabbit.ai/?ref=pestphp)**
- **[Devin](https://devin.ai/?ref=nunomaduro)**
- **[Mailtrap](https://l.rw.rw/pestphp)**
- **[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)**
- **[NativePHP](https://nativephp.com/mobile?ref=pestphp.com)**
- **[CMS Max](https://cmsmax.com/?ref=pestphp)**
### Premium Sponsors
- [Zapiet](https://zapiet.com/?ref=pestphp)
- [Load Forge](https://loadforge.com/?ref=pestphp)
- [Route4Me](https://route4me.com/pt?ref=pestphp)
- [Nerdify](https://getnerdify.com/?ref=pestphp)
- [Akaunting](https://akaunting.com/?ref=pestphp)
- [DocuWriter.ai](https://www.docuwriter.ai/?ref=pestphp)
- [Localazy](https://localazy.com/?ref=pestphp)
- [Forge](https://forge.laravel.com/?ref=pestphp)
- [Route4Me](https://www.route4me.com/?ref=pestphp)
- [Spatie](https://spatie.be/?ref=pestphp)
- [Worksome](https://www.worksome.com/?ref=pestphp)
- [Zapiet](https://www.zapiet.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

@ -2,10 +2,10 @@
When releasing a new version of Pest there are some checks and updates that need to be done:
> **For Pest v2 you should use the `2.x` branch instead.**
> **For Pest v3 you should use the `3.x` branch instead.**
- Clear your local repository with: `git add . && git reset --hard && git checkout 3.x`
- On the GitHub repository, check the contents of [github.com/pestphp/pest/compare/{latest_version}...3.x](https://github.com/pestphp/pest/compare/{latest_version}...3.x)
- Clear your local repository with: `git add . && git reset --hard && git checkout 4.x`
- On the GitHub repository, check the contents of [github.com/pestphp/pest/compare/{latest_version}...4.x](https://github.com/pestphp/pest/compare/{latest_version}...4.x)
- Update the version number in [src/Pest.php](src/Pest.php)
- Run the tests locally using: `composer test`
- Commit the Pest file with the message: `git commit -m "release: vX.X.X"`

View File

@ -86,7 +86,7 @@ $bootPest = (static function (): void {
$getopt['teamcity-file'] ?? null,
$getopt['testdox-file'] ?? null,
isset($getopt['testdox-color']),
$getopt['testdox-columns'] ?? null,
(int) ($getopt['testdox-columns'] ?? null),
);
while (true) {

View File

@ -17,19 +17,21 @@
}
],
"require": {
"php": "^8.2.0",
"brianium/paratest": "^7.8.5",
"nunomaduro/collision": "^8.8.3",
"nunomaduro/termwind": "^2.3.3",
"pestphp/pest-plugin": "^3.0.0",
"pestphp/pest-plugin-arch": "^3.1.1",
"pestphp/pest-plugin-mutate": "^3.0.5",
"phpunit/phpunit": "^11.5.50"
"php": "^8.3.0",
"brianium/paratest": "^7.19.0",
"nunomaduro/collision": "^8.9.0",
"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.1",
"phpunit/phpunit": "^12.5.12",
"symfony/process": "^7.4.5|^8.0.5"
},
"conflict": {
"filp/whoops": "<2.16.0",
"phpunit/phpunit": ">11.5.50",
"sebastian/exporter": "<6.0.0",
"filp/whoops": "<2.18.3",
"phpunit/phpunit": ">12.5.12",
"sebastian/exporter": "<7.0.0",
"webmozart/assert": "<1.11.0"
},
"autoload": {
@ -53,9 +55,10 @@
]
},
"require-dev": {
"pestphp/pest-dev-tools": "^3.4.0",
"pestphp/pest-plugin-type-coverage": "^3.6.1",
"symfony/process": "^7.4.4"
"pestphp/pest-dev-tools": "^4.1.0",
"pestphp/pest-plugin-browser": "^4.3.0",
"pestphp/pest-plugin-type-coverage": "^4.0.3",
"psy/psysh": "^0.12.20"
},
"minimum-stability": "dev",
"prefer-stable": true,
@ -71,12 +74,22 @@
],
"scripts": {
"refacto": "rector",
"test:unit": "php bin/pest --colors=always --exclude-group=integration --compact",
"test:inline": "php bin/pest --colors=always --configuration=phpunit.inline.xml",
"test:parallel": "php bin/pest --colors=always --exclude-group=integration --parallel --processes=3",
"test:integration": "php bin/pest --colors=always --group=integration -v",
"update:snapshots": "REBUILD_SNAPSHOTS=true php bin/pest --colors=always --update-snapshots",
"lint": "pint --parallel",
"test:refacto": "rector --dry-run",
"test:lint": "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",
"test:unit": "php bin/pest --exclude-group=integration --compact",
"test:inline": "php bin/pest --configuration=phpunit.inline.xml",
"test:parallel": "php bin/pest --exclude-group=integration --parallel --processes=3",
"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",
"@test:unit",
"@test:parallel",
"@test:integration"
@ -102,6 +115,7 @@
"Pest\\Plugins\\Snapshot",
"Pest\\Plugins\\Verbose",
"Pest\\Plugins\\Version",
"Pest\\Plugins\\Shard",
"Pest\\Plugins\\Parallel"
]
},

View File

@ -52,6 +52,8 @@ use PHPUnit\Util\Filter;
use PHPUnit\Util\ThrowableToStringMapper;
/**
* @no-named-arguments Parameter names are not covered by the backward compatibility promise for PHPUnit
*
* @internal This class is not covered by the backward compatibility promise for PHPUnit
*/
final readonly class ThrowableBuilder
@ -82,7 +84,7 @@ final readonly class ThrowableBuilder
$t->getMessage(),
ThrowableToStringMapper::map($t),
$trace,
$previous
$previous,
);
}
}

View File

@ -99,7 +99,9 @@ abstract class NameFilterIterator extends RecursiveFilterIterator
}
if ($test instanceof HasPrintableTestCaseName) {
$name = $test::getPrintableTestCaseName().'::'.$test->getPrintableTestCaseMethodName();
$name = trim(
$test::getPrintableTestCaseName().'::'.$test->getPrintableTestCaseMethodName().$test->dataSetAsString()
);
} else {
$name = $test::class.'::'.$test->nameWithDataSet();
}

View File

@ -72,10 +72,7 @@ use function Pest\version;
*/
final class DefaultResultCache implements ResultCache
{
/**
* @var string
*/
private const DEFAULT_RESULT_CACHE_FILENAME = '.phpunit.result.cache';
private const string DEFAULT_RESULT_CACHE_FILENAME = '.phpunit.result.cache';
private readonly string $cacheFilename;

View File

@ -45,6 +45,7 @@ declare(strict_types=1);
namespace PHPUnit\TextUI;
use Pest\Plugins\Only;
use Pest\Runner\Filter\EnsureTestCaseIsInitiatedFilter;
use PHPUnit\Event;
use PHPUnit\Framework\TestSuite;
use PHPUnit\Runner\Filter\Factory;
@ -66,6 +67,12 @@ final readonly class TestSuiteFilterProcessor
{
$factory = new Factory;
// @phpstan-ignore-next-line
(fn () => $this->filters[] = [
'className' => EnsureTestCaseIsInitiatedFilter::class,
'argument' => '',
])->call($factory);
if (! $configuration->hasFilter() &&
! $configuration->hasGroups() &&
! $configuration->hasExcludeGroups() &&
@ -73,6 +80,8 @@ final readonly class TestSuiteFilterProcessor
! $configuration->hasTestsCovering() &&
! $configuration->hasTestsUsing() &&
! Only::isEnabled()) {
$suite->injectFilter($factory);
return;
}

View File

@ -16,6 +16,7 @@
<testsuites>
<testsuite name="default">
<directory suffix=".php">./tests</directory>
<directory suffix=".php">./tests-external</directory>
<exclude>./tests/.snapshots</exclude>
<exclude>./tests/.tests</exclude>
<exclude>./tests/Fixtures/Inheritance</exclude>

View File

@ -2,7 +2,10 @@
declare(strict_types=1);
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;
return RectorConfig::configure()
@ -12,6 +15,9 @@ return RectorConfig::configure()
->withSkip([
__DIR__.'/src/Plugins/Parallel/Paratest/WrapperRunner.php',
ReturnNeverTypeRector::class,
ArrowFunctionDelegatingCallToFirstClassCallableRector::class,
NarrowObjectReturnTypeRector::class,
RemoveParentDelegatingConstructorRector::class,
])
->withPreparedSets(
deadCode: true,

View File

@ -0,0 +1,22 @@
<div class="mx-2 mb-1">
<p>
<span>Using the <span class="text-yellow font-bold">visit()</span> function requires the Pest Plugin Browser to be installed.</span>
<span class="ml-1 text-yellow font-bold">Run:</span>
</p>
<div>
<span class="text-gray mr-1">- </span>
<span>composer require pestphp/pest-plugin-browser:^4.0 --dev</span>
</div>
<div>
<span class="text-gray mr-1">- </span>
<span>npm install playwright@latest</span>
</div>
<div>
<span class="text-gray mr-1">- </span>
<span>npx playwright install</span>
</div>
</div>

View File

@ -32,7 +32,6 @@ final class Security extends AbstractPreset
'create_function',
'unserialize',
'extract',
'parse_str',
'mb_parse_str',
'dl',
'assert',

View File

@ -17,7 +17,7 @@ final class BootExcludeList implements Bootstrapper
*
* @var array<int, non-empty-string>
*/
private const EXCLUDE_LIST = [
private const array EXCLUDE_LIST = [
'bin',
'overrides',
'resources',

View File

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Pest\Bootstrappers;
use Pest\Contracts\Bootstrapper;
use Pest\Exceptions\FatalException;
use Pest\Support\DatasetInfo;
use Pest\Support\Str;
use Pest\TestSuite;
@ -24,7 +25,7 @@ final class BootFiles implements Bootstrapper
*
* @var array<int, string>
*/
private const STRUCTURE = [
private const array STRUCTURE = [
'Expectations',
'Expectations.php',
'Helpers',
@ -40,6 +41,10 @@ final class BootFiles implements Bootstrapper
$rootPath = TestSuite::getInstance()->rootPath;
$testsPath = $rootPath.DIRECTORY_SEPARATOR.testDirectory();
if (! is_dir($testsPath)) {
throw new FatalException(sprintf('The test directory [%s] does not exist.', $testsPath));
}
foreach (self::STRUCTURE as $filename) {
$filename = sprintf('%s%s%s', $testsPath, DIRECTORY_SEPARATOR, $filename);
@ -78,7 +83,7 @@ final class BootFiles implements Bootstrapper
private function bootDatasets(string $testsPath): void
{
assert(strlen($testsPath) > 0);
assert($testsPath !== '');
$files = (new PhpUnitFileIterator)->getFilesAsArray($testsPath, '.php');

View File

@ -15,17 +15,17 @@ final class BootOverrides implements Bootstrapper
/**
* The list of files to be overridden.
*
* @var array<string, string>
* @var array<int, string>
*/
public const FILES = [
'53c246e5f416a39817ac81124cdd64ea8403038d01d7a202e1ffa486fbdf3fa7' => 'Runner/Filter/NameFilterIterator.php',
'77ffb7647b583bd82e37962c6fbdc4b04d3344d8a2c1ed103e625ed1ff7cb5c2' => 'Runner/ResultCache/DefaultResultCache.php',
'd0e81317889ad88c707db4b08a94cadee4c9010d05ff0a759f04e71af5efed89' => 'Runner/TestSuiteLoader.php',
'3bb609b0d3bf6dee8df8d6cd62a3c8ece823c4bb941eaaae39e3cb267171b9d2' => 'TextUI/Command/Commands/WarmCodeCoverageCacheCommand.php',
'8abdad6413329c6fe0d7d44a8b9926e390af32c0b3123f3720bb9c5bbc6fbb7e' => 'TextUI/Output/Default/ProgressPrinter/Subscriber/TestSkippedSubscriber.php',
'b4250fc3ffad5954624cb5e682fd940b874e8d3422fa1ee298bd7225e1aa5fc2' => 'TextUI/TestSuiteFilterProcessor.php',
'8cfcb4999af79463eca51a42058e502ea4ddc776cba5677bf2f8eb6093e21a5c' => 'Event/Value/ThrowableBuilder.php',
'86cd9bcaa53cdd59c5b13e58f30064a015c549501e7629d93b96893d4dee1eb1' => 'Logging/JUnit/JunitXmlLogger.php',
public const array FILES = [
'Runner/Filter/NameFilterIterator.php',
'Runner/ResultCache/DefaultResultCache.php',
'Runner/TestSuiteLoader.php',
'TextUI/Command/Commands/WarmCodeCoverageCacheCommand.php',
'TextUI/Output/Default/ProgressPrinter/Subscriber/TestSkippedSubscriber.php',
'TextUI/TestSuiteFilterProcessor.php',
'Event/Value/ThrowableBuilder.php',
'Logging/JUnit/JunitXmlLogger.php',
];
/**

View File

@ -20,7 +20,7 @@ final readonly class BootSubscribers implements Bootstrapper
*
* @var array<int, class-string<Subscriber>>
*/
private const SUBSCRIBERS = [
private const array SUBSCRIBERS = [
Subscribers\EnsureConfigurationIsAvailable::class,
Subscribers\EnsureIgnorableTestCasesAreIgnored::class,
Subscribers\EnsureKernelDumpIsFlushed::class,

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

@ -6,10 +6,12 @@ namespace Pest\Concerns;
use Closure;
use Pest\Exceptions\DatasetArgumentsMismatch;
use Pest\Panic;
use Pest\Preset;
use Pest\Support\ChainableClosure;
use Pest\Support\ExceptionTrace;
use Pest\Support\Reflection;
use Pest\Support\Shell;
use Pest\TestSuite;
use PHPUnit\Framework\Attributes\PostCondition;
use PHPUnit\Framework\TestCase;
@ -101,27 +103,6 @@ trait Testable
*/
private array $__snapshotChanges = [];
/**
* Creates a new Test Case instance.
*/
public function __construct(string $name)
{
parent::__construct($name);
$test = TestSuite::getInstance()->tests->get(self::$__filename);
if ($test->hasMethod($name)) {
$method = $test->getMethod($name);
$this->__description = self::$__latestDescription = $method->description;
self::$__latestAssignees = $method->assignees;
self::$__latestNotes = $method->notes;
self::$__latestIssues = $method->issues;
self::$__latestPrs = $method->prs;
$this->__describing = $method->describing;
$this->__test = $method->getClosure();
}
}
/**
* Resets the test case static properties.
*/
@ -214,7 +195,11 @@ trait Testable
$beforeAll = ChainableClosure::boundStatically(self::$__beforeAll, $beforeAll);
}
call_user_func(Closure::bind($beforeAll, null, self::class));
try {
call_user_func(Closure::bind($beforeAll, null, self::class));
} catch (Throwable $e) {
Panic::with($e);
}
}
/**
@ -242,8 +227,6 @@ trait Testable
$method = TestSuite::getInstance()->tests->get(self::$__filename)->getMethod($this->name());
$method->setUp($this);
$description = $method->description;
if ($this->dataName()) {
$description = str_contains((string) $description, ':dataset')
@ -285,6 +268,33 @@ trait Testable
$this->__callClosure($beforeEach, $arguments);
}
/**
* Initialize test case properties from TestSuite.
*/
public function __initializeTestCase(): void
{
// Return if the test case has already been initialized
if (isset($this->__test)) {
return;
}
$name = $this->name();
$test = TestSuite::getInstance()->tests->get(self::$__filename);
if ($test->hasMethod($name)) {
$method = $test->getMethod($name);
$this->__description = self::$__latestDescription = $method->description;
self::$__latestAssignees = $method->assignees;
self::$__latestNotes = $method->notes;
self::$__latestIssues = $method->issues;
self::$__latestPrs = $method->prs;
$this->__describing = $method->describing;
$this->__test = $method->getClosure();
$method->setUp($this);
}
}
/**
* Gets executed after the Test Case.
*/
@ -434,15 +444,7 @@ trait Testable
return;
}
if (count($this->__snapshotChanges) === 1) {
$this->markTestIncomplete($this->__snapshotChanges[0]);
return;
}
$messages = implode(PHP_EOL, array_map(static fn (string $message): string => '- $message', $this->__snapshotChanges));
$this->markTestIncomplete($messages);
$this->markTestIncomplete(implode('. ', $this->__snapshotChanges));
}
/**
@ -466,7 +468,7 @@ trait Testable
*/
public static function getLatestPrintableTestCaseMethodName(): string
{
return self::$__latestDescription;
return self::$__latestDescription ?? '';
}
/**
@ -481,4 +483,12 @@ trait Testable
'notes' => self::$__latestNotes,
];
}
/**
* Opens a shell for the test case.
*/
public function shell(): void
{
Shell::open();
}
}

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.
*/
@ -102,6 +111,14 @@ final readonly class Configuration
return Configuration\Project::getInstance();
}
/**
* Gets the browser configuration.
*/
public function browser(): Browser\Configuration
{
return new Browser\Configuration;
}
/**
* Proxies calls to the uses method.
*

View File

@ -16,7 +16,7 @@ final readonly class Help
*
* @var array<int, string>
*/
private const HELP_MESSAGES = [
private const array HELP_MESSAGES = [
'<comment>Pest Options:</comment>',
' <info>--init</info> Initialise a standard Pest configuration',
' <info>--coverage</info> Enable coverage and output to standard output',

View File

@ -22,11 +22,11 @@ final readonly class Thanks
*
* @var array<string, string>
*/
private const FUNDING_MESSAGES = [
private const array FUNDING_MESSAGES = [
'Star' => 'https://github.com/pestphp/pest',
'YouTube' => 'https://youtube.com/@nunomaduro',
'TikTok' => 'https://tiktok.com/@nunomaduro',
'Twitch' => 'https://twitch.tv/enunomaduro',
'TikTok' => 'https://tiktok.com/@enunomaduro',
'Twitch' => 'https://twitch.tv/nunomaduro',
'LinkedIn' => 'https://linkedin.com/in/nunomaduro',
'Instagram' => 'https://instagram.com/enunomaduro',
'X' => 'https://x.com/enunomaduro',

View File

@ -22,7 +22,7 @@ final class DatasetMissing extends BadFunctionCallException implements Exception
public function __construct(string $file, string $name, array $arguments)
{
parent::__construct(sprintf(
"A test with the description '%s' has %d argument(s) ([%s]) and no dataset(s) provided in %s",
'A test with the description [%s] has [%d] argument(s) ([%s]) and no dataset(s) provided in [%s]',
$name,
count($arguments),
implode(', ', array_map(static fn (string $arg, string $type): string => sprintf('%s $%s', $type, $arg), array_keys($arguments), $arguments)),

View File

@ -19,7 +19,11 @@ final class TestCaseAlreadyInUse extends InvalidArgumentException implements Exc
*/
public function __construct(string $inUse, string $newOne, string $folder)
{
parent::__construct(sprintf('Test case `%s` can not be used. The folder `%s` already uses the test case `%s`',
$newOne, $folder, $inUse));
parent::__construct(sprintf(
'Test case [%s] can not be used. The folder [%s] already uses the test case [%s].',
$newOne,
$folder,
$inUse,
));
}
}

View File

@ -22,7 +22,7 @@ final class TestClosureMustNotBeStatic extends InvalidArgumentException implemen
{
parent::__construct(
sprintf(
'Test closure must not be static. Please remove the `static` keyword from the `%s` method in `%s`.',
'Test closure must not be static. Please remove the [static] keyword from the [%s] method in [%s].',
$method->description,
$method->filename
)

View File

@ -52,7 +52,9 @@ use ReflectionProperty;
*/
final class Expectation
{
/** @use Extendable<self<TValue>> */
use Extendable;
use Pipeable;
use Retrievable;
@ -330,7 +332,7 @@ final class Expectation
* @param array<int, mixed> $parameters
* @return Expectation<TValue>|HigherOrderExpectation<Expectation<TValue>, TValue>
*/
public function __call(string $method, array $parameters): Expectation|HigherOrderExpectation|PendingArchExpectation
public function __call(string $method, array $parameters): Expectation|HigherOrderExpectation|PendingArchExpectation|ArchExpectation
{
if (! self::hasMethod($method)) {
if (! is_object($this->value) && method_exists(PendingArchExpectation::class, $method)) {
@ -355,6 +357,10 @@ final class Expectation
$reflectionClosure = new \ReflectionFunction($closure);
$expectation = $reflectionClosure->getClosureThis();
if ($reflectionClosure->getReturnType()?->__toString() === ArchExpectation::class) {
return $closure(...$parameters);
}
assert(is_object($expectation));
ExpectationPipeline::for($closure)
@ -393,7 +399,7 @@ final class Expectation
*
* @return Expectation<TValue>|OppositeExpectation<TValue>|EachExpectation<TValue>|HigherOrderExpectation<Expectation<TValue>, TValue|null>|TValue
*/
public function __get(string $name)
public function __get(string $name): mixed
{
if (! self::hasMethod($name)) {
if (! is_object($this->value) && method_exists(PendingArchExpectation::class, $name)) {
@ -890,6 +896,14 @@ final class Expectation
return ToUseNothing::make($this);
}
/**
* Asserts that the source code of the given expectation target does not include suspicious characters.
*/
public function toHaveSuspiciousCharacters(): ArchExpectation
{
throw InvalidExpectation::fromMethods(['toHaveSuspiciousCharacters']);
}
/**
* Not supported.
*/

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;
@ -24,6 +25,7 @@ use PHPUnit\Framework\AssertionFailedError;
use PHPUnit\Framework\ExpectationFailedException;
use ReflectionMethod;
use ReflectionProperty;
use Spoofchecker;
use stdClass;
/**
@ -278,6 +280,28 @@ final readonly class OppositeExpectation
);
}
/**
* Asserts that the given expectation target does not have suspicious characters.
*/
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 */
$original = $this->original;
return Targeted::make(
$original,
fn (ObjectDescription $object): bool => ! $checker->isSuspicious((string) file_get_contents($object->path)),
'to not include suspicious characters',
FileLineFinder::where(fn (string $line): bool => $checker->isSuspicious($line)),
);
}
/**
* Asserts that the given expectation target does not have the given methods.
*

View File

@ -1,10 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Factories\Covers;
/**
* @internal
*/
final class CoversNothing {}

View File

@ -32,7 +32,7 @@ final class TestCaseMethodFactory
/**
* The test's describing, if any.
*
* @var array<int, string>
* @var array<int, \Pest\Support\Description>
*/
public array $describing = [];

View File

@ -2,11 +2,14 @@
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;
use Pest\Expectation;
use Pest\Installers\PluginBrowser;
use Pest\Mutate\Contracts\MutationTestRunner;
use Pest\Mutate\Repositories\ConfigurationRepository;
use Pest\PendingCalls\AfterEachCall;
@ -18,6 +21,7 @@ use Pest\Repositories\DatasetsRepository;
use Pest\Support\Backtrace;
use Pest\Support\Container;
use Pest\Support\DatasetInfo;
use Pest\Support\Description;
use Pest\Support\HigherOrderTapProxy;
use Pest\TestSuite;
use PHPUnit\Framework\TestCase;
@ -95,7 +99,7 @@ if (! function_exists('describe')) {
{
$filename = Backtrace::testFile();
return new DescribeCall(TestSuite::getInstance(), $filename, $description, $tests);
return new DescribeCall(TestSuite::getInstance(), $filename, new Description($description), $tests);
}
}
@ -278,3 +282,51 @@ if (! function_exists('mutates')) {
}
}
}
if (! function_exists('fixture')) {
/**
* Returns the absolute path to a fixture file.
*/
function fixture(string $file): string
{
$file = implode(DIRECTORY_SEPARATOR, [
TestSuite::getInstance()->rootPath,
TestSuite::getInstance()->testPath,
'Fixtures',
str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $file),
]);
$fileRealPath = realpath($file);
if ($fileRealPath === false) {
throw new InvalidArgumentException(
'The fixture file ['.$file.'] does not exist.',
);
}
return $fileRealPath;
}
}
if (! function_exists('visit')) {
/**
* Browse to the given URL.
*
* @template TUrl of array<int, string>|string
*
* @param TUrl $url
* @param array<string, mixed> $options
* @return (TUrl is array<int, string> ? ArrayablePendingAwaitablePage : PendingAwaitablePage)
*/
function visit(array|string $url, array $options = []): ArrayablePendingAwaitablePage|PendingAwaitablePage
{
if (! class_exists(\Pest\Browser\Configuration::class)) {
PluginBrowser::install();
exit(0);
}
// @phpstan-ignore-next-line
return test()->visit($url, $options);
}
}

View File

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Pest\Installers;
use Pest\Support\View;
final readonly class PluginBrowser
{
public static function install(): void
{
View::render('installers/plugin-browser');
}
}

View File

@ -34,7 +34,7 @@ final readonly class Kernel
*
* @var array<int, class-string>
*/
private const BOOTSTRAPPERS = [
private const array BOOTSTRAPPERS = [
Bootstrappers\BootOverrides::class,
Bootstrappers\BootSubscribers::class,
Bootstrappers\BootFiles::class,
@ -71,7 +71,7 @@ final readonly class Kernel
$output,
);
register_shutdown_function(fn () => $kernel->shutdown());
register_shutdown_function($kernel->shutdown(...));
foreach (self::BOOTSTRAPPERS as $bootstrapper) {
$bootstrapper = Container::getInstance()->get($bootstrapper);

View File

@ -31,7 +31,7 @@ final readonly class Converter
/**
* The prefix for the test suite name.
*/
private const PREFIX = 'P\\';
private const string PREFIX = 'P\\';
/**
* The state generator.
@ -131,7 +131,7 @@ final readonly class Converter
// clean the paths of each frame.
$frames = array_map(
fn (string $frame): string => $this->toRelativePath($frame),
$this->toRelativePath(...),
$frames
);

View File

@ -781,15 +781,13 @@ final class Expectation
foreach ($array as $key => $value) {
Assert::assertArrayHasKey($key, $valueAsArray, $message);
if ($message === '') {
$message = sprintf(
'Failed asserting that an array has a key %s with the value %s.',
$this->export($key),
$this->export($valueAsArray[$key]),
);
}
$assertMessage = $message !== '' ? $message : sprintf(
'Failed asserting that an array has a key %s with the value %s.',
$this->export($key),
$this->export($valueAsArray[$key]),
);
Assert::assertEquals($value, $valueAsArray[$key], $message);
Assert::assertEquals($value, $valueAsArray[$key], $assertMessage);
}
return $this;
@ -802,7 +800,7 @@ final class Expectation
* @param iterable<string, mixed> $object
* @return self<TValue>
*/
public function toMatchObject(iterable $object, string $message = ''): self
public function toMatchObject(object|iterable $object, string $message = ''): self
{
foreach ((array) $object as $property => $value) {
if (! is_object($this->value) && ! is_string($this->value)) {
@ -814,15 +812,13 @@ final class Expectation
/* @phpstan-ignore-next-line */
$propertyValue = $this->value->{$property};
if ($message === '') {
$message = sprintf(
'Failed asserting that an object has a property %s with the value %s.',
$this->export($property),
$this->export($propertyValue),
);
}
$assertMessage = $message !== '' ? $message : sprintf(
'Failed asserting that an object has a property %s with the value %s.',
$this->export($property),
$this->export($propertyValue),
);
Assert::assertEquals($value, $propertyValue, $message);
Assert::assertEquals($value, $propertyValue, $assertMessage);
}
return $this;
@ -1158,4 +1154,21 @@ final class Expectation
return $this;
}
/**
* Asserts that the value can be converted to a slug
*
* @return self<TValue>
*/
public function toBeSlug(string $message = ''): self
{
if ($message === '') {
$message = "Failed asserting that {$this->value} can be converted to a slug.";
}
$slug = Str::slugify((string) $this->value);
Assert::assertNotEmpty($slug, $message);
return $this;
}
}

View File

@ -12,14 +12,14 @@ trait Describable
/**
* Note: this is property is not used; however, it gets added automatically by rector php.
*
* @var array<int, string>
* @var array<int, \Pest\Support\Description>
*/
public array $__describing;
/**
* The describing of the test case.
*
* @var array<int, string>
* @var array<int, \Pest\Support\Description>
*/
public array $describing = [];
}

View File

@ -6,6 +6,7 @@ namespace Pest\PendingCalls;
use Closure;
use Pest\Support\Backtrace;
use Pest\Support\Description;
use Pest\TestSuite;
/**
@ -16,7 +17,7 @@ final class DescribeCall
/**
* The current describe call.
*
* @var array<int, string>
* @var array<int, Description>
*/
private static array $describing = [];
@ -31,7 +32,7 @@ final class DescribeCall
public function __construct(
public readonly TestSuite $testSuite,
public readonly string $filename,
public readonly string $description,
public readonly Description $description,
public readonly Closure $tests
) {
//
@ -40,7 +41,7 @@ final class DescribeCall
/**
* What is the current describing.
*
* @return array<int, string>
* @return array<int, Description>
*/
public static function describing(): array
{

View File

@ -12,6 +12,7 @@ use Pest\Factories\Attribute;
use Pest\Factories\TestCaseMethodFactory;
use Pest\Mutate\Repositories\ConfigurationRepository;
use Pest\PendingCalls\Concerns\Describable;
use Pest\Plugins\Environment;
use Pest\Plugins\Only;
use Pest\Support\Backtrace;
use Pest\Support\Container;
@ -178,10 +179,9 @@ final class TestCall // @phpstan-ignore-line
}
/**
* Runs the current test multiple times with
* each item of the given `iterable`.
* Runs the current test multiple times with each item of the given `iterable`.
*
* @param array<\Closure|iterable<int|string, mixed>|string> $data
* @param Closure|iterable<array-key, mixed>|string $data
*/
public function with(Closure|iterable|string ...$data): self
{
@ -315,6 +315,61 @@ final class TestCall // @phpstan-ignore-line
: $this;
}
/**
* Weather the current test is running on a CI environment.
*/
private function runningOnCI(): bool
{
foreach ([
'CI',
'GITHUB_ACTIONS',
'GITLAB_CI',
'CIRCLECI',
'TRAVIS',
'APPVEYOR',
'BITBUCKET_BUILD_NUMBER',
'BUILDKITE',
'TEAMCITY_VERSION',
'JENKINS_URL',
'SYSTEM_COLLECTIONURI',
'CI_NAME',
'TASKCLUSTER_ROOT_URL',
'DRONE',
'WERCKER',
'NEVERCODE',
'SEMAPHORE',
'NETLIFY',
'NOW_BUILDER',
] as $env) {
if (getenv($env) !== false) {
return true;
}
}
return Environment::name() === Environment::CI;
}
/**
* Skips the current test when running on a CI environments.
*/
public function skipOnCI(): self
{
if ($this->runningOnCI()) {
return $this->skip('This test is skipped on [CI].');
}
return $this;
}
public function skipLocally(): self
{
if ($this->runningOnCI() === false) {
return $this->skip('This test is skipped [locally].');
}
return $this;
}
/**
* Skips the current test unless the given test is running on Windows.
*/
@ -604,18 +659,29 @@ final class TestCall // @phpstan-ignore-line
}
/**
* Sets that the current test covers nothing.
* Adds one or more references to the tested method or class. This helps
* to link test cases to the source code for easier navigation.
*
* @param array<class-string|string>|class-string ...$classes
*/
public function coversNothing(): self
public function references(string|array ...$classes): self
{
$this->testCaseMethod->attributes[] = new Attribute(
\PHPUnit\Framework\Attributes\CoversNothing::class,
[],
);
assert($classes !== []);
return $this;
}
/**
* Adds one or more references to the tested method or class. This helps
* to link test cases to the source code for easier navigation.
*
* @param array<class-string|string>|class-string ...$classes
*/
public function see(string|array ...$classes): self
{
return $this->references(...$classes);
}
/**
* Informs the test runner that no expectations happen in this test,
* and its purpose is simply to check whether the given code can
@ -693,7 +759,12 @@ final class TestCall // @phpstan-ignore-line
$this->testSuite->tests->set($this->testCaseMethod);
if (! is_null($testCase = $this->testSuite->tests->get($this->filename))) {
$testCase->attributes = array_merge($testCase->attributes, $this->testCaseFactoryAttributes);
$attributesToMerge = array_filter(
$this->testCaseFactoryAttributes,
fn (Attribute $attributeToMerge): bool => array_filter($testCase->attributes, fn (Attribute $attribute): bool => serialize($attributeToMerge) === serialize($attribute)) === []
);
$testCase->attributes = array_merge($testCase->attributes, $attributesToMerge);
}
}
}

View File

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

View File

@ -21,7 +21,7 @@ final class Cache implements HandlesArguments
/**
* The temporary folder.
*/
private const TEMPORARY_FOLDER = __DIR__
private const string TEMPORARY_FOLDER = __DIR__
.DIRECTORY_SEPARATOR
.'..'
.DIRECTORY_SEPARATOR

View File

@ -21,7 +21,7 @@ final class Configuration implements HandlesArguments, Terminable
/**
* The base PHPUnit file.
*/
public const BASE_PHPUNIT_FILE = __DIR__
public const string BASE_PHPUNIT_FILE = __DIR__
.DIRECTORY_SEPARATOR
.'..'
.DIRECTORY_SEPARATOR
@ -34,7 +34,7 @@ final class Configuration implements HandlesArguments, Terminable
*/
public function handleArguments(array $arguments): array
{
if ($this->hasArgument('--configuration', $arguments) || $this->hasCustomConfigurationFile()) {
if ($this->hasArgument('--configuration', $arguments) || $this->hasArgument('-c', $arguments) || $this->hasCustomConfigurationFile()) {
return $arguments;
}

View File

@ -17,20 +17,11 @@ use Symfony\Component\Console\Output\OutputInterface;
*/
final class Coverage implements AddsOutput, HandlesArguments
{
/**
* @var string
*/
private const COVERAGE_OPTION = 'coverage';
private const string COVERAGE_OPTION = 'coverage';
/**
* @var string
*/
private const MIN_OPTION = 'min';
private const string MIN_OPTION = 'min';
/**
* @var string
*/
private const EXACTLY_OPTION = 'exactly';
private const string EXACTLY_OPTION = 'exactly';
/**
* Whether it should show the coverage or not.

View File

@ -14,12 +14,12 @@ final class Environment implements HandlesArguments
/**
* The continuous integration environment.
*/
public const CI = 'ci';
public const string CI = 'ci';
/**
* The local environment.
*/
public const LOCAL = 'local';
public const string LOCAL = 'local';
/**
* The current environment.

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'] = [...[[
@ -141,6 +142,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'], ...[

View File

@ -20,12 +20,12 @@ final readonly class Init implements HandlesArguments
/**
* The option the triggers the init job.
*/
private const INIT_OPTION = '--init';
private const string INIT_OPTION = '--init';
/**
* The files that will be created.
*/
private const STUBS = [
private const array STUBS = [
'phpunit.xml.stub' => 'phpunit.xml',
'Pest.php.stub' => 'tests/Pest.php',
'TestCase.php.stub' => 'tests/TestCase.php',

View File

@ -5,7 +5,10 @@ declare(strict_types=1);
namespace Pest\Plugins;
use Pest\Contracts\Plugins\Terminable;
use Pest\Factories\Attribute;
use Pest\Factories\TestCaseMethodFactory;
use Pest\PendingCalls\TestCall;
use PHPUnit\Framework\Attributes\Group;
/**
* @internal
@ -15,7 +18,7 @@ final class Only implements Terminable
/**
* The temporary folder.
*/
private const TEMPORARY_FOLDER = __DIR__
private const string TEMPORARY_FOLDER = __DIR__
.DIRECTORY_SEPARATOR
.'..'
.DIRECTORY_SEPARATOR
@ -23,28 +26,19 @@ final class Only implements Terminable
.DIRECTORY_SEPARATOR
.'.temp';
/**
* {@inheritDoc}
*/
public function terminate(): void
{
if (Parallel::isWorker()) {
return;
}
$lockFile = self::TEMPORARY_FOLDER.DIRECTORY_SEPARATOR.'only.lock';
if (file_exists($lockFile)) {
unlink($lockFile);
}
}
/**
* Creates the lock file.
*/
public static function enable(TestCall $testCall, string $group = '__pest_only'): void
public static function enable(TestCall|TestCaseMethodFactory $testCall, string $group = '__pest_only'): void
{
$testCall->group($group);
if ($testCall instanceof TestCall) {
$testCall->group($group);
} else {
$testCall->attributes[] = new Attribute(
Group::class,
[$group],
);
}
if (Environment::name() === Environment::CI || Parallel::isWorker()) {
return;
@ -88,4 +82,20 @@ final class Only implements Terminable
return file_get_contents($lockFile) ?: '__pest_only'; // @phpstan-ignore-line
}
/**
* {@inheritDoc}
*/
public function terminate(): void
{
if (Parallel::isWorker()) {
return;
}
$lockFile = self::TEMPORARY_FOLDER.DIRECTORY_SEPARATOR.'only.lock';
if (file_exists($lockFile)) {
unlink($lockFile);
}
}
}

View File

@ -23,9 +23,9 @@ final class Parallel implements HandlesArguments
{
use HandleArguments;
private const GLOBAL_PREFIX = 'PEST_PARALLEL_GLOBAL_';
private const string GLOBAL_PREFIX = 'PEST_PARALLEL_GLOBAL_';
private const HANDLERS = [
private const array HANDLERS = [
Parallel\Handlers\Parallel::class,
Parallel\Handlers\Pest::class,
Parallel\Handlers\Laravel::class,
@ -34,7 +34,7 @@ final class Parallel implements HandlesArguments
/**
* @var string[]
*/
private const UNSUPPORTED_ARGUMENTS = ['--todo', '--todos', '--retry', '--notes', '--issue', '--pr', '--pull-request'];
private const array UNSUPPORTED_ARGUMENTS = ['--todo', '--todos', '--retry', '--notes', '--issue', '--pr', '--pull-request'];
/**
* Whether the given command line arguments indicate that the test suite should be run in parallel.

View File

@ -18,7 +18,7 @@ final class Parallel implements HandlesArguments
/**
* The list of arguments to remove.
*/
private const ARGS_TO_REMOVE = [
private const array ARGS_TO_REMOVE = [
'--parallel',
'-p',
'--no-output',

View File

@ -11,6 +11,7 @@ final class CleanConsoleOutput extends ConsoleOutput
/**
* {@inheritdoc}
*/
#[\Override]
protected function doWrite(string $message, bool $newline): void // @pest-arch-ignore-line
{
if ($this->isOpeningHeadline($message)) {

View File

@ -59,10 +59,10 @@ final class ResultPrinter
private readonly OutputInterface $output,
private readonly Options $options
) {
$this->printer = new class($this->output) implements Printer
$this->printer = new readonly class($this->output) implements Printer
{
public function __construct(
private readonly OutputInterface $output,
private OutputInterface $output,
) {}
public function print(string $buffer): void

View File

@ -17,6 +17,7 @@ use ParaTest\WrapperRunner\WrapperWorker;
use Pest\Result;
use Pest\TestSuite;
use PHPUnit\Event\Facade as EventFacade;
use PHPUnit\Event\Test\AfterLastTestMethodFailed;
use PHPUnit\Event\TestRunner\WarningTriggered;
use PHPUnit\Runner\CodeCoverage;
use PHPUnit\Runner\ResultCache\DefaultResultCache;
@ -50,7 +51,7 @@ final class WrapperRunner implements RunnerInterface
/**
* The time to sleep between cycles.
*/
private const CYCLE_SLEEP = 10000;
private const int CYCLE_SLEEP = 10000;
/**
* The result printer.
@ -313,27 +314,42 @@ final class WrapperRunner implements RunnerInterface
$testResult = unserialize($contents);
assert($testResult instanceof TestResult);
/** @var list<AfterLastTestMethodFailed> $failedEvents */
$failedEvents = array_merge_recursive($testResultSum->testFailedEvents(), $testResult->testFailedEvents());
$testResultSum = new TestResult(
(int) $testResultSum->hasTests() + (int) $testResult->hasTests(),
$testResultSum->numberOfTestsRun() + $testResult->numberOfTestsRun(),
$testResultSum->numberOfAssertions() + $testResult->numberOfAssertions(),
array_merge_recursive($testResultSum->testErroredEvents(), $testResult->testErroredEvents()),
array_merge_recursive($testResultSum->testFailedEvents(), $testResult->testFailedEvents()),
$failedEvents,
array_merge_recursive($testResultSum->testConsideredRiskyEvents(), $testResult->testConsideredRiskyEvents()),
array_merge_recursive($testResultSum->testSuiteSkippedEvents(), $testResult->testSuiteSkippedEvents()),
array_merge_recursive($testResultSum->testSkippedEvents(), $testResult->testSkippedEvents()),
array_merge_recursive($testResultSum->testMarkedIncompleteEvents(), $testResult->testMarkedIncompleteEvents()),
array_merge_recursive($testResultSum->testTriggeredPhpunitDeprecationEvents(), $testResult->testTriggeredPhpunitDeprecationEvents()),
array_merge_recursive($testResultSum->testTriggeredPhpunitErrorEvents(), $testResult->testTriggeredPhpunitErrorEvents()),
array_merge_recursive($testResultSum->testTriggeredPhpunitNoticeEvents(), $testResult->testTriggeredPhpunitNoticeEvents()),
array_merge_recursive($testResultSum->testTriggeredPhpunitWarningEvents(), $testResult->testTriggeredPhpunitWarningEvents()),
// @phpstan-ignore-next-line
array_merge_recursive($testResultSum->testRunnerTriggeredDeprecationEvents(), $testResult->testRunnerTriggeredDeprecationEvents()),
// @phpstan-ignore-next-line
array_merge_recursive($testResultSum->testRunnerTriggeredNoticeEvents(), $testResult->testRunnerTriggeredNoticeEvents()),
// @phpstan-ignore-next-line
array_merge_recursive($testResultSum->testRunnerTriggeredWarningEvents(), $testResult->testRunnerTriggeredWarningEvents()),
// @phpstan-ignore-next-line
array_merge_recursive($testResultSum->errors(), $testResult->errors()),
// @phpstan-ignore-next-line
array_merge_recursive($testResultSum->deprecations(), $testResult->deprecations()),
// @phpstan-ignore-next-line
array_merge_recursive($testResultSum->notices(), $testResult->notices()),
// @phpstan-ignore-next-line
array_merge_recursive($testResultSum->warnings(), $testResult->warnings()),
// @phpstan-ignore-next-line
array_merge_recursive($testResultSum->phpDeprecations(), $testResult->phpDeprecations()),
// @phpstan-ignore-next-line
array_merge_recursive($testResultSum->phpNotices(), $testResult->phpNotices()),
// @phpstan-ignore-next-line
array_merge_recursive($testResultSum->phpWarnings(), $testResult->phpWarnings()),
$testResultSum->numberOfIssuesIgnoredByBaseline() + $testResult->numberOfIssuesIgnoredByBaseline(),
);
@ -351,8 +367,10 @@ final class WrapperRunner implements RunnerInterface
$testResultSum->testMarkedIncompleteEvents(),
$testResultSum->testTriggeredPhpunitDeprecationEvents(),
$testResultSum->testTriggeredPhpunitErrorEvents(),
$testResultSum->testTriggeredPhpunitNoticeEvents(),
$testResultSum->testTriggeredPhpunitWarningEvents(),
$testResultSum->testRunnerTriggeredDeprecationEvents(),
$testResultSum->testRunnerTriggeredNoticeEvents(),
array_values(array_filter(
$testResultSum->testRunnerTriggeredWarningEvents(),
fn (WarningTriggered $event): bool => ! str_contains($event->message(), 'No tests found')

View File

@ -34,7 +34,7 @@ final class CompactPrinter
/**
* @var array<string, array<int, string>>
*/
private const LOOKUP_TABLE = [
private const array LOOKUP_TABLE = [
'.' => ['gray', '.'],
'S' => ['yellow', 's'],
'T' => ['cyan', 't'],
@ -131,14 +131,14 @@ final class CompactPrinter
$status['collected'],
$status['threshold'],
$status['roots'],
null,
null,
null,
null,
null,
null,
null,
null,
0.00,
0.00,
0.00,
0.00,
false,
false,
false,
0,
);
$telemetry = new Info(

177
src/Plugins/Shard.php Normal file
View File

@ -0,0 +1,177 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins;
use Pest\Contracts\Plugins\AddsOutput;
use Pest\Contracts\Plugins\HandlesArguments;
use Pest\Exceptions\InvalidOption;
use Symfony\Component\Console\Input\ArgvInput;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Process\Process;
/**
* @internal
*/
final class Shard implements AddsOutput, HandlesArguments
{
use Concerns\HandleArguments;
private const string SHARD_OPTION = 'shard';
/**
* The shard index and total number of shards.
*
* @var array{
* index: int,
* total: int,
* testsRan: int,
* testsCount: int
* }|null
*/
private static ?array $shard = null;
/**
* Creates a new Plugin instance.
*/
public function __construct(
private readonly OutputInterface $output,
) {
//
}
/**
* {@inheritDoc}
*/
public function handleArguments(array $arguments): array
{
if (! $this->hasArgument('--shard', $arguments)) {
return $arguments;
}
// @phpstan-ignore-next-line
$input = new ArgvInput($arguments);
['index' => $index, 'total' => $total] = self::getShard($input);
$arguments = $this->popArgument("--shard=$index/$total", $this->popArgument('--shard', $this->popArgument(
"$index/$total",
$arguments,
)));
/** @phpstan-ignore-next-line */
$tests = $this->allTests($arguments);
$testsToRun = (array_chunk($tests, max(1, (int) ceil(count($tests) / $total))))[$index - 1] ?? [];
self::$shard = [
'index' => $index,
'total' => $total,
'testsRan' => count($testsToRun),
'testsCount' => count($tests),
];
return [...$arguments, '--filter', $this->buildFilterArgument($testsToRun)];
}
/**
* Returns all tests that the test suite would run.
*
* @param list<string> $arguments
* @return list<string>
*/
private function allTests(array $arguments): array
{
$output = (new Process([
'php',
...$this->removeParallelArguments($arguments),
'--list-tests',
]))->mustRun()->getOutput();
preg_match_all('/ - (?:P\\\\)?(Tests\\\\[^:]+)::/', $output, $matches);
return array_values(array_unique($matches[1]));
}
/**
* @param array<int, string> $arguments
* @return array<int, string>
*/
private function removeParallelArguments(array $arguments): array
{
return array_filter($arguments, fn (string $argument): bool => ! in_array($argument, ['--parallel', '-p'], strict: true));
}
/**
* Builds the filter argument for the given tests to run.
*/
private function buildFilterArgument(mixed $testsToRun): string
{
return addslashes(implode('|', $testsToRun));
}
/**
* Adds output after the Test Suite execution.
*/
public function addOutput(int $exitCode): int
{
if (self::$shard === null) {
return $exitCode;
}
[
'index' => $index,
'total' => $total,
'testsRan' => $testsRan,
'testsCount' => $testsCount,
] = self::$shard;
$this->output->writeln(sprintf(
' <fg=gray>Shard:</> <fg=default>%d of %d</> — %d file%s ran, out of %d.',
$index,
$total,
$testsRan,
$testsRan === 1 ? '' : 's',
$testsCount,
));
return $exitCode;
}
/**
* Returns the shard information.
*
* @return array{index: int, total: int}
*/
public static function getShard(InputInterface $input): array
{
if ($input->hasParameterOption('--'.self::SHARD_OPTION)) {
$shard = $input->getParameterOption('--'.self::SHARD_OPTION);
} else {
$shard = null;
}
if (! is_string($shard) || ! preg_match('/^\d+\/\d+$/', $shard)) {
throw new InvalidOption('The [--shard] option must be in the format "index/total".');
}
[$index, $total] = explode('/', $shard);
if (! is_numeric($index) || ! is_numeric($total)) {
throw new InvalidOption('The [--shard] option must be in the format "index/total".');
}
if ($index <= 0 || $total <= 0 || $index > $total) {
throw new InvalidOption('The [--shard] option index must be a non-negative integer less than the total number of shards.');
}
$index = (int) $index;
$total = (int) $total;
return [
'index' => $index,
'total' => $total,
];
}
}

View File

@ -16,7 +16,7 @@ final class Verbose implements HandlesArguments
/**
* The list of verbosity levels.
*/
private const VERBOSITY_LEVELS = ['v', 'vv', 'vvv', 'q'];
private const array VERBOSITY_LEVELS = ['v', 'vv', 'vvv', 'q'];
/**
* {@inheritDoc}

View File

@ -19,7 +19,7 @@ use function sprintf;
*/
final class DatasetsRepository
{
private const SEPARATOR = '>>';
private const string SEPARATOR = '>>';
/**
* Holds the datasets.
@ -67,11 +67,11 @@ final class DatasetsRepository
}
/**
* @return Closure|array<int|string, mixed>
* @return array<int|string, mixed>
*
* @throws ShouldNotHappen
*/
public static function get(string $filename, string $description): Closure|array // @phpstan-ignore-line
public static function get(string $filename, string $description): array // @phpstan-ignore-line
{
$dataset = self::$withs[$filename.self::SEPARATOR.$description];
@ -191,6 +191,7 @@ final class DatasetsRepository
return str_starts_with($currentTestFile, $datasetScope);
}, ARRAY_FILTER_USE_KEY);
/** @var string|null $closestScopeDatasetKey */
$closestScopeDatasetKey = array_reduce(
array_keys($matchingDatasets),
fn (string|int|null $keyA, string|int|null $keyB): string|int|null => $keyA !== null && strlen((string) $keyA) > strlen((string) $keyB) ? $keyA : $keyB

View File

@ -19,6 +19,7 @@ final class SnapshotRepository
* Creates a snapshot repository instance.
*/
public function __construct(
private readonly string $rootPath,
private readonly string $testsPath,
private readonly string $snapshotsPath,
) {}
@ -103,7 +104,19 @@ final class SnapshotRepository
*/
private function getSnapshotFilename(): string
{
$relativePath = str_replace($this->testsPath, '', TestSuite::getInstance()->getFilename());
$testFile = TestSuite::getInstance()->getFilename();
if (str_starts_with($testFile, $this->testsPath)) {
// if the test file is in the tests directory
$startPath = $this->testsPath;
} else {
// if the test file is in the app, src, etc. directory
$startPath = $this->rootPath;
}
// relative path: we use substr() and not str_replace() to remove the start path
// for instance, if the $startPath is /app/ and the $testFile is /app/app/tests/Unit/ExampleTest.php, we should only remove the first /app/ from the path
$relativePath = substr($testFile, strlen($startPath));
// remove extension from filename
$relativePath = substr($relativePath, 0, (int) strrpos($relativePath, '.'));

View File

@ -4,20 +4,16 @@ declare(strict_types=1);
namespace Pest;
use NunoMaduro\Collision\Adapters\Phpunit\Support\ResultReflection;
use PHPUnit\TestRunner\TestResult\TestResult;
use PHPUnit\TextUI\Configuration\Configuration;
use PHPUnit\TextUI\ShellExitCodeCalculator;
/**
* @internal
*/
final class Result
{
private const SUCCESS_EXIT = 0;
private const FAILURE_EXIT = 1;
private const EXCEPTION_EXIT = 2;
private const int SUCCESS_EXIT = 0;
/**
* If the exit code is different from 0.
@ -40,44 +36,8 @@ final class Result
*/
public static function exitCode(Configuration $configuration, TestResult $result): int
{
if ($result->wasSuccessful()) {
if ($configuration->failOnWarning()) {
$warnings = $result->numberOfTestsWithTestTriggeredPhpunitWarningEvents()
+ count($result->warnings())
+ count($result->phpWarnings());
$shell = new ShellExitCodeCalculator;
if ($warnings > 0) {
return self::FAILURE_EXIT;
}
}
if (! $result->hasTestTriggeredPhpunitWarningEvents()) {
return self::SUCCESS_EXIT;
}
}
if ($configuration->failOnEmptyTestSuite() && ResultReflection::numberOfTests($result) === 0) {
return self::FAILURE_EXIT;
}
if ($result->wasSuccessful()) {
if ($configuration->failOnRisky() && $result->hasTestConsideredRiskyEvents()) {
$returnCode = self::FAILURE_EXIT;
}
if ($configuration->failOnIncomplete() && $result->hasTestMarkedIncompleteEvents()) {
$returnCode = self::FAILURE_EXIT;
}
if ($configuration->failOnSkipped() && $result->hasTestSkippedEvents()) {
$returnCode = self::FAILURE_EXIT;
}
}
if ($result->hasTestErroredEvents()) {
return self::EXCEPTION_EXIT;
}
return self::FAILURE_EXIT;
return $shell->calculate($configuration, $result);
}
}

View File

@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace Pest\Runner\Filter;
use Pest\Contracts\HasPrintableTestCaseName;
use PHPUnit\Framework\Test;
use RecursiveFilterIterator;
use RecursiveIterator;
/**
* @internal
*/
final class EnsureTestCaseIsInitiatedFilter extends RecursiveFilterIterator
{
/**
* @param RecursiveIterator<int, Test> $iterator
*/
public function __construct(RecursiveIterator $iterator)
{
parent::__construct($iterator);
}
/**
* {@inheritdoc}
*/
public function accept(): bool
{
$test = $this->getInnerIterator()->current();
if ($test instanceof HasPrintableTestCaseName) {
/** @phpstan-ignore-next-line */
$test->__initializeTestCase();
}
return true;
}
}

View File

@ -33,7 +33,7 @@ final class EnsureIgnorableTestCasesAreIgnored implements StartedSubscriber
/** @var array<int, WarningTriggered> $testRunnerTriggeredWarningEvents */
$testRunnerTriggeredWarningEvents = $property->getValue($collector);
$testRunnerTriggeredWarningEvents = array_values(array_filter($testRunnerTriggeredWarningEvents, fn (WarningTriggered $event): bool => $event->message() !== 'No tests found in class "Pest\TestCases\IgnorableTestCase".'));
$testRunnerTriggeredWarningEvents = array_values(array_filter($testRunnerTriggeredWarningEvents, fn (WarningTriggered $event): bool => str_contains($event->message(), 'No tests found in class') === false));
$property->setValue($collector, $testRunnerTriggeredWarningEvents);
}

View File

@ -11,12 +11,9 @@ use Pest\Exceptions\ShouldNotHappen;
*/
final class Backtrace
{
/**
* @var string
*/
private const FILE = 'file';
private const string FILE = 'file';
private const BACKTRACE_OPTIONS = DEBUG_BACKTRACE_IGNORE_ARGS;
private const int BACKTRACE_OPTIONS = DEBUG_BACKTRACE_IGNORE_ARGS;
/**
* Returns the current test file.

View File

@ -11,9 +11,9 @@ use function Pest\testDirectory;
*/
final class DatasetInfo
{
public const DATASETS_DIR_NAME = 'Datasets';
public const string DATASETS_DIR_NAME = 'Datasets';
public const DATASETS_FILE_NAME = 'Datasets.php';
public const string DATASETS_FILE_NAME = 'Datasets.php';
public static function isInsideADatasetsDirectory(string $file): bool
{

View File

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Pest\Support;
final readonly class Description implements \Stringable
{
/**
* Creates a new Description instance.
*/
public function __construct(private string $description) {}
/**
* Returns the description as a string.
*/
public function __toString(): string
{
return $this->description;
}
}

View File

@ -13,7 +13,7 @@ use Throwable;
*/
final class ExceptionTrace
{
private const UNDEFINED_METHOD = 'Call to undefined method P\\';
private const string UNDEFINED_METHOD = 'Call to undefined method P\\';
/**
* Ensures the given closure reports the good execution context.

View File

@ -15,7 +15,7 @@ final readonly class Exporter
/**
* The maximum number of items in an array to export.
*/
private const MAX_ARRAY_ITEMS = 3;
private const int MAX_ARRAY_ITEMS = 3;
/**
* Creates a new Exporter instance.

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

@ -13,7 +13,7 @@ use Throwable;
*/
final class HigherOrderMessage
{
public const UNDEFINED_METHOD = 'Method %s does not exist';
public const string UNDEFINED_METHOD = 'Method %s does not exist';
/**
* An optional condition that will determine if the message will be executed.

View File

@ -31,10 +31,8 @@ final class HigherOrderTapProxy
/**
* Dynamically pass properties gets to the target.
*
* @return mixed
*/
public function __get(string $property)
public function __get(string $property): mixed
{
if (property_exists($this->target, $property)) {
return $this->target->{$property};

101
src/Support/Shell.php Normal file
View File

@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace Pest\Support;
use Illuminate\Support\Env;
use Laravel\Tinker\ClassAliasAutoloader;
use Pest\TestSuite;
use Psy\Configuration;
use Psy\Shell as PsyShell;
use Psy\VersionUpdater\Checker;
/**
* @internal
*/
final class Shell
{
/**
* Creates a new interactive shell.
*/
public static function open(): void
{
$config = new Configuration;
$config->setUpdateCheck(Checker::NEVER);
$config->getPresenter()->addCasters(self::casters());
$shell = new PsyShell($config);
$loader = self::tinkered($shell);
try {
$shell->run();
} finally {
$loader?->unregister(); // @phpstan-ignore-line
}
}
/**
* Returns the casters for the Psy Shell.
*
* @return array<string, callable>
*/
private static function casters(): array
{
$casters = [
'Illuminate\Support\Collection' => 'Laravel\Tinker\TinkerCaster::castCollection',
'Illuminate\Support\HtmlString' => 'Laravel\Tinker\TinkerCaster::castHtmlString',
'Illuminate\Support\Stringable' => 'Laravel\Tinker\TinkerCaster::castStringable',
];
if (class_exists('Illuminate\Database\Eloquent\Model')) {
$casters['Illuminate\Database\Eloquent\Model'] = 'Laravel\Tinker\TinkerCaster::castModel';
}
if (class_exists('Illuminate\Process\ProcessResult')) {
$casters['Illuminate\Process\ProcessResult'] = 'Laravel\Tinker\TinkerCaster::castProcessResult';
}
if (class_exists('Illuminate\Foundation\Application')) {
$casters['Illuminate\Foundation\Application'] = 'Laravel\Tinker\TinkerCaster::castApplication';
}
if (function_exists('app') === false) {
return $casters; // @phpstan-ignore-line
}
$config = app()->make('config');
return array_merge($casters, (array) $config->get('tinker.casters', []));
}
/**
* Tinkers the current shell, if the Tinker package is available.
*/
private static function tinkered(PsyShell $shell): ?object
{
if (function_exists('app') === false
|| ! class_exists(Env::class)
|| ! class_exists(ClassAliasAutoloader::class)
) {
return null;
}
$path = Env::get('COMPOSER_VENDOR_DIR', app()->basePath().DIRECTORY_SEPARATOR.'vendor');
$path .= '/composer/autoload_classmap.php';
if (! file_exists($path)) {
$path = TestSuite::getInstance()->rootPath.DIRECTORY_SEPARATOR.'vendor'.DIRECTORY_SEPARATOR.'composer'.DIRECTORY_SEPARATOR.'autoload_classmap.php';
}
$config = app()->make('config');
return ClassAliasAutoloader::register(
$shell, $path, $config->get('tinker.alias', []), $config->get('tinker.dont_alias', [])
);
}
}

View File

@ -13,12 +13,9 @@ final class Str
* Pool of alpha-numeric characters for generating (unsafe) random strings
* from.
*/
private const POOL = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
private const string POOL = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
/**
* @var string
*/
private const PREFIX = '__pest_evaluable_';
private const string PREFIX = '__pest_evaluable_';
/**
* Create a (unsecure & non-cryptographically safe) random alpha-numeric
@ -82,7 +79,7 @@ final class Str
return $subject;
}
return substr($subject, 0, $pos);
return mb_substr($subject, 0, $pos);
}
/**
@ -104,7 +101,7 @@ final class Str
/**
* Creates a describe block as `$describeDescription` → `$testDescription` format.
*
* @param array<int, string> $describeDescriptions
* @param array<int, Description> $describeDescriptions
*/
public static function describe(array $describeDescriptions, string $testDescription): string
{
@ -120,4 +117,14 @@ final class Str
{
return (bool) filter_var($value, FILTER_VALIDATE_URL);
}
/**
* Converts the given `$target` to a URL-friendly "slug".
*/
public static function slugify(string $target): string
{
$target = preg_replace('/[^a-zA-Z0-9]+/', '-', $target);
return strtolower(trim((string) $target, '-'));
}
}

View File

@ -78,6 +78,7 @@ final class TestSuite
$this->afterAll = new AfterAllRepository;
$this->rootPath = (string) realpath($rootPath);
$this->snapshots = new SnapshotRepository(
$this->rootPath,
implode(DIRECTORY_SEPARATOR, [$this->rootPath, $this->testPath]),
implode(DIRECTORY_SEPARATOR, ['.pest', 'snapshots']),
);
@ -101,7 +102,7 @@ final class TestSuite
}
if (! self::$instance instanceof self) {
Panic::with(new InvalidPestCommand);
throw new InvalidPestCommand;
}
return self::$instance;
@ -119,7 +120,7 @@ final class TestSuite
assert($this->test instanceof TestCase);
$description = str_replace('__pest_evaluable_', '', $this->test->name());
$datasetAsString = str_replace('__pest_evaluable_', '', Str::evaluable($this->test->dataSetAsStringWithData()));
$datasetAsString = str_replace('__pest_evaluable_', '', Str::evaluable($this->test->dataSetAsString()));
return str_replace(' ', '_', $description.$datasetAsString);
}

View File

@ -0,0 +1,35 @@
<?php
use Pest\TestSuite;
beforeEach(function () {
$this->snapshotable = <<<'HTML'
<div class="container">
<div class="row">
<div class="col-md-12">
<h1>Snapshot</h1>
</div>
</div>
</div>
HTML;
});
test('pass with dataset', function ($data) {
TestSuite::getInstance()->snapshots->save($this->snapshotable);
[$filename] = TestSuite::getInstance()->snapshots->get();
expect($filename)->toStartWith('tests/.pest/snapshots-external/')
->toEndWith('pass_with_dataset_with_data_set____my_datas_set_value___.snap')
->and($this->snapshotable)->toMatchSnapshot();
})->with(['my-datas-set-value']);
describe('within describe', function () {
test('pass with dataset', function ($data) {
TestSuite::getInstance()->snapshots->save($this->snapshotable);
[$filename] = TestSuite::getInstance()->snapshots->get();
expect($filename)->toStartWith('tests/.pest/snapshots-external/')
->toEndWith('pass_with_dataset_with_data_set____my_datas_set_value___.snap')
->and($this->snapshotable)->toMatchSnapshot();
});
})->with(['my-datas-set-value']);

View File

@ -0,0 +1,7 @@
<div class="container">
<div class="row">
<div class="col-md-12">
<h1>Snapshot</h1>
</div>
</div>
</div>

View File

@ -0,0 +1,7 @@
<div class="container">
<div class="row">
<div class="col-md-12">
<h1>Snapshot</h1>
</div>
</div>
</div>

View File

@ -0,0 +1,7 @@
<div class="container">
<div class="row">
<div class="col-md-12">
<h1>Snapshot</h1>
</div>
</div>
</div>

View File

@ -0,0 +1,7 @@
<div class="container">
<div class="row">
<div class="col-md-12">
<h1>Snapshot</h1>
</div>
</div>
</div>

View File

@ -0,0 +1,7 @@
<div class="container">
<div class="row">
<div class="col-md-12">
<h1>Snapshot</h1>
</div>
</div>
</div>

Some files were not shown because too many files have changed in this diff Show More