This commit is contained in:
Nuno Maduro
2020-05-11 18:38:30 +02:00
commit de2929077b
112 changed files with 6211 additions and 0 deletions

18
.editorconfig Normal file
View File

@ -0,0 +1,18 @@
; This file is for unifying the coding style for different editors and IDEs.
; More information at http://editorconfig.org
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = space
indent_size = 4
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
[*.yml]
indent_size = 2

16
.gitattributes vendored Normal file
View File

@ -0,0 +1,16 @@
/art export-ignore
/docs export-ignore
/tests export-ignore
/scripts export-ignore
/.github export-ignore
/.php_cs export-ignore
.editorconfig export-ignore
.gitattributes export-ignore
.gitignore export-ignore
.travis.yml export-ignore
phpstan.neon export-ignore
rector.yaml export-ignore
phpunit.xml export-ignore
CHANGELOG.md export-ignore
CONTRIBUTING.md export-ignore
README.md export-ignore

5
.github/FUNDING.yml vendored Normal file
View File

@ -0,0 +1,5 @@
# These are supported funding model platforms
github: nunomaduro
patreon: nunomaduro
custom: https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=66BYDWAT92N6L

51
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,51 @@
name: Continuous Integration
on: ['push', 'pull_request']
jobs:
ci:
runs-on: ubuntu-latest
strategy:
fail-fast: true
matrix:
php: [7.3, 7.4]
dependency-version: [prefer-lowest, prefer-stable]
name: CI - PHP ${{ matrix.php }} (${{ matrix.dependency-version }})
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Cache dependencies
uses: actions/cache@v1
with:
path: ~/.composer/cache/files
key: dependencies-php-${{ matrix.php }}-composer-${{ hashFiles('composer.json') }}
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
extensions: mbstring, zip
tools: prestissimo
coverage: pcov
- name: Install Composer dependencies
run: composer update --${{ matrix.dependency-version }} --no-interaction --prefer-dist
- name: Coding Style Checks
run: |
vendor/bin/rector process src --dry-run
vendor/bin/php-cs-fixer fix -v --dry-run
- name: Type Checks
run: vendor/bin/phpstan analyse --ansi
- name: Unit Tests
run: bin/pest --colors=always --exclude-group=integration
- name: Integration Tests
run: bin/pest --colors=always --group=integration

10
.gitignore vendored Normal file
View File

@ -0,0 +1,10 @@
.idea/*
.idea/codeStyleSettings.xml
composer.lock
/vendor/
coverage.xml
.phpunit.result.cache
.php_cs.cache
.temp/coverage.php
*.swp
*.swo

32
.php_cs Normal file
View File

@ -0,0 +1,32 @@
<?php
$finder = PhpCsFixer\Finder::create()
->in(__DIR__ . DIRECTORY_SEPARATOR . 'tests')
->in(__DIR__ . DIRECTORY_SEPARATOR . 'bin')
->in(__DIR__ . DIRECTORY_SEPARATOR . 'compiled')
->in(__DIR__ . DIRECTORY_SEPARATOR . 'scripts')
->in(__DIR__ . DIRECTORY_SEPARATOR . 'stubs')
->in(__DIR__ . DIRECTORY_SEPARATOR . 'src')
->append(['.php_cs']);
$rules = [
'@Symfony' => true,
'phpdoc_no_empty_return' => false,
'array_syntax' => ['syntax' => 'short'],
'yoda_style' => false,
'binary_operator_spaces' => [
'operators' => [
'=>' => 'align',
'=' => 'align',
],
],
'concat_space' => ['spacing' => 'one'],
'not_operator_with_space' => false,
];
$rules['increment_style'] = ['style' => 'post'];
return PhpCsFixer\Config::create()
->setUsingCache(true)
->setRules($rules)
->setFinder($finder);

0
.temp/.gitkeep Normal file
View File

11
CHANGELOG.md Normal file
View File

@ -0,0 +1,11 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).
## [Unreleased]
## [v0.1]
### Added
- First version

52
CONTRIBUTING.md Normal file
View File

@ -0,0 +1,52 @@
# CONTRIBUTING
Contributions are welcome, and are accepted via pull requests.
Please review these guidelines before submitting any pull requests.
## Process
1. Fork the project
1. Create a new branch
1. Code, test, commit and push
1. Open a pull request detailing your changes. Make sure to follow the [template](.github/PULL_REQUEST_TEMPLATE.md)
## Guidelines
* Please ensure the coding style running `composer lint`.
* Send a coherent commit history, making sure each individual commit in your pull request is meaningful.
* You may need to [rebase](https://git-scm.com/book/en/v2/Git-Branching-Rebasing) to avoid merge conflicts.
* Please remember that we follow [SemVer](http://semver.org/).
## Setup
Clone your fork, then install the dev dependencies:
```bash
composer install
```
## Lint
Lint your code:
```bash
composer lint
```
## Tests
Run all tests:
```bash
composer test
```
Check types:
```bash
composer test:types
```
Unit tests:
```bash
composer test:unit
```
Integration tests:
```bash
composer test:integration
```

21
LICENSE.md Executable file
View File

@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) Nuno Maduro <enunomaduro@gmail.com>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

17
README.md Normal file
View File

@ -0,0 +1,17 @@
<p align="center">
<img src="https://next.pestphp.com/assets/img/og.png" width="600" alt="PEST Preview">
<p align="center">
<a href="https://github.com/pestphp/pest/actions"><img src="https://github.com/pest/pestphp/workflows/tests/badge.svg" alt="Build Status"></a>
<a href="https://packagist.org/packages/pestphp/pest"><img src="https://poser.pugx.org/pestphp/pest/d/total.svg" alt="Total Downloads"></a>
<a href="https://packagist.org/packages/pestphp/pest"><img src="https://poser.pugx.org/pestphp/pest/v/stable.svg" alt="Latest Version"></a>
<a href="https://packagist.org/packages/pestphp/pest"><img src="https://poser.pugx.org/pestphp/pest/license.svg" alt="License"></a>
</p>
</p>
------
**Pest** it's an elegant PHP Testing Framework with a focus on simplicity. It was carefully crafted to bring the joy of testing to PHP.
- Explore the docs: **[pestphp.com »](https://pestphp.com)**
- Join the Discord Server: **[discord.gg/4UMHUb5 »](https://discord.gg/4UMHUb5)**
Pest was created by **[Nuno Maduro](https://twitter.com/enunomaduro)** and is open-sourced software licensed under the **[MIT license](https://opensource.org/licenses/MIT)**.

31
bin/pest Executable file
View File

@ -0,0 +1,31 @@
#!/usr/bin/env php
<?php declare(strict_types=1);
use Pest\Actions\ValidatesEnvironment;
use Pest\Console\Command;
use Pest\TestSuite;
use Symfony\Component\Console\Output\ConsoleOutput;
(static function () {
// Used when Pest is required using composer.
$vendorPath = realpath(__DIR__ . '/../../../../vendor/autoload.php');
// Used when Pest maintainers are running Pest tests.
$localPath = realpath(__DIR__ . '/../vendor/autoload.php');
if ($vendorPath) {
include_once $vendorPath;
} else {
include_once $localPath;
}
(new \NunoMaduro\Collision\Provider)->register();
$rootPath = getcwd();
$testSuite = TestSuite::getInstance($rootPath);
ValidatesEnvironment::in($testSuite);
exit((new Command($testSuite, new ConsoleOutput()))->run($_SERVER['argv']));
})();

0
compiled/.gitkeep Normal file
View File

1926
compiled/globals.php Normal file

File diff suppressed because it is too large Load Diff

82
composer.json Normal file
View File

@ -0,0 +1,82 @@
{
"name": "pestphp/pest",
"description": "An elegant PHP Testing Framework.",
"keywords": [
"php",
"framework",
"pest",
"unit",
"test",
"testing"
],
"license": "MIT",
"authors": [
{
"name": "Nuno Maduro",
"email": "enunomaduro@gmail.com"
}
],
"require": {
"php": "^7.3",
"nunomaduro/collision": "^5.0",
"phpunit/phpunit": "^9.1.4",
"sebastian/environment": "^5.1"
},
"autoload": {
"psr-4": {
"Pest\\": "src/"
},
"files": [
"src/globals.php",
"compiled/globals.php"
]
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/PHPUnit/"
}
},
"require-dev": {
"ergebnis/phpstan-rules": "^0.14.4",
"friendsofphp/php-cs-fixer": "^2.16.3",
"illuminate/console": "^7.10.3",
"illuminate/support": "^7.10.3",
"mockery/mockery": "^1.3.1",
"phpstan/phpstan": "^0.12.25",
"phpstan/phpstan-strict-rules": "^0.12.2",
"rector/rector": "^0.7.25",
"symfony/var-dumper": "^5.0.8",
"thecodingmachine/phpstan-strict-rules": "^0.12.0"
},
"minimum-stability": "dev",
"prefer-stable": true,
"config": {
"sort-packages": true,
"preferred-install": "dist"
},
"bin": [
"bin/pest"
],
"scripts": {
"compile": "@php ./scripts/compile.php",
"lint": "rector process src && php-cs-fixer fix -v",
"test:lint": "php-cs-fixer fix -v --dry-run && rector process src --dry-run",
"test:types": "phpstan analyse --ansi",
"test:unit": "bin/pest --colors=always --exclude-group=integration",
"test:integration": "bin/pest --colors=always --group=integration",
"test:integration:snapshots": "REBUILD_SNAPSHOTS=true bin/pest --colors=always",
"test": [
"@test:lint",
"@test:types",
"@test:unit",
"@test:integration"
]
},
"extra": {
"laravel": {
"providers": [
"Pest\\Laravel\\PestServiceProvider"
]
}
}
}

22
phpstan.neon Normal file
View File

@ -0,0 +1,22 @@
includes:
- vendor/phpstan/phpstan-strict-rules/rules.neon
- vendor/ergebnis/phpstan-rules/rules.neon
- vendor/thecodingmachine/phpstan-strict-rules/phpstan-strict-rules.neon
parameters:
level: max
paths:
- src
excludes_analyse:
- src/globals.php
checkMissingIterableValueType: true
checkGenericClassInNonGenericObjectType: false
reportUnmatchedIgnoredErrors: true
ignoreErrors:
- "#is not allowed to extend#"
- "#Language construct eval#"
- "# with null as default value#"
- "#Using \\$this in static method#"
- "#has parameter \\$closure with default value.#"

16
phpunit.xml Normal file
View File

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="./vendor/phpunit/phpunit/phpunit.xsd"
colors="true"
>
<testsuites>
<testsuite name="default">
<directory suffix=".php">./tests</directory>
</testsuite>
</testsuites>
<filter>
<whitelist processUncoveredFilesFromWhitelist="true">
<directory suffix=".php">./src</directory>
</whitelist>
</filter>
</phpunit>

16
rector.yaml Normal file
View File

@ -0,0 +1,16 @@
# rector.yaml
parameters:
sets:
- 'action-injection-to-constructor-injection'
- 'array-str-functions-to-static-call'
- 'celebrity'
- 'doctrine'
- 'phpstan'
- 'phpunit-code-quality'
- 'solid'
- 'early-return'
- 'doctrine-code-quality'
- 'code-quality'
- 'php71'
- 'php72'
- 'php73'

36
scripts/compile.php Normal file
View File

@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
$globalsFilePath = implode(DIRECTORY_SEPARATOR, [
dirname(__DIR__),
'vendor',
'phpunit',
'phpunit',
'src',
'Framework',
'Assert',
'Functions.php',
]);
$compiledFilePath = implode(DIRECTORY_SEPARATOR, [dirname(__DIR__), 'compiled', 'globals.php']);
@unlink($compiledFilePath);
$replace = function ($contents, $string, $by) {
return str_replace($string, $by, $contents);
};
$remove = function ($contents, $string) {
return str_replace($string, '', $contents);
};
$contents = file_get_contents($globalsFilePath);
$contents = $replace($contents, 'namespace PHPUnit\Framework;', 'use PHPUnit\Framework\Assert;');
$contents = $remove($contents, 'use ArrayAccess;');
$contents = $remove($contents, 'use Countable;');
$contents = $remove($contents, 'use DOMDocument;');
$contents = $remove($contents, 'use DOMElement;');
$contents = $remove($contents, 'use Throwable;');
file_put_contents(implode(DIRECTORY_SEPARATOR, [dirname(__DIR__), 'compiled', 'globals.php']), $contents);

View File

@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace Pest\Actions;
use Pest\Console\Coverage;
use Pest\Support\Str;
use Pest\TestSuite;
use Symfony\Component\Console\Input\ArgvInput;
use Symfony\Component\Console\Input\InputDefinition;
use Symfony\Component\Console\Input\InputOption;
/**
* @internal
*/
final class AddsCoverage
{
/**
* @var string
*/
private const COVERAGE_OPTION = 'coverage';
/**
* @var string
*/
private const MIN_OPTION = 'min';
/**
* Holds the coverage related options.
*
* @var array<int, string>
*/
private const OPTIONS = [self::COVERAGE_OPTION, self::MIN_OPTION];
/**
* If any, adds the coverage params to the given original arguments.
*
* @param array<int, string> $originals
*
* @return array<int, string>
*/
public static function from(TestSuite $testSuite, array $originals): array
{
$arguments = array_merge([''], array_values(array_filter($originals, function ($original): bool {
foreach (self::OPTIONS as $option) {
if ($original === sprintf('--%s', $option) || Str::startsWith($original, sprintf('--%s=', $option))) {
return true;
}
}
return false;
})));
$originals = array_flip($originals);
foreach ($arguments as $argument) {
unset($originals[$argument]);
}
$originals = array_flip($originals);
$inputs = [];
$inputs[] = new InputOption(self::COVERAGE_OPTION, null, InputOption::VALUE_NONE);
$inputs[] = new InputOption(self::MIN_OPTION, null, InputOption::VALUE_REQUIRED);
$input = new ArgvInput($arguments, new InputDefinition($inputs));
if ((bool) $input->getOption(self::COVERAGE_OPTION)) {
$testSuite->coverage = true;
$originals[] = '--coverage-php';
$originals[] = Coverage::getPath();
}
if ($input->getOption(self::MIN_OPTION) !== null) {
/* @phpstan-ignore-next-line */
$testSuite->coverageMin = (float) $input->getOption(self::MIN_OPTION);
}
return $originals;
}
}

View File

@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace Pest\Actions;
use NunoMaduro\Collision\Adapters\Phpunit\Printer;
/**
* @internal
*/
final class AddsDefaults
{
/**
* Adds default arguments to the given `arguments` array.
*
* @param array<string, mixed> $arguments
*
* @return array<string, mixed>
*/
public static function to(array $arguments): array
{
if (!array_key_exists('printer', $arguments)) {
$arguments['printer'] = new Printer();
}
return $arguments;
}
}

66
src/Actions/AddsTests.php Normal file
View File

@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace Pest\Actions;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\TestSuite;
use PHPUnit\Framework\WarningTestCase;
/**
* @internal
*/
final class AddsTests
{
/**
* Adds tests to the given test suite.
*
* @param TestSuite<\PHPUnit\Framework\TestCase> $testSuite
*/
public static function to(TestSuite $testSuite, \Pest\TestSuite $pestTestSuite): void
{
self::removeTestClosureWarnings($testSuite);
// @todo refactor this...
$testSuites = [];
$pestTestSuite->tests->build($pestTestSuite, function (TestCase $testCase) use (&$testSuites): void {
$testCaseClass = get_class($testCase);
if (!array_key_exists($testCaseClass, $testSuites)) {
$testSuites[$testCaseClass] = [];
}
$testSuites[$testCaseClass][] = $testCase;
});
foreach ($testSuites as $testCaseName => $testCases) {
$testTestSuite = new TestSuite($testCaseName);
$testTestSuite->setTests([]);
foreach ($testCases as $testCase) {
$testTestSuite->addTest($testCase, $testCase->getGroups());
}
$testSuite->addTestSuite($testTestSuite);
}
}
/**
* @param TestSuite<\PHPUnit\Framework\TestCase> $testSuite
*/
private static function removeTestClosureWarnings(TestSuite $testSuite): void
{
$tests = $testSuite->tests();
foreach ($tests as $key => $test) {
if ($test instanceof TestSuite) {
self::removeTestClosureWarnings($test);
}
if ($test instanceof WarningTestCase) {
unset($tests[$key]);
}
}
$testSuite->setTests($tests);
}
}

View File

@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace Pest\Actions;
use Pest\Support\Str;
use PHPUnit\TextUI\Configuration\Configuration;
use PHPUnit\Util\FileLoader;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
/**
* @internal
*/
final class LoadStructure
{
/**
* The Pest convention.
*
* @var array<int, string>
*/
private const STRUCTURE = [
'Datasets.php',
'Pest.php',
'Datasets',
];
/**
* Validates the configuration in the given `configuration`.
*/
public static function in(string $rootPath): void
{
$testsPath = $rootPath . DIRECTORY_SEPARATOR . 'tests';
$load = function ($filename): bool {
return file_exists($filename) && (bool) FileLoader::checkAndLoad($filename);
};
foreach (self::STRUCTURE as $filename) {
$filename = sprintf('%s%s%s', $testsPath, DIRECTORY_SEPARATOR, $filename);
if (!file_exists($filename)) {
continue;
}
if (is_dir($filename)) {
$directory = new RecursiveDirectoryIterator($filename);
$iterator = new RecursiveIteratorIterator($directory);
foreach ($iterator as $file) {
$filename = $file->__toString();
if (Str::endsWith($filename, '.php') && file_exists($filename)) {
require_once $filename;
}
}
} else {
$load($filename);
}
}
}
}

View File

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace Pest\Actions;
use Pest\Exceptions\AttributeNotSupportedYet;
use Pest\Exceptions\FileOrFolderNotFound;
use PHPUnit\TextUI\Configuration\Configuration;
use PHPUnit\TextUI\Configuration\Registry;
/**
* @internal
*/
final class ValidatesConfiguration
{
/**
* @var string
*/
private const CONFIGURATION_KEY = 'configuration';
/**
* Validates the configuration in the given `configuration`.
*
* @param array<string, mixed> $arguments
*/
public static function in($arguments): void
{
if (!array_key_exists(self::CONFIGURATION_KEY, $arguments) || !file_exists($arguments[self::CONFIGURATION_KEY])) {
throw new FileOrFolderNotFound('phpunit.xml');
}
$configuration = Registry::getInstance()
->get($arguments[self::CONFIGURATION_KEY])
->phpunit();
if ($configuration->processIsolation()) {
throw new AttributeNotSupportedYet('processIsolation', 'true');
}
}
}

View File

@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace Pest\Actions;
use Pest\Exceptions\FileOrFolderNotFound;
use Pest\TestSuite;
/**
* @internal
*/
final class ValidatesEnvironment
{
/**
* The need files on the root path.
*
* @var array<int, string>
*/
private const NEEDED_FILES = [
'composer.json',
'tests',
];
/**
* Validates the environment.
*/
public static function in(TestSuite $testSuite): void
{
$rootPath = $testSuite->rootPath;
$exists = function ($neededFile) use ($rootPath): bool {
return file_exists(sprintf('%s%s%s', $rootPath, DIRECTORY_SEPARATOR, $neededFile));
};
foreach (self::NEEDED_FILES as $neededFile) {
if (!$exists($neededFile)) {
throw new FileOrFolderNotFound($neededFile);
}
}
}
}

145
src/Concerns/TestCase.php Normal file
View File

@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
namespace Pest\Concerns;
use Closure;
use Pest\Support\ExceptionTrace;
use Pest\TestSuite;
use PHPUnit\Util\Test;
/**
* To avoid inheritance conflicts, all the fields related
* to Pest only will be prefixed by double underscore.
*
* @internal
*/
trait TestCase
{
/**
* The test case description. Contains the first
* argument of global functions like `it` and `test`.
*
* @var string
*/
private $__description;
/**
* Holds the test closure function.
*
* @var Closure
*/
private $__test;
/**
* Creates a new instance of the test case.
*/
public function __construct(Closure $test, string $description, array $data)
{
$this->__test = $test;
$this->__description = $description;
parent::__construct('__test', $data);
}
/**
* Adds the groups to the current test case.
*/
public function addGroups(array $groups): void
{
$groups = array_unique(array_merge($this->getGroups(), $groups));
$this->setGroups($groups);
}
/**
* Returns the test case name. Note that, in Pest
* we ignore withDataset argument as the description
* already contains the dataset description.
*/
public function getName(bool $withDataSet = true): string
{
return $this->__description;
}
/**
* This method is called before the first test of this test class is run.
*/
public static function setUpBeforeClass(): void
{
parent::setUpBeforeClass();
$beforeAll = TestSuite::getInstance()->beforeAll->get(self::$__filename);
call_user_func(Closure::bind($beforeAll, null, self::class));
}
/**
* This method is called after the last test of this test class is run.
*/
public static function tearDownAfterClass(): void
{
$afterAll = TestSuite::getInstance()->afterAll->get(self::$__filename);
call_user_func(Closure::bind($afterAll, null, self::class));
parent::tearDownAfterClass();
}
/**
* Gets executed before the test.
*/
protected function setUp(): void
{
parent::setUp();
$beforeEach = TestSuite::getInstance()->beforeEach->get(self::$__filename);
$this->__callClosure($beforeEach, func_get_args());
}
/**
* Gets executed after the test.
*/
protected function tearDown(): void
{
$afterEach = TestSuite::getInstance()->afterEach->get(self::$__filename);
$this->__callClosure($afterEach, func_get_args());
parent::tearDown();
}
/**
* Returns the test case as string.
*/
public function toString(): string
{
return \sprintf(
'%s::%s',
self::$__filename,
$this->__description
);
}
/**
* Runs the test.
*/
public function __test(): void
{
$this->__callClosure($this->__test, func_get_args());
}
private function __callClosure(Closure $closure, array $arguments): void
{
ExceptionTrace::ensure(function () use ($closure, $arguments) {
call_user_func_array(Closure::bind($closure, $this, get_class($this)), $arguments);
});
}
public function getPrintableTestCaseName(): string
{
return ltrim(self::class, 'P\\');
}
}

143
src/Console/Command.php Normal file
View File

@ -0,0 +1,143 @@
<?php
declare(strict_types=1);
namespace Pest\Console;
use Pest\Actions\AddsCoverage;
use Pest\Actions\AddsDefaults;
use Pest\Actions\AddsTests;
use Pest\Actions\LoadStructure;
use Pest\Actions\ValidatesConfiguration;
use Pest\Exceptions\CodeCoverageDriverNotAvailable;
use Pest\TestSuite;
use PHPUnit\Framework\TestSuite as BaseTestSuite;
use PHPUnit\TextUI\Command as BaseCommand;
use PHPUnit\TextUI\TestRunner;
use SebastianBergmann\FileIterator\Facade as FileIteratorFacade;
use Symfony\Component\Console\Output\OutputInterface;
/**
* @internal
*/
final class Command extends BaseCommand
{
/**
* Holds the current testing suite.
*
* @var TestSuite
*/
private $testSuite;
/**
* Holds the current console output.
*
* @var OutputInterface
*/
private $output;
/**
* Creates a new instance of the command class.
*/
public function __construct(TestSuite $testSuite, OutputInterface $output)
{
$this->testSuite = $testSuite;
$this->output = $output;
}
/**
* {@inheritdoc}
*
* @phpstan-ignore-next-line
*
* @param array<int, string> $argv
*/
protected function handleArguments(array $argv): void
{
/*
* First, let's handle pest is own `--coverage` param.
*/
$argv = AddsCoverage::from($this->testSuite, $argv);
/*
* Next, as usual, let's send the console arguments to PHPUnit.
*/
parent::handleArguments($argv);
/*
* Finally, let's validate the configuration. Making
* sure all options are yet supported by Pest.
*/
ValidatesConfiguration::in($this->arguments);
}
/**
* Creates a new PHPUnit test runner.
*/
protected function createRunner(): TestRunner
{
/*
* First, let's add the defaults we use on `pest`. Those
* are the printer class, and others that may be appear.
*/
$this->arguments = AddsDefaults::to($this->arguments);
$testRunner = new TestRunner($this->arguments['loader']);
$testSuite = $this->arguments['test'];
if (is_string($testSuite)) {
if (\is_dir($testSuite)) {
/** @var string[] $files */
$files = (new FileIteratorFacade())->getFilesAsArray(
$testSuite,
$this->arguments['testSuffixes']
);
} else {
$files = [$testSuite];
}
$testSuite = new BaseTestSuite($testSuite);
$testSuite->addTestFiles($files);
$this->arguments['test'] = $testSuite;
}
LoadStructure::in($this->testSuite->rootPath);
AddsTests::to($testSuite, $this->testSuite);
return $testRunner;
}
/**
* {@inheritdoc}
*
* @phpstan-ignore-next-line
*
* @param array<int, string> $argv
*/
public function run(array $argv, bool $exit = true): int
{
$result = parent::run($argv, false);
if ($result === 0 && $this->testSuite->coverage) {
if (!Coverage::isAvailable()) {
throw new CodeCoverageDriverNotAvailable();
}
$coverage = Coverage::report($this->output);
$result = (int) ($coverage < $this->testSuite->coverageMin);
if ($result === 1) {
$this->output->writeln(sprintf(
"\n <fg=white;bg=red;options=bold> FAIL </> Code coverage below expected:<fg=red;options=bold> %s %%</>. Minimum:<fg=white;options=bold> %s %%</>.",
number_format($coverage, 1),
number_format($this->testSuite->coverageMin, 1)
));
}
}
exit($result);
}
}

167
src/Console/Coverage.php Normal file
View File

@ -0,0 +1,167 @@
<?php
declare(strict_types=1);
namespace Pest\Console;
use Pest\Exceptions\ShouldNotHappen;
use SebastianBergmann\CodeCoverage\Node\Directory;
use SebastianBergmann\CodeCoverage\Node\File;
use SebastianBergmann\Environment\Runtime;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Terminal;
/**
* @internal
*/
final class Coverage
{
/**
* Returns the coverage path.
*/
public static function getPath(): string
{
return implode(DIRECTORY_SEPARATOR, [
dirname(__DIR__, 2),
'.temp',
'coverage.php',
]);
}
/**
* Runs true there is any code
* coverage driver available.
*/
public static function isAvailable(): bool
{
return (new Runtime())->canCollectCodeCoverage();
}
/**
* Reports the code coverage report to the
* console and returns the result in float.
*/
public static function report(OutputInterface $output): float
{
if (!file_exists($reportPath = self::getPath())) {
throw ShouldNotHappen::fromMessage(sprintf('Coverage not found in path: %s.', $reportPath));
}
/** @var \SebastianBergmann\CodeCoverage\CodeCoverage $codeCoverage */
$codeCoverage = require $reportPath;
unlink($reportPath);
$totalWidth = (new Terminal())->getWidth();
$dottedLineLength = $totalWidth <= 70 ? $totalWidth : 70;
$totalCoverage = $codeCoverage->getReport()->getLineExecutedPercent();
$output->writeln(
sprintf(
' <fg=white;options=bold>Cov: </><fg=default>%s</>',
$totalCoverage
)
);
$output->writeln('');
/** @var Directory<File|Directory> $report */
$report = $codeCoverage->getReport();
foreach ($report->getIterator() as $file) {
if (!$file instanceof File) {
continue;
}
$dirname = dirname($file->getId());
$basename = basename($file->getId(), '.php');
$name = $dirname === '.' ? $basename : implode(DIRECTORY_SEPARATOR, [
$dirname,
$basename,
]);
$rawName = $dirname === '.' ? $basename : implode(DIRECTORY_SEPARATOR, [
$dirname,
$basename,
]);
$linesExecutedTakenSize = 0;
if ($file->getLineExecutedPercent() != '0.00%') {
$linesExecutedTakenSize = strlen($uncoveredLines = trim(implode(', ', self::getMissingCoverage($file)))) + 1;
$name .= sprintf(' <fg=red>%s</>', $uncoveredLines);
}
$percentage = $file->getNumExecutableLines() === 0
? '100.0'
: number_format((float) $file->getLineExecutedPercent(), 1, '.', '');
$takenSize = strlen($rawName . $percentage) + 4 + $linesExecutedTakenSize; // adding 3 space and percent sign
$percentage = sprintf(
'<fg=%s>%s</>',
$percentage === '100.0' ? 'green' : ($percentage === '0.0' ? 'red' : 'yellow'),
$percentage
);
$output->writeln(sprintf(' %s %s %s %%',
$name,
str_repeat('.', max($dottedLineLength - $takenSize, 1)),
$percentage
));
}
return (float) $totalCoverage;
}
/**
* Generates an array of missing coverage on the following format:.
*
* ```
* ['11', '20..25', '50', '60...80'];
* ```
*
* @param File $file
*
* @return array<int, string>
*/
public static function getMissingCoverage($file): array
{
$shouldBeNewLine = true;
$eachLine = function (array $array, array $tests, int $line) use (&$shouldBeNewLine): array {
if (count($tests) > 0) {
$shouldBeNewLine = true;
return $array;
}
if ($shouldBeNewLine) {
$array[] = (string) $line;
$shouldBeNewLine = false;
return $array;
}
$lastKey = count($array) - 1;
if (array_key_exists($lastKey, $array) && strpos($array[$lastKey], '..') !== false) {
[$from] = explode('..', $array[$lastKey]);
$array[$lastKey] = sprintf('%s..%s', $from, $line);
return $array;
}
$array[$lastKey] = sprintf('%s..%s', $array[$lastKey], $line);
return $array;
};
$array = [];
foreach (array_filter($file->getCoverageData(), 'is_array') as $line => $tests) {
$array = $eachLine($array, $tests, $line);
}
return $array;
}
}

View File

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Pest\Contracts;
if (interface_exists(\NunoMaduro\Collision\Contracts\Adapters\Phpunit\HasPrintableTestCaseName::class)) {
/**
* @internal
*/
interface HasPrintableTestCaseName extends \NunoMaduro\Collision\Contracts\Adapters\Phpunit\HasPrintableTestCaseName
{
}
} else {
/**
* @internal
*/
interface HasPrintableTestCaseName
{
}
}

97
src/Datasets.php Normal file
View File

@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
namespace Pest;
use Closure;
use Pest\Exceptions\DatasetAlreadyExist;
use Pest\Exceptions\DatasetDoesNotExist;
use SebastianBergmann\Exporter\Exporter;
use Traversable;
/**
* @internal
*/
final class Datasets
{
/**
* Holds the datasets.
*
* @var array<string, \Closure|iterable<int, mixed>>
*/
private static $datasets = [];
/**
* Sets the given.
*
* @param Closure|iterable<int, mixed> $data
*/
public static function set(string $name, $data): void
{
if (array_key_exists($name, self::$datasets)) {
throw new DatasetAlreadyExist($name);
}
self::$datasets[$name] = $data;
}
/**
* @return Closure|iterable<int, mixed>
*/
public static function get(string $name)
{
if (!array_key_exists($name, self::$datasets)) {
throw new DatasetDoesNotExist($name);
}
return self::$datasets[$name];
}
/**
* Resolves the current dataset to an array value.
*
* @param Traversable<int, mixed>|Closure|iterable<int, mixed>|string|null $data
*
* @return array<string, mixed>
*/
public static function resolve(string $description, $data): array
{
/* @phpstan-ignore-next-line */
if (is_null($data) || empty($data)) {
return [$description => []];
}
if (is_string($data)) {
$data = self::get($data);
}
if (is_callable($data)) {
$data = call_user_func($data);
}
if ($data instanceof Traversable) {
$data = iterator_to_array($data);
}
$namedData = [];
foreach ($data as $values) {
$values = is_array($values) ? $values : [$values];
$name = $description . self::getDataSetDescription($values);
$namedData[$name] = $values;
}
return $namedData;
}
/**
* @param array<int, mixed> $data
*/
private static function getDataSetDescription(array $data): string
{
$exporter = new Exporter();
return \sprintf(' with (%s)', $exporter->shortenedRecursiveExport($data));
}
}

View File

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Pest\Exceptions;
use InvalidArgumentException;
use NunoMaduro\Collision\Contracts\RenderlessEditor;
use NunoMaduro\Collision\Contracts\RenderlessTrace;
use Symfony\Component\Console\Exception\ExceptionInterface;
/**
* @internal
*/
final class AfterAllAlreadyExist extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace
{
/**
* Creates a new instance of after all already exist exception.
*/
public function __construct(string $filename)
{
parent::__construct(sprintf('The afterAll already exist in the filename `%s`.', $filename));
}
}

View File

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Pest\Exceptions;
use InvalidArgumentException;
use NunoMaduro\Collision\Contracts\RenderlessEditor;
use NunoMaduro\Collision\Contracts\RenderlessTrace;
use Symfony\Component\Console\Exception\ExceptionInterface;
/**
* @internal
*/
final class AfterEachAlreadyExist extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace
{
/**
* Creates a new instance of after each already exist exception.
*/
public function __construct(string $filename)
{
parent::__construct(sprintf('The afterEach already exist in the filename `%s`.', $filename));
}
}

View File

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Pest\Exceptions;
use InvalidArgumentException;
use NunoMaduro\Collision\Contracts\RenderlessEditor;
use NunoMaduro\Collision\Contracts\RenderlessTrace;
use Symfony\Component\Console\Exception\ExceptionInterface;
/**
* @internal
*/
final class AttributeNotSupportedYet extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace
{
/**
* Creates a new instance of attribute not supported yet.
*/
public function __construct(string $attribute, string $value)
{
parent::__construct(sprintf('The PHPUnit attribute `%s` with value `%s` is not supported yet.', $attribute, $value));
}
}

View File

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Pest\Exceptions;
use InvalidArgumentException;
use NunoMaduro\Collision\Contracts\RenderlessEditor;
use NunoMaduro\Collision\Contracts\RenderlessTrace;
use Symfony\Component\Console\Exception\ExceptionInterface;
/**
* @internal
*/
final class BeforeEachAlreadyExist extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace
{
/**
* Creates a new instance of before each already exist exception.
*/
public function __construct(string $filename)
{
parent::__construct(sprintf('The beforeEach already exist in the filename `%s`.', $filename));
}
}

View File

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Pest\Exceptions;
use InvalidArgumentException;
use NunoMaduro\Collision\Contracts\RenderlessEditor;
use NunoMaduro\Collision\Contracts\RenderlessTrace;
use Symfony\Component\Console\Exception\ExceptionInterface;
/**
* @internal
*/
final class CodeCoverageDriverNotAvailable extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace
{
/**
* Creates a new instance of test already exist.
*/
public function __construct()
{
parent::__construct('No code coverage driver is available');
}
}

View File

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Pest\Exceptions;
use InvalidArgumentException;
use NunoMaduro\Collision\Contracts\RenderlessEditor;
use NunoMaduro\Collision\Contracts\RenderlessTrace;
use Symfony\Component\Console\Exception\ExceptionInterface;
/**
* @internal
*/
final class DatasetAlreadyExist extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace
{
/**
* Creates a new instance of dataset already exist.
*/
public function __construct(string $name)
{
parent::__construct(sprintf('A dataset with the name `%s` already exist.', $name));
}
}

View File

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Pest\Exceptions;
use InvalidArgumentException;
use NunoMaduro\Collision\Contracts\RenderlessEditor;
use NunoMaduro\Collision\Contracts\RenderlessTrace;
use Symfony\Component\Console\Exception\ExceptionInterface;
/**
* @internal
*/
final class DatasetDoesNotExist extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace
{
/**
* Creates a new instance of dataset does not exist.
*/
public function __construct(string $name)
{
parent::__construct(sprintf("A dataset with the name `%s` does not exist. You can create it using `dataset('%s', ['a', 'b']);`.", $name, $name));
}
}

View File

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Pest\Exceptions;
use InvalidArgumentException;
use NunoMaduro\Collision\Contracts\RenderlessEditor;
use NunoMaduro\Collision\Contracts\RenderlessTrace;
use Symfony\Component\Console\Exception\ExceptionInterface;
/**
* @internal
*/
final class FileOrFolderNotFound extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace
{
/**
* Creates a new instance of file not found.
*/
public function __construct(string $filename)
{
parent::__construct(sprintf('The file or folder with the name `%s` not found.', $filename));
}
}

View File

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Pest\Exceptions;
use InvalidArgumentException;
use NunoMaduro\Collision\Contracts\RenderlessEditor;
use NunoMaduro\Collision\Contracts\RenderlessTrace;
use Symfony\Component\Console\Exception\ExceptionInterface;
/**
* @internal
*/
final class InvalidConsoleArgument extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace
{
/**
* Creates a new instance of should not happen.
*/
public function __construct(string $message)
{
parent::__construct($message, 1);
}
}

View File

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Pest\Exceptions;
use InvalidArgumentException;
use NunoMaduro\Collision\Contracts\RenderlessEditor;
use NunoMaduro\Collision\Contracts\RenderlessTrace;
use Symfony\Component\Console\Exception\ExceptionInterface;
/**
* @internal
*/
final class InvalidPestCommand extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace
{
/**
* Creates a new instance of invalid pest command exception.
*/
public function __construct()
{
parent::__construct('Please run `./vendor/bin/pest` instead of `/vendor/bin/phpunit`.');
}
}

View File

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Pest\Exceptions;
use InvalidArgumentException;
use NunoMaduro\Collision\Contracts\RenderlessEditor;
use NunoMaduro\Collision\Contracts\RenderlessTrace;
use Symfony\Component\Console\Exception\ExceptionInterface;
/**
* @internal
*/
final class InvalidUsesPath extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace
{
/**
* Creates a new instance of invalid uses path.
*/
public function __construct(string $target)
{
parent::__construct(sprintf('The path `%s` is not valid.', $target));
}
}

View File

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace Pest\Exceptions;
use Exception;
use RuntimeException;
/**
* @internal
*/
final class ShouldNotHappen extends RuntimeException
{
/**
* Creates a new instance of should not happen.
*/
public function __construct(Exception $exception)
{
$message = $exception->getMessage();
parent::__construct(sprintf(<<<EOF
This should not happen - please create an new issue here: https://github.com/pestphp/pest.
- Issue: %s
- PHP version: %s
- Operating system: %s
EOF
, $message, phpversion(), PHP_OS), 1, $exception);
}
/**
* Creates a new instance of should not happen without a specific exception.
*/
public static function fromMessage(string $message): ShouldNotHappen
{
return new ShouldNotHappen(new Exception($message));
}
}

View File

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Pest\Exceptions;
use InvalidArgumentException;
use NunoMaduro\Collision\Contracts\RenderlessEditor;
use NunoMaduro\Collision\Contracts\RenderlessTrace;
use Symfony\Component\Console\Exception\ExceptionInterface;
/**
* @internal
*/
final class TestAlreadyExist extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace
{
/**
* Creates a new instance of test already exist.
*/
public function __construct(string $fileName, string $description)
{
parent::__construct(sprintf('A test with the description `%s` already exist in the filename `%s`.', $description, $fileName));
}
}

View File

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace Pest\Exceptions;
use InvalidArgumentException;
use NunoMaduro\Collision\Contracts\RenderlessEditor;
use NunoMaduro\Collision\Contracts\RenderlessTrace;
use Symfony\Component\Console\Exception\ExceptionInterface;
/**
* @internal
*/
final class TestCaseAlreadyInUse extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace
{
/**
* Creates a new instance of test case already in use.
*/
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));
}
}

View File

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Pest\Exceptions;
use InvalidArgumentException;
use NunoMaduro\Collision\Contracts\RenderlessEditor;
use NunoMaduro\Collision\Contracts\RenderlessTrace;
use Symfony\Component\Console\Exception\ExceptionInterface;
/**
* @internal
*/
final class TestCaseClassOrTraitNotFound extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace
{
/**
* Creates a new instance of after each already exist exception.
*/
public function __construct(string $testCaseClass)
{
parent::__construct(sprintf('The class `%s` was not found.', $testCaseClass));
}
}

View File

@ -0,0 +1,191 @@
<?php
declare(strict_types=1);
namespace Pest\Factories;
use Closure;
use Pest\Concerns;
use Pest\Contracts\HasPrintableTestCaseName;
use Pest\Datasets;
use Pest\Support\HigherOrderMessageCollection;
use Pest\Support\NullClosure;
use Pest\TestSuite;
use PHPUnit\Framework\TestCase;
/**
* @internal
*/
final class TestCaseFactory
{
/**
* Holds the test filename.
*
* @readonly
*
* @var string
*/
public $filename;
/**
* Marks this test case as only.
*
* @readonly
*
* @var bool
*/
public $only = false;
/**
* Holds the test description.
*
* @readonly
*
* @var string
*/
public $description;
/**
* Holds the test closure.
*
* @readonly
*
* @var Closure
*/
public $test;
/**
* Holds the dataset, if any.
*
* @var Closure|iterable<int, mixed>|string|null
*/
public $dataset;
/**
* The FQN of the test case class.
*
* @var string
*/
public $class = TestCase::class;
/**
* An array of FQN of the class traits.
*
* @var array <int, string>
*/
public $traits = [
Concerns\TestCase::class,
];
/**
* Holds the higher order messages
* for the factory that are proxyble.
*
* @var HigherOrderMessageCollection
*/
public $factoryProxies;
/**
* Holds the higher order
* messages that are proxyble.
*
* @var HigherOrderMessageCollection
*/
public $proxies;
/**
* Holds the higher order
* messages that are chainable.
*
* @var HigherOrderMessageCollection
*/
public $chains;
/**
* Creates a new anonymous test case pending object.
*/
public function __construct(string $filename, string $description, Closure $closure = null)
{
$this->filename = $filename;
$this->description = $description;
$this->test = $closure ?? NullClosure::create();
$this->factoryProxies = new HigherOrderMessageCollection();
$this->proxies = new HigherOrderMessageCollection();
$this->chains = new HigherOrderMessageCollection();
}
/**
* Builds the anonymous test case.
*
* @return array<int, TestCase>
*/
public function build(TestSuite $testSuite): array
{
$chains = $this->chains;
$proxies = $this->proxies;
$factoryTest = $this->test;
$test = function () use ($chains, $proxies, $factoryTest): void {
$proxies->proxy($this);
$chains->chain($this);
call_user_func(Closure::bind($factoryTest, $this, get_class($this)), ...func_get_args());
};
$className = $this->makeClassFromFilename($this->filename);
$createTest = function ($description, $data) use ($className, $test) {
$testCase = new $className($test, $description, $data);
$this->factoryProxies->proxy($testCase);
return $testCase;
};
$datasets = Datasets::resolve($this->description, $this->dataset);
return array_map($createTest, array_keys($datasets), $datasets);
}
/**
* Makes a fully qualified class name
* from the given filename.
*/
public function makeClassFromFilename(string $filename): string
{
$rootPath = TestSuite::getInstance()->rootPath;
$relativePath = str_replace($rootPath . DIRECTORY_SEPARATOR, '', $filename);
// Strip out any %-encoded octets.
$relativePath = (string) preg_replace('|%[a-fA-F0-9][a-fA-F0-9]|', '', $relativePath);
// Limit to A-Z, a-z, 0-9, '_', '-'.
$relativePath = (string) preg_replace('/[^A-Za-z0-9.\/]/', '', $relativePath);
$classFQN = 'P\\' . basename(ucfirst(str_replace(DIRECTORY_SEPARATOR, '\\', $relativePath)), '.php');
if (class_exists($classFQN)) {
return $classFQN;
}
$hasPrintableTestCaseClassFQN = sprintf('\%s', HasPrintableTestCaseName::class);
$traitsCode = sprintf('use %s;', implode(', ', array_map(function ($trait): string {
return sprintf('\%s', $trait);
}, $this->traits)));
$partsFQN = explode('\\', $classFQN);
$className = array_pop($partsFQN);
$namespace = implode('\\', $partsFQN);
$baseClass = sprintf('\%s', $this->class);
eval("
namespace $namespace;
final class $className extends $baseClass implements $hasPrintableTestCaseClassFQN {
$traitsCode
private static \$__filename = '$filename';
}
");
return $classFQN;
}
}

View File

@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace Pest\Laravel\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
use Pest\Exceptions\InvalidConsoleArgument;
/**
* @internal
*/
final class PestDatasetCommand extends Command
{
/**
* The console command name.
*
* @var string
*/
protected $signature = 'pest:dataset {name : The name of the dataset}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Create a new dataset file';
/**
* Execute the console command.
*/
public function handle(): void
{
/** @var string $name */
$name = $this->argument('name');
$relativePath = sprintf('tests/Datasets/%s.php', ucfirst($name));
/* @phpstan-ignore-next-line */
$target = base_path($relativePath);
if (File::exists($target)) {
throw new InvalidConsoleArgument(sprintf('%s already exist', $target));
}
if (!File::exists(dirname($relativePath))) {
File::makeDirectory(dirname($relativePath));
}
$contents = File::get(implode(DIRECTORY_SEPARATOR, [
dirname(__DIR__, 3),
'stubs',
'Dataset.php',
]));
$name = mb_strtolower($name);
$contents = str_replace('{dataset_name}', $name, $contents);
$element = Str::singular($name);
$contents = str_replace('{dataset_element}', $element, $contents);
File::put($target, str_replace('{dataset_name}', $name, $contents));
$this->output->success(sprintf('`%s` created successfully.', $relativePath));
}
}

View File

@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace Pest\Laravel\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
use Pest\Exceptions\InvalidConsoleArgument;
/**
* @internal
*/
final class PestInstallCommand extends Command
{
/**
* The console command name.
*
* @var string
*/
protected $signature = 'pest:install';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Creates Pest resources in your current PHPUnit test suite';
/**
* Execute the console command.
*/
public function handle(): void
{
/* @phpstan-ignore-next-line */
$target = base_path('tests/Pest.php');
if (File::exists($target)) {
throw new InvalidConsoleArgument(sprintf('%s already exist', $target));
}
File::copy(implode(DIRECTORY_SEPARATOR, [
dirname(__DIR__, 3),
'stubs',
'Pest.php',
]), $target);
$this->output->success('`tests/Pest.php` created successfully.');
}
}

View File

@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace Pest\Laravel\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
use Pest\Exceptions\InvalidConsoleArgument;
use Pest\Support\Str;
/**
* @internal
*/
final class PestTestCommand extends Command
{
/**
* The console command name.
*
* @var string
*/
protected $signature = 'pest:test {name : The name of the file} {--unit : Create a unit test}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Create a new test file';
/**
* Execute the console command.
*/
public function handle(): void
{
/** @var string $name */
$name = $this->argument('name');
$type = ((bool) $this->option('unit')) ? 'Unit' : 'Feature';
$relativePath = sprintf('tests/%s/%s.php',
$type,
ucfirst($name)
);
/* @phpstan-ignore-next-line */
$target = base_path($relativePath);
if (!File::isDirectory(dirname($target))) {
File::makeDirectory(dirname($target), 0777, true, true);
}
if (File::exists($target)) {
throw new InvalidConsoleArgument(sprintf('%s already exist', $target));
}
$contents = File::get(implode(DIRECTORY_SEPARATOR, [
dirname(__DIR__, 3),
'stubs',
sprintf('%s.php', $type),
]));
$name = mb_strtolower($name);
$name = Str::endsWith($name, 'test') ? mb_substr($name, 0, -4) : $name;
File::put($target, str_replace('{name}', $name, $contents));
$this->output->success(sprintf('`%s` created successfully.', $relativePath));
}
}

View File

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace Pest\Laravel;
use Illuminate\Support\ServiceProvider;
use Pest\Laravel\Commands\PestDatasetCommand;
use Pest\Laravel\Commands\PestInstallCommand;
use Pest\Laravel\Commands\PestTestCommand;
final class PestServiceProvider extends ServiceProvider
{
/**
* Register artisan commands.
*/
public function register(): void
{
if ($this->app->runningInConsole()) {
$this->commands([
PestInstallCommand::class,
PestTestCommand::class,
PestDatasetCommand::class,
]);
}
}
}

View File

@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace Pest\PendingObjects;
use Closure;
use Pest\Support\Backtrace;
use Pest\Support\ChainableClosure;
use Pest\Support\HigherOrderMessageCollection;
use Pest\Support\NullClosure;
use Pest\TestSuite;
/**
* @internal
*/
final class AfterEachCall
{
/**
* Holds the test suite.
*
* @var TestSuite
*/
private $testSuite;
/**
* Holds the filename.
*
* @var string
*/
private $filename;
/**
* Holds the before each closure.
*
* @var Closure
*/
private $closure;
/**
* Holds calls that should be proxied.
*
* @var HigherOrderMessageCollection
*/
private $proxies;
/**
* Creates a new instance of before each call.
*/
public function __construct(TestSuite $testSuite, string $filename, Closure $closure = null)
{
$this->testSuite = $testSuite;
$this->filename = $filename;
$this->closure = $closure instanceof Closure ? $closure : NullClosure::create();
$this->proxies = new HigherOrderMessageCollection();
}
/**
* Dispatch the creation of each call.
*/
public function __destruct()
{
$proxies = $this->proxies;
$this->testSuite->afterEach->set(
$this->filename,
ChainableClosure::from(function () use ($proxies): void {
$proxies->chain($this);
}, $this->closure)
);
}
/**
* Saves the calls to be used on the target.
*
* @param array<int, mixed> $arguments
*/
public function __call(string $name, array $arguments): self
{
$this->proxies
->add(Backtrace::file(), Backtrace::line(), $name, $arguments);
return $this;
}
}

View File

@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace Pest\PendingObjects;
use Closure;
use Pest\Support\Backtrace;
use Pest\Support\ChainableClosure;
use Pest\Support\HigherOrderMessageCollection;
use Pest\Support\NullClosure;
use Pest\TestSuite;
/**
* @internal
*/
final class BeforeEachCall
{
/**
* Holds the test suite.
*
* @var TestSuite
*/
private $testSuite;
/**
* Holds the filename.
*
* @var string
*/
private $filename;
/**
* Holds the before each closure.
*
* @var Closure
*/
private $closure;
/**
* Holds calls that should be proxied.
*
* @var HigherOrderMessageCollection
*/
private $proxies;
/**
* Creates a new instance of before each call.
*/
public function __construct(TestSuite $testSuite, string $filename, Closure $closure = null)
{
$this->testSuite = $testSuite;
$this->filename = $filename;
$this->closure = $closure instanceof Closure ? $closure : NullClosure::create();
$this->proxies = new HigherOrderMessageCollection();
}
/**
* Dispatch the creation of each call.
*/
public function __destruct()
{
$proxies = $this->proxies;
$this->testSuite->beforeEach->set(
$this->filename,
ChainableClosure::from(function () use ($proxies): void {
$proxies->chain($this);
}, $this->closure)
);
}
/**
* Saves the calls to be used on the target.
*
* @param array<int, mixed> $arguments
*/
public function __call(string $name, array $arguments): self
{
$this->proxies
->add(Backtrace::file(), Backtrace::line(), $name, $arguments);
return $this;
}
}

View File

@ -0,0 +1,133 @@
<?php
declare(strict_types=1);
namespace Pest\PendingObjects;
use Closure;
use Pest\Factories\TestCaseFactory;
use Pest\Support\Backtrace;
use Pest\Support\NullClosure;
use Pest\TestSuite;
/**
* @internal
*/
final class TestCall
{
/**
* Holds the test case factory.
*
* @readonly
*
* @var TestCaseFactory
*/
private $testCaseFactory;
/**
* Creates a new instance of a pending test call.
*/
public function __construct(TestSuite $testSuite, string $filename, string $description, Closure $closure = null)
{
$this->testCaseFactory = new TestCaseFactory($filename, $description, $closure);
$testSuite->tests->set($this->testCaseFactory);
}
/**
* Asserts that the test throws the given `$exceptionClass` when called.
*/
public function throws(string $exceptionClass, string $exceptionMessage = null): TestCall
{
$this->testCaseFactory
->proxies
->add(Backtrace::file(), Backtrace::line(), 'expectException', [$exceptionClass]);
if (is_string($exceptionMessage)) {
$this->testCaseFactory
->proxies
->add(Backtrace::file(), Backtrace::line(), 'expectExceptionMessage', [$exceptionMessage]);
}
return $this;
}
/**
* Runs the current test multiple times with
* each item of the given `iterable`.
*
* @param \Closure|iterable<int, mixed>|string $data
*/
public function with($data): TestCall
{
$this->testCaseFactory->dataset = $data;
return $this;
}
/**
* Makes the test suite only this test case.
*/
public function only(): TestCall
{
$this->testCaseFactory->only = true;
return $this;
}
/**
* Sets the test groups(s).
*/
public function group(string ...$groups): TestCall
{
$this->testCaseFactory
->factoryProxies
->add(Backtrace::file(), Backtrace::line(), 'addGroups', [$groups]);
return $this;
}
/**
* Skips the current test.
*
* @param Closure|bool|string $conditionOrMessage
*/
public function skip($conditionOrMessage = true, string $message = ''): TestCall
{
$condition = is_string($conditionOrMessage)
? NullClosure::create()
: $conditionOrMessage;
$condition = is_callable($condition)
? $condition
: function () use ($condition) { /* @phpstan-ignore-line */
return $condition;
};
$message = is_string($conditionOrMessage)
? $conditionOrMessage
: $message;
if ($condition() !== false) {
$this->testCaseFactory
->chains
->add(Backtrace::file(), Backtrace::line(), 'markTestSkipped', [$message]);
}
return $this;
}
/**
* Saves the calls to be used on the target.
*
* @param array<int, mixed> $arguments
*/
public function __call(string $name, array $arguments): self
{
$this->testCaseFactory
->chains
->add(Backtrace::file(), Backtrace::line(), $name, $arguments);
return $this;
}
}

View File

@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
namespace Pest\PendingObjects;
use Pest\Exceptions\InvalidUsesPath;
use Pest\TestSuite;
/**
* @internal
*/
final class UsesCall
{
/**
* Holds the class and traits.
*
* @var array<int, string>
*/
private $classAndTraits;
/**
* Holds the base dirname here the uses call was performed.
*
* @var string
*/
private $filename;
/**
* Holds the targets of the uses.
*
* @var array<int, string>
*/
private $targets;
/**
* Holds the groups of the uses.
*
* @var array<int, string>
*/
private $groups = [];
/**
* Creates a new instance of a pending test uses.
*
* @param array<int, string> $classAndTraits
*/
public function __construct(string $filename, array $classAndTraits)
{
$this->classAndTraits = $classAndTraits;
$this->filename = $filename;
$this->targets = [$filename];
}
/**
* The directories or file where the
* class or trais should be used.
*/
public function in(string ...$targets): void
{
$targets = array_map(function ($path): string {
return $path[0] === DIRECTORY_SEPARATOR
? $path
: implode(DIRECTORY_SEPARATOR, [
dirname($this->filename),
$path,
]);
}, $targets);
$this->targets = array_map(function ($target): string {
$realTarget = realpath($target);
if ($realTarget === false) {
throw new InvalidUsesPath($target);
}
return $realTarget;
}, $targets);
}
/**
* Sets the test group(s).
*/
public function group(string ...$groups): UsesCall
{
$this->groups = $groups;
return $this;
}
/**
* Dispatch the creation of uses.
*/
public function __destruct()
{
TestSuite::getInstance()->tests->use($this->classAndTraits, $this->groups, $this->targets);
}
}

View File

@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace Pest\Repositories;
use Closure;
use Pest\Exceptions\AfterAllAlreadyExist;
use Pest\Support\NullClosure;
use Pest\Support\Reflection;
/**
* @internal
*/
final class AfterAllRepository
{
/**
* @var array<string, Closure>
*/
private $state = [];
/**
* Runs the given closure for each after all.
*/
public function each(callable $each): void
{
foreach ($this->state as $filename => $closure) {
$each($filename, $closure);
}
}
/**
* Sets a after all closure.
*/
public function set(Closure $closure): void
{
$filename = Reflection::getFileNameFromClosure($closure);
if (array_key_exists($filename, $this->state)) {
throw new AfterAllAlreadyExist($filename);
}
$this->state[$filename] = $closure;
}
/**
* Gets a after all closure by the given filename.
*/
public function get(string $filename): Closure
{
return $this->state[$filename] ?? NullClosure::create();
}
}

View File

@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace Pest\Repositories;
use Closure;
use Mockery;
use Pest\Exceptions\AfterEachAlreadyExist;
use Pest\Support\ChainableClosure;
use Pest\Support\NullClosure;
/**
* @internal
*/
final class AfterEachRepository
{
/**
* @var array<string, Closure>
*/
private $state = [];
/**
* Sets a after each closure.
*/
public function set(string $filename, Closure $closure): void
{
if (array_key_exists($filename, $this->state)) {
throw new AfterEachAlreadyExist($filename);
}
$this->state[$filename] = $closure;
}
/**
* Gets a after each closure by the given filename.
*/
public function get(string $filename): Closure
{
$afterEach = $this->state[$filename] ?? NullClosure::create();
return ChainableClosure::from(function (): void {
if (class_exists(Mockery::class)) {
Mockery::close();
}
}, $afterEach);
}
}

View File

@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace Pest\Repositories;
use Closure;
use Pest\Exceptions\BeforeEachAlreadyExist;
use Pest\Support\NullClosure;
use Pest\Support\Reflection;
/**
* @internal
*/
final class BeforeAllRepository
{
/**
* @var array<string, Closure>
*/
private $state = [];
/**
* Runs one before all closure, and unsets it from the repository.
*/
public function pop(string $filename): Closure
{
$closure = $this->get($filename);
unset($this->state[$filename]);
return $closure;
}
/**
* Sets a before all closure.
*/
public function set(Closure $closure): void
{
$filename = Reflection::getFileNameFromClosure($closure);
if (array_key_exists($filename, $this->state)) {
throw new BeforeEachAlreadyExist($filename);
}
$this->state[$filename] = $closure;
}
/**
* Gets a before all closure by the given filename.
*/
public function get(string $filename): Closure
{
return $this->state[$filename] ?? NullClosure::create();
}
}

View File

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace Pest\Repositories;
use Closure;
use Pest\Exceptions\BeforeEachAlreadyExist;
use Pest\Support\NullClosure;
/**
* @internal
*/
final class BeforeEachRepository
{
/**
* @var array<string, Closure>
*/
private $state = [];
/**
* Sets a before each closure.
*/
public function set(string $filename, Closure $closure): void
{
if (array_key_exists($filename, $this->state)) {
throw new BeforeEachAlreadyExist($filename);
}
$this->state[$filename] = $closure;
}
/**
* Gets a before each closure by the given filename.
*/
public function get(string $filename): Closure
{
return $this->state[$filename] ?? NullClosure::create();
}
}

View File

@ -0,0 +1,122 @@
<?php
declare(strict_types=1);
namespace Pest\Repositories;
use Pest\Exceptions\TestAlreadyExist;
use Pest\Exceptions\TestCaseAlreadyInUse;
use Pest\Exceptions\TestCaseClassOrTraitNotFound;
use Pest\Factories\TestCaseFactory;
use Pest\Support\Str;
use Pest\TestSuite;
/**
* @internal
*/
final class TestRepository
{
/**
* @var array<string, TestCaseFactory>
*/
private $state = [];
/**
* @var array<string, array<int, array<int, string>>>
*/
private $uses = [];
/**
* Counts the number of test cases.
*/
public function count(): int
{
return count($this->state);
}
/**
* Calls the given callable foreach test case.
*/
public function build(TestSuite $testSuite, callable $each): void
{
$startsWith = function (string $target, string $directory): bool {
return Str::startsWith($target, $directory . DIRECTORY_SEPARATOR);
};
foreach ($this->uses as $path => $uses) {
[$classOrTraits, $groups] = $uses;
$setClassName = function (TestCaseFactory $testCase, string $key) use ($path, $classOrTraits, $groups, $startsWith): void {
[$filename] = explode('@', $key);
if ((!is_dir($path) && $filename === $path) || (is_dir($path) && $startsWith($filename, $path))) {
foreach ($classOrTraits as $class) {
if (class_exists($class)) {
if ($testCase->class !== \PHPUnit\Framework\TestCase::class) {
throw new TestCaseAlreadyInUse($testCase->class, $class, $filename);
}
$testCase->class = $class;
} elseif (trait_exists($class)) {
$testCase->traits[] = $class;
}
}
$testCase
->factoryProxies
// Consider set the real line here.
->add($filename, 0, 'addGroups', [$groups]);
}
};
foreach ($this->state as $key => $test) {
$setClassName($test, $key);
}
}
$onlyState = array_filter($this->state, function ($testFactory): bool {
return $testFactory->only;
});
$state = count($onlyState) > 0 ? $onlyState : $this->state;
foreach ($state as $testFactory) {
/* @var TestCaseFactory $testFactory */
$tests = $testFactory->build($testSuite);
foreach ($tests as $test) {
$each($test);
}
}
}
/**
* Uses the given `$testCaseClass` on the given `$paths`.
*
* @param array<int, string> $classOrTraits
* @param array<int, string> $groups
* @param array<int, string> $paths
*/
public function use(array $classOrTraits, array $groups, array $paths): void
{
foreach ($classOrTraits as $classOrTrait) {
if (!class_exists($classOrTrait) && !trait_exists($classOrTrait)) {
throw new TestCaseClassOrTraitNotFound($classOrTrait);
}
}
foreach ($paths as $path) {
$this->uses[$path] = [$classOrTraits, $groups];
}
}
/**
* Sets a test case by the given filename and description.
*/
public function set(TestCaseFactory $test): void
{
if (array_key_exists(sprintf('%s@%s', $test->filename, $test->description), $this->state)) {
throw new TestAlreadyExist($test->filename, $test->description);
}
$this->state[sprintf('%s@%s', $test->filename, $test->description)] = $test;
}
}

35
src/Support/Backtrace.php Normal file
View File

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace Pest\Support;
/**
* @internal
*/
final class Backtrace
{
/**
* Returns the filename that called the current function/method.
*/
public static function file(): string
{
return debug_backtrace()[1]['file'];
}
/**
* Returns the dirname that called the current function/method.
*/
public static function dirname(): string
{
return dirname(debug_backtrace()[1]['file']);
}
/**
* Returns the line that called the current function/method.
*/
public static function line(): int
{
return debug_backtrace()[1]['line'];
}
}

View File

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Pest\Support;
use Closure;
/**
* @internal
*/
final class ChainableClosure
{
/**
* Calls the given `$closure` and chains the the `$next` closure.
*/
public static function from(Closure $closure, Closure $next): Closure
{
return function () use ($closure, $next): void {
call_user_func_array(Closure::bind($closure, $this, get_class($this)), func_get_args());
call_user_func_array(Closure::bind($next, $this, get_class($this)), func_get_args());
};
}
}

View File

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace Pest\Support;
use Closure;
use Throwable;
/**
* @internal
*/
final class ExceptionTrace
{
private const UNDEFINED_METHOD = 'Call to undefined method P\\';
/**
* Ensures the given closure reports
* the good execution context.
*/
public static function ensure(Closure $closure): void
{
try {
$closure();
} catch (Throwable $throwable) {
if (Str::startsWith($message = $throwable->getMessage(), self::UNDEFINED_METHOD)) {
$message = str_replace(self::UNDEFINED_METHOD, 'Call to undefined method ', $message);
Reflection::setPropertyValue($throwable, 'message', $message);
}
throw $throwable;
}
}
}

View File

@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace Pest\Support;
/**
* @internal
*/
final class HigherOrderMessage
{
/**
* The filename where the function was originally called.
*
* @readonly
*
* @var string
*/
public $filename;
/**
* The line where the function was originally called.
*
* @readonly
*
* @var int
*/
public $line;
/**
* The method name.
*
* @readonly
*
* @var string
*/
public $methodName;
/**
* The arguments.
*
* @var array<int, mixed>
*
* @readonly
*/
public $arguments;
/**
* Creates a new higher order message.
*
* @param array<int, mixed> $arguments
*/
public function __construct(string $filename, int $line, string $methodName, array $arguments)
{
$this->filename = $filename;
$this->line = $line;
$this->methodName = $methodName;
$this->arguments = $arguments;
}
}

View File

@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace Pest\Support;
use ReflectionClass;
use Throwable;
/**
* @internal
*/
final class HigherOrderMessageCollection
{
public const UNDEFINED_METHOD = 'Method %s does not exist';
/**
* @var array<int, HigherOrderMessage>
*/
private $messages = [];
/**
* Adds a new higher order message to the collection.
*
* @param array<int, mixed> $arguments
*/
public function add(string $filename, int $line, string $methodName, array $arguments): void
{
$this->messages[] = new HigherOrderMessage($filename, $line, $methodName, $arguments);
}
/**
* Proxy all the messages starting from the target.
*/
public function chain(object $target): void
{
foreach ($this->messages as $message) {
$target = $this->attempt($target, $message);
}
}
/**
* Proxy all the messages to the target.
*/
public function proxy(object $target): void
{
foreach ($this->messages as $message) {
$this->attempt($target, $message);
}
}
/**
* Re-throws the given `$throwable` with the good line and filename.
*
* @return mixed
*/
private function attempt(object $target, HigherOrderMessage $message)
{
try {
return Reflection::call($target, $message->methodName, $message->arguments);
} catch (Throwable $throwable) {
Reflection::setPropertyValue($throwable, 'file', $message->filename);
Reflection::setPropertyValue($throwable, 'line', $message->line);
if ($throwable->getMessage() === sprintf(self::UNDEFINED_METHOD, $message->methodName)) {
/** @var \ReflectionClass $reflection */
$reflection = (new ReflectionClass($target))->getParentClass();
Reflection::setPropertyValue($throwable, 'message', sprintf('Call to undefined method %s::%s()', $reflection->getName(), $message->methodName));
}
throw $throwable;
}
}
}

View File

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Pest\Support;
use Closure;
/**
* @internal
*/
final class NullClosure
{
/**
* Creates a nullable closure.
*/
public static function create(): Closure
{
return Closure::fromCallable(function (): void {
});
}
}

112
src/Support/Reflection.php Normal file
View File

@ -0,0 +1,112 @@
<?php
declare(strict_types=1);
namespace Pest\Support;
use Closure;
use Pest\Exceptions\ShouldNotHappen;
use ReflectionClass;
use ReflectionException;
use ReflectionFunction;
use ReflectionProperty;
/**
* @internal
*/
final class Reflection
{
/**
* Calls the given method with args on the given object.
*
* @param array<int, mixed> $args
*
* @return mixed
*/
public static function call(object $object, string $method, array $args = [])
{
$reflectionClass = new ReflectionClass($object);
$reflectionMethod = $reflectionClass->getMethod($method);
$reflectionMethod->setAccessible(true);
return $reflectionMethod->invoke($object, ...$args);
}
/**
* Infers the file name from the given closure.
*/
public static function getFileNameFromClosure(Closure $closure): string
{
$reflectionClosure = new ReflectionFunction($closure);
return (string) $reflectionClosure->getFileName();
}
/**
* Gets the property value from of the given object.
*
* @return mixed
*/
public static function getPropertyValue(object $object, string $property)
{
$reflectionClass = new ReflectionClass($object);
$reflectionProperty = null;
while ($reflectionProperty === null) {
try {
/* @var ReflectionProperty $reflectionProperty */
$reflectionProperty = $reflectionClass->getProperty($property);
} catch (ReflectionException $reflectionException) {
$reflectionClass = $reflectionClass->getParentClass();
if (!$reflectionClass instanceof ReflectionClass) {
throw new ShouldNotHappen($reflectionException);
}
}
}
if ($reflectionProperty === null) {
throw ShouldNotHappen::fromMessage('Reflection property not found.');
}
$reflectionProperty->setAccessible(true);
return $reflectionProperty->getValue($object);
}
/**
* Sets the property value of the given object.
*
* @param mixed $value
*/
public static function setPropertyValue(object $object, string $property, $value): void
{
/** @var ReflectionClass $reflectionClass */
$reflectionClass = new ReflectionClass($object);
$reflectionProperty = null;
while ($reflectionProperty === null) {
try {
/* @var ReflectionProperty $reflectionProperty */
$reflectionProperty = $reflectionClass->getProperty($property);
} catch (ReflectionException $reflectionException) {
$reflectionClass = $reflectionClass->getParentClass();
if (!$reflectionClass instanceof ReflectionClass) {
throw new ShouldNotHappen($reflectionException);
}
}
}
if ($reflectionProperty === null) {
throw ShouldNotHappen::fromMessage('Reflection property not found.');
}
$reflectionProperty->setAccessible(true);
$reflectionProperty->setValue($object, $value);
}
}

32
src/Support/Str.php Normal file
View File

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Pest\Support;
/**
* @internal
*/
final class Str
{
/**
* Checks if the given `$target` starts with the given `$search`.
*/
public static function startsWith(string $target, string $search): bool
{
return substr($target, 0, strlen($search)) === $search;
}
/**
* Checks if the given `$target` ends with the given `$search`.
*/
public static function endsWith(string $target, string $search): bool
{
$length = strlen($search);
if ($length === 0) {
return true;
}
return substr($target, -$length) === $search;
}
}

111
src/TestSuite.php Normal file
View File

@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace Pest;
use Pest\Exceptions\InvalidPestCommand;
use Pest\Repositories\AfterAllRepository;
use Pest\Repositories\AfterEachRepository;
use Pest\Repositories\BeforeAllRepository;
use Pest\Repositories\BeforeEachRepository;
use Pest\Repositories\TestRepository;
/**
* @internal
*/
final class TestSuite
{
/**
* Holds the tests repository.
*
* @var TestRepository
*/
public $tests;
/**
* Whether should show the coverage or not.
*
* @var bool
*/
public $coverage = false;
/**
* The minimum coverage.
*
* @var float
*/
public $coverageMin = 0.0;
/**
* Holds the before each repository.
*
* @var BeforeEachRepository
*/
public $beforeEach;
/**
* Holds the before all repository.
*
* @var BeforeAllRepository
*/
public $beforeAll;
/**
* Holds the after each repository.
*
* @var AfterEachRepository
*/
public $afterEach;
/**
* Holds the after all repository.
*
* @var AfterAllRepository
*/
public $afterAll;
/**
* Holds the root path.
*
* @var string
*/
public $rootPath;
/**
* Holds an instance of the test suite.
*
* @var TestSuite
*/
private static $instance;
/**
* Creates a new instance of the test suite.
*/
public function __construct(string $rootPath)
{
$this->beforeAll = new BeforeAllRepository();
$this->beforeEach = new BeforeEachRepository();
$this->tests = new TestRepository();
$this->afterEach = new AfterEachRepository();
$this->afterAll = new AfterAllRepository();
$this->rootPath = $rootPath;
}
/**
* Returns the current instance of the test suite.
*/
public static function getInstance(string $rootPath = null): TestSuite
{
if (is_string($rootPath)) {
return self::$instance ?? self::$instance = new TestSuite($rootPath);
}
if (self::$instance === null) {
throw new InvalidPestCommand();
}
return self::$instance;
}
}

101
src/globals.php Normal file
View File

@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
use Pest\Datasets;
use Pest\PendingObjects\AfterEachCall;
use Pest\PendingObjects\BeforeEachCall;
use Pest\PendingObjects\TestCall;
use Pest\PendingObjects\UsesCall;
use Pest\Support\Backtrace;
use Pest\TestSuite;
use PHPUnit\Framework\TestCase;
/**
* Runs the given closure after all tests in the current file.
*/
function beforeAll(Closure $closure): void
{
TestSuite::getInstance()->beforeAll->set($closure);
}
/**
* Runs the given closure before each test in the current file.
*
* @return BeforeEachCall|TestCase|mixed
*/
function beforeEach(Closure $closure = null): BeforeEachCall
{
$filename = Backtrace::file();
return new BeforeEachCall(TestSuite::getInstance(), $filename, $closure);
}
/**
* Registers the given dataset.
*
* @param Closure|iterable $dataset
*/
function dataset(string $name, $dataset): void
{
Datasets::set($name, $dataset);
}
/**
* The uses function adds the binds the
* given arguments to test closures.
*/
function uses(string ...$classAndTraits): UsesCall
{
$filename = Backtrace::file();
return new UsesCall($filename, $classAndTraits);
}
/**
* Adds the given closure as a test. The first argument
* is the test description; the second argument is
* a closure that contains the test expectations.
*
* @return TestCall|TestCase|mixed
*/
function test(string $description, Closure $closure = null): TestCall
{
$filename = Backtrace::file();
return new TestCall(TestSuite::getInstance(), $filename, $description, $closure);
}
/**
* Adds the given closure as a test. The first argument
* is the test description; the second argument is
* a closure that contains the test expectations.
*
* @return TestCall|TestCase|mixed
*/
function it(string $description, Closure $closure = null): TestCall
{
$filename = Backtrace::file();
return new TestCall(TestSuite::getInstance(), $filename, sprintf('it %s', $description), $closure);
}
/**
* Runs the given closure after each test in the current file.
*
* @return AfterEachCall|TestCase|mixed
*/
function afterEach(Closure $closure = null): AfterEachCall
{
$filename = Backtrace::file();
return new AfterEachCall(TestSuite::getInstance(), $filename, $closure);
}
/**
* Runs the given closure after all tests in the current file.
*/
function afterAll(Closure $closure = null): void
{
TestSuite::getInstance()->afterAll->set($closure);
}

5
stubs/Dataset.php Normal file
View File

@ -0,0 +1,5 @@
<?php
dataset('{dataset_name}', function () {
return ['{dataset_element} A', '{dataset_element} B'];
});

7
stubs/Feature.php Normal file
View File

@ -0,0 +1,7 @@
<?php
it('has {name} page', function () {
$response = $this->get('/{name}');
$response->assertStatus(200);
});

3
stubs/Pest.php Normal file
View File

@ -0,0 +1,3 @@
<?php
uses(Tests\TestCase::class)->in('Feature');

5
stubs/Unit.php Normal file
View File

@ -0,0 +1,5 @@
<?php
test('{name}', function () {
assertTrue(true);
});

21
stubs/phpunit.xml Normal file
View File

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="./vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
>
<testsuites>
<testsuite name="Unit">
<directory suffix="Test.php">./tests/Unit</directory>
</testsuite>
<testsuite name="Feature">
<directory suffix="Test.php">./tests/Feature</directory>
</testsuite>
</testsuites>
<filter>
<whitelist processUncoveredFilesFromWhitelist="true">
<directory suffix=".php">./app</directory>
<directory suffix=".php">./src</directory>
</whitelist>
</filter>
</phpunit>

View File

@ -0,0 +1,130 @@
PASS Tests\CustomTestCase\PhpunitTest
✓ that gets executed
PASS Tests\Features\AfterAll
✓ deletes file after all
PASS Tests\Features\AfterEach
✓ it does not get executed before the test
✓ it gets executed after the test
PASS Tests\Features\BeforeAll
✓ it gets executed before tests
✓ it do not get executed before each test
PASS Tests\Features\BeforeEach
✓ it gets executed before each test
✓ it gets executed before each test once again
PASS Tests\Features\Datasets
✓ it throws exception if dataset does not exist
✓ it throws exception if dataset already exist
✓ it sets closures
✓ it sets arrays
✓ it gets bound to test case object with ('a')
✓ it gets bound to test case object with ('b')
✓ it truncates the description with (' fooo fooo fooo fooo fooo fooo fooo f...oo fooo')
✓ lazy datasets with (1)
✓ lazy datasets with (2)
✓ lazy datasets did the job right
✓ eager datasets with (1)
✓ eager datasets with (2)
✓ eager datasets did the job right
✓ lazy registered datasets with (1)
✓ lazy registered datasets with (2)
✓ lazy registered datasets did the job right
✓ eager registered datasets with (1)
✓ eager registered datasets with (2)
✓ eager registered datasets did the job right
✓ eager wrapped registered datasets with (1)
✓ eager wrapped registered datasets with (2)
✓ eager registered wrapped datasets did the job right
✓ lazy named datasets with ( bar object (...))
PASS Tests\Features\Exceptions
✓ it gives access the the underlying expect exception
✓ it catch exceptions
✓ it catch exceptions and messages
PASS Tests\Features\HigherOrderMessages
✓ it proxies calls to object
PASS Tests\Features\It
✓ it is a test
✓ it is a higher order message test
PASS Tests\Features\Mocks
✓ it has bar
WARN Tests\Features\Skip
✓ it do not skips
s it skips with truthy
s it skips with truthy condition by default
s it skips with message → skipped because bar
s it skips with truthy closure condition
✓ it do not skips with falsy closure condition
s it skips with condition and messsage → skipped because foo
PASS Tests\Features\Test
✓ a test
✓ higher order message test
PASS Tests\Fixtures\DirectoryWithTests\ExampleTest
✓ it example
PASS Tests\Fixtures\ExampleTest
✓ it example
PASS Tests\PHPUnit\CustomTestCase\UsesPerDirectory
✓ closure was bound to custom test case
PASS Tests\PHPUnit\CustomTestCaseInSubFolders\SubFolder\SubFolder\UsesPerSubDirectory
✓ closure was bound to custom test case
PASS Tests\PHPUnit\CustomTestCaseInSubFolders\SubFolder2\UsesPerFile
✓ custom traits can be used
✓ trait applied in this file
PASS Tests\Playground
✓ basic
PASS Tests\Unit\Actions\AddsCoverage
✓ it adds coverage if --coverage exist
✓ it adds coverage if --min exist
PASS Tests\Unit\Actions\AddsDefaults
✓ it sets defaults
✓ it does not override options
PASS Tests\Unit\Actions\AddsTests
✓ default php unit tests
✓ it removes warnings
PASS Tests\Unit\Actions\ValidatesConfiguration
✓ it throws exception when configuration not found
✓ it throws exception when `process isolation` is true
✓ it do not throws exception when `process isolation` is false
PASS Tests\Unit\Console\Coverage
✓ it generates coverage based on file input
PASS Tests\Unit\Support\Backtrace
✓ it gets file name from called file
PASS Tests\Unit\Support\Reflection
✓ it gets file name from closure
✓ it gets property values
PASS Tests\Unit\TestSuite
✓ it does not allow to add the same test description twice
PASS Tests\Visual\SingleTestOrDirectory
✓ allows to run a single test
✓ allows to run a directory
WARN Tests\Visual\Success
s visual snapshot of test suite on success
Tests: 6 skipped, 65 passed
Time: 2.50s

5
tests/Autoload.php Normal file
View File

@ -0,0 +1,5 @@
<?php
if (class_exists(NunoMaduro\Collision\Provider::class)) {
(new NunoMaduro\Collision\Provider())->register();
}

View File

@ -0,0 +1,15 @@
<?php
dataset('numbers.closure', function () {
yield [1];
yield [2];
});
dataset('numbers.closure.wrapped', function () {
yield 1;
yield 2;
});
dataset('numbers.array', [[1], [2]]);
dataset('numbers.array.wrapped', [1, 2]);

View File

@ -0,0 +1,15 @@
<?php
$file = __DIR__ . DIRECTORY_SEPARATOR . 'after-all-test';
afterAll(function () use ($file) {
unlink($file);
});
test('deletes file after all', function () use ($file) {
file_put_contents($file, 'foo');
assertFileExists($file);
register_shutdown_function(function () use ($file) {
assertFileNotExists($file);
});
});

View File

@ -0,0 +1,20 @@
<?php
$state = new stdClass();
beforeEach(function () use ($state) {
$this->state = $state;
});
afterEach(function () use ($state) {
$this->state->bar = 2;
});
it('does not get executed before the test', function () {
assertFalse(property_exists($this->state, 'bar'));
});
it('gets executed after the test', function () {
assertTrue(property_exists($this->state, 'bar'));
assertEquals(2, $this->state->bar);
});

View File

@ -0,0 +1,18 @@
<?php
$foo = new \stdClass();
$foo->bar = 0;
beforeAll(function () use ($foo) {
$foo->bar++;
});
it('gets executed before tests', function () use ($foo) {
assertEquals($foo->bar, 1);
$foo->bar = 'changed';
});
it('do not get executed before each test', function () use ($foo) {
assertEquals($foo->bar, 'changed');
});

View File

@ -0,0 +1,15 @@
<?php
beforeEach(function () {
$this->bar = 2;
});
it('gets executed before each test', function () {
assertEquals($this->bar, 2);
$this->bar = 'changed';
});
it('gets executed before each test once again', function () {
assertEquals($this->bar, 2);
});

108
tests/Features/Datasets.php Normal file
View File

@ -0,0 +1,108 @@
<?php
use Pest\Datasets;
use Pest\Exceptions\DatasetAlreadyExist;
use Pest\Exceptions\DatasetDoesNotExist;
it('throws exception if dataset does not exist', function () {
$this->expectException(DatasetDoesNotExist::class);
$this->expectExceptionMessage("A dataset with the name `first` does not exist. You can create it using `dataset('first', ['a', 'b']);`.");
Datasets::get('first');
});
it('throws exception if dataset already exist', function () {
Datasets::set('second', [[]]);
$this->expectException(DatasetAlreadyExist::class);
$this->expectExceptionMessage('A dataset with the name `second` already exist.');
Datasets::set('second', [[]]);
});
it('sets closures', function () {
Datasets::set('foo', function () {
yield [1];
});
assertEquals([[1]], iterator_to_array(Datasets::get('foo')()));
});
it('sets arrays', function () {
Datasets::set('bar', [[2]]);
assertEquals([[2]], Datasets::get('bar'));
});
it('gets bound to test case object', function () {
$this->assertTrue(true);
})->with([['a'], ['b']]);
test('it truncates the description', function () {
assertTrue(true);
// it gets tested by the integration test
})->with([str_repeat('Fooo', 10000000)]);
$state = new stdClass();
$state->text = '';
$datasets = [[1], [2]];
test('lazy datasets', function ($text) use ($state, $datasets) {
$state->text .= $text;
assertTrue(in_array([$text], $datasets));
})->with($datasets);
test('lazy datasets did the job right', function () use ($state) {
assertEquals('12', $state->text);
});
$state->text = '';
test('eager datasets', function ($text) use ($state, $datasets) {
$state->text .= $text;
assertTrue(in_array([$text], $datasets));
})->with(function () use ($datasets) {
return $datasets;
});
test('eager datasets did the job right', function () use ($state) {
assertEquals('1212', $state->text);
});
test('lazy registered datasets', function ($text) use ($state, $datasets) {
$state->text .= $text;
assertTrue(in_array([$text], $datasets));
})->with('numbers.array');
test('lazy registered datasets did the job right', function () use ($state) {
assertEquals('121212', $state->text);
});
test('eager registered datasets', function ($text) use ($state, $datasets) {
$state->text .= $text;
assertTrue(in_array([$text], $datasets));
})->with('numbers.closure');
test('eager registered datasets did the job right', function () use ($state) {
assertEquals('12121212', $state->text);
});
test('eager wrapped registered datasets', function ($text) use ($state, $datasets) {
$state->text .= $text;
assertTrue(in_array([$text], $datasets));
})->with('numbers.closure.wrapped');
test('eager registered wrapped datasets did the job right', function () use ($state) {
assertEquals('1212121212', $state->text);
});
class Bar
{
public $name = 1;
}
$namedDatasets = [
new Bar(),
];
test('lazy named datasets', function ($text) use ($state, $datasets) {
assertTrue(true);
})->with($namedDatasets);

View File

@ -0,0 +1,15 @@
<?php
it('gives access the the underlying expectException', function () {
$this->expectException(InvalidArgumentException::class);
throw new InvalidArgumentException();
});
it('catch exceptions', function () {
throw new Exception('Something bad happened');
})->throws(Exception::class);
it('catch exceptions and messages', function () {
throw new Exception('Something bad happened');
})->throws(Exception::class, 'Something bad happened');

View File

@ -0,0 +1,7 @@
<?php
beforeEach()->assertTrue(true);
it('proxies calls to object')->assertTrue(true);
afterEach()->assertTrue(true);

7
tests/Features/It.php Normal file
View File

@ -0,0 +1,7 @@
<?php
it('is a test', function () {
assertArrayHasKey('key', ['key' => 'foo']);
});
it('is a higher order message test')->assertTrue(true);

15
tests/Features/Mocks.php Normal file
View File

@ -0,0 +1,15 @@
<?php
interface Foo
{
public function bar(): int;
}
it('has bar', function () {
$mock = Mockery::mock(Foo::class);
$mock->shouldReceive('bar')
->times(1)
->andReturn(2);
assertEquals(2, $mock->bar());
});

29
tests/Features/Skip.php Normal file
View File

@ -0,0 +1,29 @@
<?php
it('do not skips')
->skip(false)
->assertTrue(true);
it('skips with truthy')
->skip(1)
->assertTrue(false);
it('skips with truthy condition by default')
->skip()
->assertTrue(false);
it('skips with message')
->skip('skipped because bar')
->assertTrue(false);
it('skips with truthy closure condition')
->skip(function () { return '1'; })
->assertTrue(false);
it('do not skips with falsy closure condition')
->skip(function () { return false; })
->assertTrue(true);
it('skips with condition and messsage')
->skip(true, 'skipped because foo')
->assertTrue(false);

7
tests/Features/Test.php Normal file
View File

@ -0,0 +1,7 @@
<?php
test('a test', function () {
assertArrayHasKey('key', ['key' => 'foo']);
});
test('higher order message test')->assertTrue(true);

View File

@ -0,0 +1,27 @@
<?php
$foo = new stdClass();
$foo->beforeAll = false;
$foo->beforeEach = false;
$foo->afterEach = false;
$foo->afterAll = false;
beforeAll(function () {
$foo->beforeAll = true;
});
beforeEach(function () {
$foo->beforeEach = true;
});
afterEach(function () {
$foo->afterEach = true;
});
afterAll(function () {
$foo->afterAll = true;
});
register_shutdown_function(function () use ($foo) {
assertFalse($foo->beforeAll);
assertFalse($foo->beforeEach);
assertFalse($foo->afterEach);
assertFalse($foo->afterAll);
});

View File

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

View File

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

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="./vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
processIsolation="true"
>
</phpunit>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="./vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
processIsolation="false"
>
</phpunit>

View File

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Tests\CustomTestCase;
use function PHPUnit\Framework\assertTrue;
use PHPUnit\Framework\TestCase;
class CustomTestCase extends TestCase
{
public function assertCustomTrue()
{
assertTrue(true);
}
}

View File

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace Tests\CustomTestCase;
use function PHPUnit\Framework\assertTrue;
use PHPUnit\Framework\TestCase;
class PhpunitTest extends TestCase
{
public static $executed = false;
/** @test */
public function testThatGetsExecuted(): void
{
self::$executed = true;
$this->assertTrue(true);
}
}
// register_shutdown_function(fn () => assertTrue(PhpunitTest::$executed));

View File

@ -0,0 +1,7 @@
<?php
uses(Tests\CustomTestCase\CustomTestCase::class)->in(__DIR__);
test('closure was bound to CustomTestCase', function () {
$this->assertCustomTrue();
});

View File

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Tests\SubFolder\SubFolder\SubFolder;
use PHPUnit\Framework\TestCase;
class CustomTestCaseInSubFolder extends TestCase
{
public function assertCustomInSubFolderTrue()
{
assertTrue(true);
}
}

View File

@ -0,0 +1,5 @@
<?php
test('closure was bound to CustomTestCase', function () {
$this->assertCustomInSubFolderTrue();
});

View File

@ -0,0 +1,25 @@
<?php
trait MyCustomTrait
{
public function assertFalseIsFalse()
{
assertFalse(false);
}
}
class MyCustomClass extends PHPUnit\Framework\TestCase
{
public function assertTrueIsTrue()
{
assertTrue(true);
}
}
uses(MyCustomClass::class, MyCustomTrait::class);
test('custom traits can be used', function () {
$this->assertTrueIsTrue();
});
test('trait applied in this file')->assertTrueIsTrue();

3
tests/PHPUnit/Pest.php Normal file
View File

@ -0,0 +1,3 @@
<?php
uses(Tests\SubFolder\SubFolder\SubFolder\CustomTestCaseInSubFolder::class)->in('CustomTestCaseInSubFolders/SubFolder');

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