├── src ├── Expectation │ ├── ExpressionContext.php │ ├── Expectation.php │ ├── ChainExpectation.php │ ├── Expectations.php │ ├── ExpressionExpectation.php │ └── IsTruthyExpression.php ├── Annotation │ ├── Processor │ │ ├── Processor.php │ │ └── RequiresProcessor.php │ ├── Requirement │ │ ├── Requirement.php │ │ ├── ConstantRequirement.php │ │ ├── ConditionFunctionProvider.php │ │ ├── PackageRequirement.php │ │ └── ConditionRequirement.php │ ├── PlaceholderResolver │ │ ├── PlaceholderResolver.php │ │ ├── TmpDirResolver.php │ │ ├── TargetClassResolver.php │ │ ├── TargetMethodResolver.php │ │ └── ChainResolver.php │ ├── AnnotationExtension.php │ ├── InvalidAnnotationException.php │ ├── AnnotationProcessor.php │ ├── Annotations.php │ ├── Target.php │ ├── EstablishedAnnotationNames.php │ ├── ProcessorMap.php │ └── AnnotationProcessorBuilder.php └── TestCase.php ├── LICENSE ├── composer.json └── README.md /src/Expectation/ExpressionContext.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace PHPUnitExtras\Expectation; 15 | 16 | interface ExpressionContext 17 | { 18 | public function getExpression() : string; 19 | 20 | public function getValues() : array; 21 | } 22 | -------------------------------------------------------------------------------- /src/Annotation/Processor/Processor.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace PHPUnitExtras\Annotation\Processor; 15 | 16 | interface Processor 17 | { 18 | public function getName() : string; 19 | 20 | public function process(string $value) : void; 21 | } 22 | -------------------------------------------------------------------------------- /src/Annotation/Requirement/Requirement.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace PHPUnitExtras\Annotation\Requirement; 15 | 16 | interface Requirement 17 | { 18 | public function getName() : string; 19 | 20 | public function check(string $value) : ?string; 21 | } 22 | -------------------------------------------------------------------------------- /src/Expectation/Expectation.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace PHPUnitExtras\Expectation; 15 | 16 | use PHPUnit\Framework\ExpectationFailedException; 17 | 18 | interface Expectation 19 | { 20 | /** 21 | * @throws ExpectationFailedException 22 | */ 23 | public function verify() : void; 24 | } 25 | -------------------------------------------------------------------------------- /src/Annotation/PlaceholderResolver/PlaceholderResolver.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace PHPUnitExtras\Annotation\PlaceholderResolver; 15 | 16 | use PHPUnitExtras\Annotation\Target; 17 | 18 | interface PlaceholderResolver 19 | { 20 | public function getName() : string; 21 | 22 | public function resolve(string $value, Target $target) : string; 23 | } 24 | -------------------------------------------------------------------------------- /src/Annotation/AnnotationExtension.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace PHPUnitExtras\Annotation; 15 | 16 | use PHPUnit\Runner\BeforeTestHook; 17 | 18 | class AnnotationExtension implements BeforeTestHook 19 | { 20 | use Annotations; 21 | 22 | public function executeBeforeTest(string $test) : void 23 | { 24 | /** @var class-string $class */ 25 | [$class, $method] = preg_split('/ |::/', $test); 26 | $this->processAnnotations($class, $method); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Annotation/PlaceholderResolver/TmpDirResolver.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace PHPUnitExtras\Annotation\PlaceholderResolver; 15 | 16 | use PHPUnitExtras\Annotation\Target; 17 | 18 | final class TmpDirResolver implements PlaceholderResolver 19 | { 20 | public function getName() : string 21 | { 22 | return 'tmp_dir'; 23 | } 24 | 25 | public function resolve(string $value, Target $target) : string 26 | { 27 | return strtr($value, ['%tmp_dir%' => sys_get_temp_dir()]); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Annotation/Requirement/ConstantRequirement.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace PHPUnitExtras\Annotation\Requirement; 15 | 16 | final class ConstantRequirement implements Requirement 17 | { 18 | public function getName() : string 19 | { 20 | return 'constant'; 21 | } 22 | 23 | public function check(string $value) : ?string 24 | { 25 | if (\defined($value)) { 26 | return null; 27 | } 28 | 29 | return sprintf('The constant "%s" is undefined', $value); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Annotation/PlaceholderResolver/TargetClassResolver.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace PHPUnitExtras\Annotation\PlaceholderResolver; 15 | 16 | use PHPUnitExtras\Annotation\Target; 17 | 18 | final class TargetClassResolver implements PlaceholderResolver 19 | { 20 | public function getName() : string 21 | { 22 | return 'target_class'; 23 | } 24 | 25 | public function resolve(string $value, Target $target) : string 26 | { 27 | return strtr($value, [ 28 | '%target_class%' => $target->getClassShortName(), 29 | '%target_class_full%' => $target->getClassName(), 30 | ]); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Annotation/Requirement/ConditionFunctionProvider.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace PHPUnitExtras\Annotation\Requirement; 15 | 16 | use Symfony\Component\ExpressionLanguage\ExpressionFunction; 17 | use Symfony\Component\ExpressionLanguage\ExpressionFunctionProviderInterface; 18 | 19 | final class ConditionFunctionProvider implements ExpressionFunctionProviderInterface 20 | { 21 | public function getFunctions() : array 22 | { 23 | return [ 24 | ExpressionFunction::fromPhp('strtoupper'), 25 | ExpressionFunction::fromPhp('strtolower'), 26 | ExpressionFunction::fromPhp('strpos'), 27 | ]; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Expectation/ChainExpectation.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace PHPUnitExtras\Expectation; 15 | 16 | final class ChainExpectation implements Expectation 17 | { 18 | /** 19 | * @var array 20 | */ 21 | private $expectations = []; 22 | 23 | public function expect(Expectation $expectation) : void 24 | { 25 | $this->expectations[] = $expectation; 26 | } 27 | 28 | public function verify() : void 29 | { 30 | try { 31 | foreach ($this->expectations as $expectation) { 32 | $expectation->verify(); 33 | } 34 | } finally { 35 | $this->expectations = []; 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Annotation/PlaceholderResolver/TargetMethodResolver.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace PHPUnitExtras\Annotation\PlaceholderResolver; 15 | 16 | use PHPUnitExtras\Annotation\Target; 17 | 18 | final class TargetMethodResolver implements PlaceholderResolver 19 | { 20 | public function getName() : string 21 | { 22 | return 'target_method'; 23 | } 24 | 25 | public function resolve(string $value, Target $target) : string 26 | { 27 | if (!$target->isOnMethod()) { 28 | return $value; 29 | } 30 | 31 | return strtr($value, [ 32 | '%target_method%' => $target->getMethodShortName(), 33 | '%target_method_full%' => $target->getMethodName(), 34 | ]); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Expectation/Expectations.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace PHPUnitExtras\Expectation; 15 | 16 | trait Expectations 17 | { 18 | /** @var ChainExpectation|null */ 19 | private $expectations; 20 | 21 | final protected function verifyExpectations() : void 22 | { 23 | $this->getExpectations()->verify(); 24 | } 25 | 26 | final protected function expect(Expectation $expectation) : void 27 | { 28 | $this->getExpectations()->expect($expectation); 29 | } 30 | 31 | private function getExpectations() : ChainExpectation 32 | { 33 | if ($this->expectations) { 34 | return $this->expectations; 35 | } 36 | 37 | return $this->expectations = new ChainExpectation(); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020-2022 Eugene Leonovich 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/Annotation/InvalidAnnotationException.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace PHPUnitExtras\Annotation; 15 | 16 | use PHPUnit\Framework\Exception; 17 | 18 | /** @psalm-suppress InternalMethod */ 19 | final class InvalidAnnotationException extends Exception 20 | { 21 | public static function unknownName(string $name) : self 22 | { 23 | return new self(sprintf('Unknown annotation "%s"', $name)); 24 | } 25 | 26 | public static function invalidSyntax(string $annotation, string $reason = '') : self 27 | { 28 | return new self(sprintf('Unable to parse "%s": %s', $annotation, $reason)); 29 | } 30 | 31 | public static function unresolvedPlaceholder(string $placeholder) : self 32 | { 33 | return new self(sprintf('Unresolved placeholder "%s"', $placeholder)); 34 | } 35 | 36 | public static function unknownRequirement(string $requirement) : self 37 | { 38 | return new self(sprintf('Unknown requirement "%s"', $requirement)); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Expectation/ExpressionExpectation.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace PHPUnitExtras\Expectation; 15 | 16 | use PHPUnit\Framework\Assert; 17 | use Symfony\Component\ExpressionLanguage\ExpressionLanguage; 18 | 19 | final class ExpressionExpectation implements Expectation 20 | { 21 | /** @var ExpressionContext */ 22 | private $context; 23 | 24 | /** @var ExpressionLanguage */ 25 | private $language; 26 | 27 | public function __construct(ExpressionContext $context, ?ExpressionLanguage $language = null) 28 | { 29 | $this->context = $context; 30 | $this->language = $language ?: new ExpressionLanguage(); 31 | } 32 | 33 | public function verify() : void 34 | { 35 | $expression = $this->context->getExpression(); 36 | $values = $this->context->getValues(); 37 | 38 | Assert::assertThat( 39 | $this->language->evaluate($expression, $values), 40 | new IsTruthyExpression($this->context) 41 | ); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Annotation/AnnotationProcessor.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace PHPUnitExtras\Annotation; 15 | 16 | use PHPUnitExtras\Annotation\PlaceholderResolver\PlaceholderResolver; 17 | 18 | final class AnnotationProcessor 19 | { 20 | private $processorMap; 21 | private $placeholderResolver; 22 | 23 | public function __construct(ProcessorMap $processorMap, PlaceholderResolver $placeholderResolver) 24 | { 25 | $this->processorMap = $processorMap; 26 | $this->placeholderResolver = $placeholderResolver; 27 | } 28 | 29 | public function getPlaceholderResolver() : PlaceholderResolver 30 | { 31 | return $this->placeholderResolver; 32 | } 33 | 34 | public function process(array $annotations, Target $target) : void 35 | { 36 | foreach ($annotations as $name => $values) { 37 | if (!$processor = $this->processorMap->tryGet($name)) { 38 | continue; 39 | } 40 | 41 | foreach ($values as $value) { 42 | $value = $this->placeholderResolver->resolve($value, $target); 43 | $processor->process($value); 44 | } 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Annotation/Requirement/PackageRequirement.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace PHPUnitExtras\Annotation\Requirement; 15 | 16 | use Composer\Semver\Semver; 17 | use PackageVersions\Versions; 18 | 19 | final class PackageRequirement implements Requirement 20 | { 21 | public function getName() : string 22 | { 23 | return 'package'; 24 | } 25 | 26 | public function check(string $value) : ?string 27 | { 28 | /** 29 | * @var string $packageName 30 | * @see https://github.com/vimeo/psalm/issues/3118 31 | */ 32 | [$packageName, $versionConstraints] = explode(' ', $value, 2) + [1 => null]; 33 | 34 | try { 35 | $packageVersion = Versions::getVersion($packageName); 36 | } catch (\OutOfBoundsException $e) { 37 | return sprintf('Package "%s" is required', $value); 38 | } 39 | 40 | if (!$versionConstraints) { 41 | return null; 42 | } 43 | 44 | $packageVersion = explode('@', $packageVersion, 2)[0]; 45 | if (Semver::satisfies($packageVersion, $versionConstraints)) { 46 | return null; 47 | } 48 | 49 | return sprintf('"%s" version %s is required', $packageName, $versionConstraints); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/TestCase.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace PHPUnitExtras; 15 | 16 | use PHPUnit\Framework\TestCase as BaseTestCase; 17 | use PHPUnitExtras\Annotation\Annotations; 18 | use PHPUnitExtras\Annotation\Target; 19 | use PHPUnitExtras\Expectation\Expectations; 20 | 21 | abstract class TestCase extends BaseTestCase 22 | { 23 | use Annotations; 24 | use Expectations; 25 | 26 | /** 27 | * @before 28 | */ 29 | final protected function processTestCaseAnnotations() : void 30 | { 31 | /** 32 | * @psalm-suppress TypeDoesNotContainType 33 | * @psalm-suppress TypeDoesNotContainNull 34 | * @psalm-suppress RedundantCondition 35 | * TestCase::getName() may return null on PHPUnit 7 36 | */ 37 | $this->processAnnotations(static::class, $this->getName(false) ?? ''); 38 | } 39 | 40 | final protected function resolvePlaceholders(string $value) : string 41 | { 42 | $resolver = $this->getAnnotationProcessor()->getPlaceholderResolver(); 43 | 44 | return $resolver->resolve($value, Target::fromTestCase($this)); 45 | } 46 | 47 | /** 48 | * @after 49 | */ 50 | final protected function verifyTestCaseExpectations() : void 51 | { 52 | $this->verifyExpectations(); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rybakit/phpunit-extras", 3 | "description": "Custom annotations and expectations for PHPUnit.", 4 | "keywords": ["phpunit", "annotations", "extensions", "assertions", "expectations", "custom"], 5 | "type": "library", 6 | "license": "MIT", 7 | "authors": [ 8 | { 9 | "name": "Eugene Leonovich", 10 | "email": "gen.work@gmail.com" 11 | } 12 | ], 13 | "require": { 14 | "php": "^7.1|^8", 15 | "phpunit/phpunit": "^7.1|^8|^9" 16 | }, 17 | "require-dev": { 18 | "php": "^7.1.3|^8", 19 | "composer/semver": "^1.5", 20 | "friendsofphp/php-cs-fixer": "^2.18", 21 | "ocramius/package-versions": "^1.4", 22 | "symfony/expression-language": "^3.3|^4|^5", 23 | "vimeo/psalm": "^3.9|^4" 24 | }, 25 | "suggest": { 26 | "composer/semver": "For using version-related requirements", 27 | "ocramius/package-versions": "For using the 'package' requirement", 28 | "symfony/expression-language": "For using expression-based requirements and/or expectations" 29 | }, 30 | "autoload": { 31 | "psr-4": { 32 | "PHPUnitExtras\\": "src/" 33 | } 34 | }, 35 | "autoload-dev": { 36 | "psr-4": { 37 | "PHPUnitExtras\\Tests\\": "tests/" 38 | } 39 | }, 40 | "config": { 41 | "preferred-install": { 42 | "*": "dist" 43 | }, 44 | "sort-packages": true, 45 | "allow-plugins": { 46 | "ocramius/package-versions": true 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Annotation/PlaceholderResolver/ChainResolver.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace PHPUnitExtras\Annotation\PlaceholderResolver; 15 | 16 | use PHPUnitExtras\Annotation\InvalidAnnotationException; 17 | use PHPUnitExtras\Annotation\Target; 18 | 19 | final class ChainResolver implements PlaceholderResolver 20 | { 21 | /** @var array */ 22 | private $resolvers = []; 23 | 24 | public function __construct(array $placeholders = []) 25 | { 26 | foreach ($placeholders as $placeholder) { 27 | $this->addResolver($placeholder); 28 | } 29 | } 30 | 31 | public function addResolver(PlaceholderResolver $resolver) : self 32 | { 33 | $this->resolvers[$resolver->getName()] = $resolver; 34 | 35 | return $this; 36 | } 37 | 38 | public function getName() : string 39 | { 40 | return 'chain'; 41 | } 42 | 43 | public function resolve(string $value, Target $target) : string 44 | { 45 | foreach ($this->resolvers as $resolver) { 46 | $value = $resolver->resolve($value, $target); 47 | } 48 | 49 | if (preg_match('/%(?P[^%]+)%/', $value, $matches)) { 50 | throw InvalidAnnotationException::unresolvedPlaceholder($matches['placeholder']); 51 | } 52 | 53 | return $value; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Expectation/IsTruthyExpression.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace PHPUnitExtras\Expectation; 15 | 16 | use PHPUnit\Framework\Constraint\Constraint; 17 | use SebastianBergmann\Exporter\Exporter; 18 | 19 | final class IsTruthyExpression extends Constraint 20 | { 21 | private $context; 22 | 23 | /** @var Exporter|null */ 24 | private $compatExporter; 25 | 26 | public function __construct(ExpressionContext $context) 27 | { 28 | $this->context = $context; 29 | } 30 | 31 | public function toString() : string 32 | { 33 | return 'is evaluated to true'; 34 | } 35 | 36 | protected function matches($other) : bool 37 | { 38 | return true === $other; 39 | } 40 | 41 | protected function failureDescription($other) : string 42 | { 43 | return sprintf( 44 | "\"%s\" with values\n %s\n%s", 45 | $this->context->getExpression(), 46 | $this->exporter()->export($this->context->getValues(), 1), 47 | $this->toString() 48 | ); 49 | } 50 | 51 | /** 52 | * Needed for backward compatibility with PHPUnit 7. 53 | */ 54 | protected function exporter() : Exporter 55 | { 56 | if (null === $this->compatExporter) { 57 | $this->compatExporter = new Exporter(); 58 | } 59 | 60 | return $this->compatExporter; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/Annotation/Annotations.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace PHPUnitExtras\Annotation; 15 | 16 | use PHPUnit\Util\Test; 17 | 18 | trait Annotations 19 | { 20 | /** @var AnnotationProcessor|null */ 21 | private $annotationProcessor; 22 | 23 | /** @var array */ 24 | private static $processedClasses = []; 25 | 26 | protected function createAnnotationProcessorBuilder() : AnnotationProcessorBuilder 27 | { 28 | return AnnotationProcessorBuilder::fromDefaults(); 29 | } 30 | 31 | /** 32 | * @param class-string $class 33 | */ 34 | private function processAnnotations(string $class, string $method) : void 35 | { 36 | $annotations = Test::parseTestMethodAnnotations($class, $method); 37 | 38 | if ($annotations['class'] && !isset(self::$processedClasses[$class])) { 39 | $this->getAnnotationProcessor()->process($annotations['class'], new Target($class)); 40 | self::$processedClasses[$class] = true; 41 | } 42 | 43 | if ($annotations['method']) { 44 | $this->getAnnotationProcessor()->process($annotations['method'], new Target($class, $method)); 45 | } 46 | } 47 | 48 | private function getAnnotationProcessor() : AnnotationProcessor 49 | { 50 | if ($this->annotationProcessor) { 51 | return $this->annotationProcessor; 52 | } 53 | 54 | $builder = $this->createAnnotationProcessorBuilder(); 55 | 56 | return $this->annotationProcessor = $builder->build(); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Annotation/Target.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace PHPUnitExtras\Annotation; 15 | 16 | use PHPUnit\Framework\TestCase; 17 | 18 | final class Target 19 | { 20 | private $className; 21 | private $methodName; 22 | 23 | /** 24 | * @param class-string $className 25 | */ 26 | public function __construct(string $className, ?string $methodName = null) 27 | { 28 | $this->className = $className; 29 | $this->methodName = $methodName; 30 | } 31 | 32 | public static function fromTestCase(TestCase $testCase) : self 33 | { 34 | return new self(\get_class($testCase), $testCase->getName(false)); 35 | } 36 | 37 | public function getClassName() : string 38 | { 39 | return $this->className; 40 | } 41 | 42 | public function getClassShortName() : string 43 | { 44 | return (new \ReflectionClass($this->className))->getShortName(); 45 | } 46 | 47 | public function isOnMethod() : bool 48 | { 49 | return null !== $this->methodName; 50 | } 51 | 52 | public function getMethodName() : string 53 | { 54 | if (null === $this->methodName) { 55 | throw new \LogicException(sprintf('Class level target "%s" does not have method name', $this->className)); 56 | } 57 | 58 | return $this->methodName; 59 | } 60 | 61 | public function getMethodShortName() : string 62 | { 63 | $methodName = $this->getMethodName(); 64 | 65 | return 0 === strpos($methodName, 'test') 66 | ? substr($methodName, 4) 67 | : $methodName; 68 | } 69 | 70 | public function toString() : string 71 | { 72 | return $this->methodName 73 | ? "$this->className::$this->methodName" 74 | : $this->className; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/Annotation/EstablishedAnnotationNames.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace PHPUnitExtras\Annotation; 15 | 16 | final class EstablishedAnnotationNames 17 | { 18 | public const PHPUNIT = [ 19 | 'author' => true, 20 | 'after' => true, 21 | 'afterClass' => true, 22 | 'backupGlobals' => true, 23 | 'backupStaticAttributes' => true, 24 | 'before' => true, 25 | 'beforeClass' => true, 26 | 'codeCoverageIgnore' => true, 27 | 'codeCoverageIgnoreStart' => true, 28 | 'codeCoverageIgnoreEnd' => true, 29 | 'covers' => true, 30 | 'coversDefaultClass' => true, 31 | 'coversNothing' => true, 32 | 'dataProvider' => true, 33 | 'depends' => true, 34 | 'doesNotPerformAssertions' => true, 35 | 'expectedException' => true, 36 | 'expectedExceptionCode' => true, 37 | 'expectedExceptionMessage' => true, 38 | 'expectedExceptionMessageRegExp' => true, 39 | 'group' => true, 40 | 'large' => true, 41 | 'medium' => true, 42 | 'preserveGlobalState' => true, 43 | 'preCondition' => true, 44 | 'postCondition' => true, 45 | 'requires' => true, 46 | 'runTestsInSeparateProcesses' => true, 47 | 'runInSeparateProcess' => true, 48 | 'small' => true, 49 | 'test' => true, 50 | 'testdox' => true, 51 | 'testWith' => true, 52 | 'ticket' => true, 53 | 'uses' => true, 54 | ]; 55 | 56 | public const MISC = [ 57 | 'fixme' => true, 58 | 'FIXME' => true, 59 | 'todo' => true, 60 | 'TODO' => true, 61 | 'param' => true, 62 | 'return' => true, 63 | 'see' => true, 64 | 'throws' => true, 65 | ]; 66 | 67 | public const ALL = self::PHPUNIT + self::MISC; 68 | 69 | private function __construct() 70 | { 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/Annotation/ProcessorMap.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace PHPUnitExtras\Annotation; 15 | 16 | use PHPUnitExtras\Annotation\Processor\Processor; 17 | 18 | final class ProcessorMap 19 | { 20 | /** @var array */ 21 | private $processors = []; 22 | 23 | /** @var array */ 24 | private $ignoredAnnotationNames; 25 | 26 | /** @var bool */ 27 | private $ignoreUnknownAnnotations; 28 | 29 | /** 30 | * @param array $processors 31 | * @param array $ignoredAnnotationNames 32 | */ 33 | public function __construct(array $processors, array $ignoredAnnotationNames = [], bool $ignoreUnknownAnnotations = false) 34 | { 35 | foreach ($processors as $processor) { 36 | $this->addProcessor($processor); 37 | } 38 | 39 | $this->ignoredAnnotationNames = array_fill_keys($ignoredAnnotationNames, true); 40 | $this->ignoreUnknownAnnotations = $ignoreUnknownAnnotations; 41 | } 42 | 43 | public function get(string $name) : Processor 44 | { 45 | if (isset($this->processors[$name])) { 46 | return $this->processors[$name]; 47 | } 48 | 49 | throw InvalidAnnotationException::unknownName($name); 50 | } 51 | 52 | public function tryGet(string $name) : ?Processor 53 | { 54 | if (isset($this->processors[$name])) { 55 | return $this->processors[$name]; 56 | } 57 | 58 | if (isset($this->ignoredAnnotationNames[$name])) { 59 | return null; 60 | } 61 | 62 | if ($this->ignoreUnknownAnnotations) { 63 | return null; 64 | } 65 | 66 | throw InvalidAnnotationException::unknownName($name); 67 | } 68 | 69 | private function addProcessor(Processor $processor) : void 70 | { 71 | $this->processors[$processor->getName()] = $processor; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/Annotation/Requirement/ConditionRequirement.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace PHPUnitExtras\Annotation\Requirement; 15 | 16 | use Symfony\Component\ExpressionLanguage\ExpressionLanguage; 17 | 18 | final class ConditionRequirement implements Requirement 19 | { 20 | /** @var array */ 21 | private $context; 22 | 23 | /** @var ExpressionLanguage */ 24 | private $language; 25 | 26 | public function __construct(array $context, ?ExpressionLanguage $language = null) 27 | { 28 | $this->context = $context; 29 | $this->language = $language ?? new ExpressionLanguage(null, [new ConditionFunctionProvider()]); 30 | } 31 | 32 | public static function fromGlobals() : self 33 | { 34 | return new self([ 35 | 'cookie' => self::wrapGlobal($_COOKIE), 36 | 'env' => self::wrapGlobal($_ENV), 37 | 'get' => self::wrapGlobal($_GET), 38 | 'files' => self::wrapGlobal($_FILES), 39 | 'post' => self::wrapGlobal($_POST), 40 | 'request' => self::wrapGlobal($_REQUEST), 41 | 'server' => self::wrapGlobal($_SERVER), 42 | ]); 43 | } 44 | 45 | public function getName() : string 46 | { 47 | return 'condition'; 48 | } 49 | 50 | public function check(string $value) : ?string 51 | { 52 | if ($this->language->evaluate($value, $this->context)) { 53 | return null; 54 | } 55 | 56 | return sprintf('"%s" is not evaluated to true', $value); 57 | } 58 | 59 | /** 60 | * A workaround for unsupported "nullsafe" and "null coalescing" operators. 61 | * @see https://github.com/symfony/symfony/issues/21691 62 | */ 63 | private static function wrapGlobal(array $data) : \ArrayObject 64 | { 65 | return new class($data) extends \ArrayObject { 66 | public function __get($key) 67 | { 68 | return $this->offsetExists($key) ? $this->offsetGet($key) : null; 69 | } 70 | }; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/Annotation/Processor/RequiresProcessor.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace PHPUnitExtras\Annotation\Processor; 15 | 16 | use PHPUnit\Framework\Assert; 17 | use PHPUnitExtras\Annotation\InvalidAnnotationException; 18 | use PHPUnitExtras\Annotation\Requirement\Requirement; 19 | 20 | final class RequiresProcessor implements Processor 21 | { 22 | /** 23 | * @see https://github.com/sebastianbergmann/phpunit/blob/7.1.0/src/Util/Test.php#L67-L90 24 | */ 25 | private const PHPUNIT_REQUIREMENTS = [ 26 | 'PHP' => true, 27 | 'PHPUnit' => true, 28 | 'OS' => true, 29 | 'OSFAMILY' => true, 30 | 'function' => true, 31 | 'extension' => true, 32 | 'setting' => true, 33 | ]; 34 | 35 | /** @var array */ 36 | private $requirements = []; 37 | 38 | /** 39 | * @param array $requirements 40 | */ 41 | public function __construct(array $requirements) 42 | { 43 | foreach ($requirements as $requirement) { 44 | $this->addRequirement($requirement); 45 | } 46 | } 47 | 48 | /** 49 | * @return array 50 | */ 51 | public function getRequirements() : array 52 | { 53 | return $this->requirements; 54 | } 55 | 56 | public function getName() : string 57 | { 58 | return 'requires'; 59 | } 60 | 61 | public function process(string $value) : void 62 | { 63 | [$reqName, $reqValue] = explode(' ', $value, 2) + [1 => '']; 64 | 65 | $found = false; 66 | foreach ($this->requirements as $name => $requirement) { 67 | if ($name !== $reqName) { 68 | continue; 69 | } 70 | 71 | $found = true; 72 | if (null === $error = $requirement->check($reqValue)) { 73 | continue; 74 | } 75 | 76 | Assert::markTestSkipped($error); 77 | } 78 | 79 | if (!$found && !isset(self::PHPUNIT_REQUIREMENTS[$reqName])) { 80 | throw InvalidAnnotationException::unknownRequirement($reqName); 81 | } 82 | } 83 | 84 | private function addRequirement(Requirement $requirement) : void 85 | { 86 | $this->requirements[$requirement->getName()] = $requirement; 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/Annotation/AnnotationProcessorBuilder.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace PHPUnitExtras\Annotation; 15 | 16 | use PHPUnitExtras\Annotation\PlaceholderResolver\ChainResolver; 17 | use PHPUnitExtras\Annotation\PlaceholderResolver\PlaceholderResolver; 18 | use PHPUnitExtras\Annotation\PlaceholderResolver\TargetClassResolver; 19 | use PHPUnitExtras\Annotation\PlaceholderResolver\TargetMethodResolver; 20 | use PHPUnitExtras\Annotation\PlaceholderResolver\TmpDirResolver; 21 | use PHPUnitExtras\Annotation\Processor\Processor; 22 | use PHPUnitExtras\Annotation\Processor\RequiresProcessor; 23 | use PHPUnitExtras\Annotation\Requirement\ConditionRequirement; 24 | use PHPUnitExtras\Annotation\Requirement\ConstantRequirement; 25 | use PHPUnitExtras\Annotation\Requirement\PackageRequirement; 26 | use PHPUnitExtras\Annotation\Requirement\Requirement; 27 | 28 | final class AnnotationProcessorBuilder 29 | { 30 | /** @var array */ 31 | private $processors = []; 32 | 33 | /** @var array */ 34 | private $requirements = []; 35 | 36 | /** @var array */ 37 | private $placeholderResolvers = []; 38 | 39 | /** @var array */ 40 | private $ignoredAnnotations = []; 41 | 42 | /** @var bool */ 43 | private $ignoreUnknownAnnotations = false; 44 | 45 | public static function fromDefaults() : self 46 | { 47 | return (new self()) 48 | ->ignoreEstablishedAnnotations() 49 | ->addRequirement(ConditionRequirement::fromGlobals()) 50 | ->addRequirement(new ConstantRequirement()) 51 | ->addRequirement(new PackageRequirement()) 52 | ->addPlaceholderResolver(new TargetClassResolver()) 53 | ->addPlaceholderResolver(new TargetMethodResolver()) 54 | ->addPlaceholderResolver(new TmpDirResolver()) 55 | ; 56 | } 57 | 58 | public function addProcessor(Processor $processor) : self 59 | { 60 | if ($processor instanceof RequiresProcessor) { 61 | $this->requirements = $processor->getRequirements() + $this->requirements; 62 | } else { 63 | $this->processors[$processor->getName()] = $processor; 64 | } 65 | 66 | return $this; 67 | } 68 | 69 | public function addRequirement(Requirement $requirement) : self 70 | { 71 | $this->requirements[$requirement->getName()] = $requirement; 72 | 73 | return $this; 74 | } 75 | 76 | public function addPlaceholderResolver(PlaceholderResolver $resolver) : self 77 | { 78 | $this->placeholderResolvers[$resolver->getName()] = $resolver; 79 | 80 | return $this; 81 | } 82 | 83 | public function ignoreUnknownAnnotations(bool $ignore = true) : self 84 | { 85 | $this->ignoreUnknownAnnotations = $ignore; 86 | 87 | return $this; 88 | } 89 | 90 | public function ignoreAnnotation(string $name) : self 91 | { 92 | $this->ignoredAnnotations[$name] = true; 93 | 94 | return $this; 95 | } 96 | 97 | public function ignoreEstablishedAnnotations() : self 98 | { 99 | $this->ignoredAnnotations = EstablishedAnnotationNames::ALL + $this->ignoredAnnotations; 100 | 101 | return $this; 102 | } 103 | 104 | public function build() : AnnotationProcessor 105 | { 106 | $processors = $this->processors; 107 | 108 | if ($this->requirements) { 109 | $requiresAnnotation = new RequiresProcessor($this->requirements); 110 | $processors = [$requiresAnnotation->getName() => $requiresAnnotation] + $processors; 111 | } 112 | 113 | return new AnnotationProcessor( 114 | new ProcessorMap($processors, array_keys($this->ignoredAnnotations), $this->ignoreUnknownAnnotations), 115 | new ChainResolver($this->placeholderResolvers) 116 | ); 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PHPUnit Extras 2 | 3 | [![Quality Assurance](https://github.com/rybakit/phpunit-extras/workflows/QA/badge.svg)](https://github.com/rybakit/phpunit-extras/actions?query=workflow%3AQA) 4 | 5 | This repository contains functionality that makes it easy to create and integrate 6 | your own annotations and expectations into the [PHPUnit](https://phpunit.de/) framework. 7 | In other words, with this library, your tests may look like this: 8 | 9 | ![https://raw.githubusercontent.com/rybakit/phpunit-extras/media/phpunit-extras-example.png](../media/phpunit-extras-example.png?raw=true) 10 | 11 | where: 12 | 1. `MySqlServer ^5.6|^8.0` is a custom requirement 13 | 2. `@sql` is a custom annotation 14 | 3. `%target_method%` is an annotation placeholder 15 | 4. `expectSelectStatementToBeExecutedOnce()` is a custom expectation. 16 | 17 | 18 | ## Table of contents 19 | 20 | * [Installation](#installation) 21 | * [Annotations](#annotations) 22 | * [Processors](#processors) 23 | * [Requires](#requires) 24 | * [Requirements](#requirements) 25 | * [Condition](#condition) 26 | * [Constant](#constant) 27 | * [Package](#package) 28 | * [Placeholders](#placeholders) 29 | * [TargetClass](#targetclass) 30 | * [TargetMethod](#targetmethod) 31 | * [TmpDir](#tmpdir) 32 | * [Creating your own annotation](#creating-your-own-annotation) 33 | * [Expectations](#expectations) 34 | * [Usage example](#usage-example) 35 | * [Advanced example](#advanced-example) 36 | * [Testing](#testing) 37 | * [License](#license) 38 | 39 | 40 | ## Installation 41 | 42 | ```bash 43 | composer require --dev rybakit/phpunit-extras 44 | ``` 45 | 46 | In addition, depending on which functionality you will use, you may need to install the following packages: 47 | 48 | *To use version-related requirements:* 49 | ```bash 50 | composer require --dev composer/semver 51 | ``` 52 | 53 | *To use the "package" requirement:* 54 | ```bash 55 | composer require --dev ocramius/package-versions 56 | ``` 57 | 58 | *To use expression-based requirements and/or expectations:* 59 | ```bash 60 | composer require --dev symfony/expression-language 61 | ``` 62 | 63 | To install everything in one command, run: 64 | ```bash 65 | composer require --dev rybakit/phpunit-extras \ 66 | composer/semver \ 67 | ocramius/package-versions \ 68 | symfony/expression-language 69 | ``` 70 | 71 | 72 | ## Annotations 73 | 74 | PHPUnit supports a variety of annotations, the full list of which can be found [here](https://phpunit.readthedocs.io/en/latest/annotations.html). 75 | With this library, you can easily expand this list by using one of the following options: 76 | 77 | #### Inheriting from the base test case class 78 | 79 | ```php 80 | use PHPUnitExtras\TestCase; 81 | 82 | final class MyTest extends TestCase 83 | { 84 | // ... 85 | } 86 | ``` 87 | 88 | #### Using a trait 89 | 90 | ```php 91 | use PHPUnit\Framework\TestCase; 92 | use PHPUnitExtras\Annotation\Annotations; 93 | 94 | final class MyTest extends TestCase 95 | { 96 | use Annotations; 97 | 98 | protected function setUp() : void 99 | { 100 | $this->processAnnotations(static::class, $this->getName(false) ?? ''); 101 | } 102 | 103 | // ... 104 | } 105 | ``` 106 | 107 | #### Registering an extension 108 | 109 | ```xml 110 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | ``` 121 | 122 | You can then use annotations provided by the library or created by yourself. 123 | 124 | 125 | ### Processors 126 | 127 | The annotation processor is a class that implements the behavior of your annotation. 128 | 129 | > *The library is currently shipped with only the "Required" processor. 130 | > For inspiration and more examples of annotation processors take a look 131 | > at the [tarantool/phpunit-extras](https://github.com/tarantool-php/phpunit-extras#processors) package.* 132 | 133 | 134 | #### Requires 135 | 136 | This processor extends the standard PHPUnit [@requires](https://phpunit.readthedocs.io/en/latest/annotations.html#requires) 137 | annotation by allowing you to add your own requirements. 138 | 139 | ### Requirements 140 | 141 | The library comes with the following requirements: 142 | 143 | #### Condition 144 | 145 | *Format:* 146 | 147 | ``` 148 | @requires condition 149 | ``` 150 | 151 | where `` is an arbitrary [expression](https://symfony.com/doc/current/components/expression_language.html#expression-syntax) 152 | that should be evaluated to the Boolean value of true. By default, you can refer to the following [superglobal variables](https://www.php.net/manual/en/language.variables.superglobals.php) 153 | in expressions: `cookie`, `env`, `get`, `files`, `post`, `request` and `server`. 154 | 155 | *Example:* 156 | 157 | ```php 158 | /** 159 | * @requires condition server.AWS_ACCESS_KEY_ID 160 | * @requires condition server.AWS_SECRET_ACCESS_KEY 161 | */ 162 | final class AwsS3AdapterTest extends TestCase 163 | { 164 | // ... 165 | } 166 | ``` 167 | 168 | You can also define your own variables in expressions: 169 | 170 | ```php 171 | use PHPUnitExtras\Annotation\Requirement\ConditionRequirement; 172 | 173 | // ... 174 | 175 | $context = ['db' => $this->getDbConnection()]; 176 | $annotationProcessorBuilder->addRequirement(new ConditionRequirement($context)); 177 | ``` 178 | 179 | 180 | #### Constant 181 | 182 | *Format:* 183 | 184 | ``` 185 | @requires constant 186 | ``` 187 | where `` is the constant name. 188 | 189 | *Example:* 190 | 191 | ```php 192 | /** 193 | * @requires constant Redis::SERIALIZER_MSGPACK 194 | */ 195 | public function testSerializeToMessagePack() : void 196 | { 197 | // ... 198 | } 199 | ``` 200 | 201 | #### Package 202 | 203 | *Format:* 204 | 205 | ``` 206 | @requires package [] 207 | ``` 208 | where `` is the name of the required package and `` is a composer-like version constraint. 209 | For details on supported constraint formats, please refer to the Composer [documentation](https://getcomposer.org/doc/articles/versions.md#writing-version-constraints). 210 | 211 | *Example:* 212 | 213 | ```php 214 | /** 215 | * @requires package symfony/uid ^5.1 216 | */ 217 | public function testUseUuidAsPrimaryKey() : void 218 | { 219 | // ... 220 | } 221 | ``` 222 | 223 | ### Placeholders 224 | 225 | Placeholders allow you to dynamically include specific values in your annotations. 226 | The placeholder is any text surrounded by the symbol `%`. An annotation can have 227 | any number of placeholders. If the placeholder is unknown, an error will be thrown. 228 | 229 | Below is a list of the placeholders available by default: 230 | 231 | #### TargetClass 232 | 233 | *Example:* 234 | 235 | ```php 236 | namespace App\Tests; 237 | 238 | /** 239 | * @example %target_class% 240 | * @example %target_class_full% 241 | */ 242 | final class FoobarTest extends TestCase 243 | { 244 | // ... 245 | } 246 | ``` 247 | 248 | In the above example, `%target_class%` will be substituted with `FoobarTest` 249 | and `%target_class_full%` will be substituted with `App\Tests\FoobarTest`. 250 | 251 | 252 | #### TargetMethod 253 | 254 | *Example:* 255 | 256 | ```php 257 | /** 258 | * @example %target_method% 259 | * @example %target_method_full% 260 | */ 261 | public function testFoobar() : void 262 | { 263 | // ... 264 | } 265 | ``` 266 | 267 | In the above example, `%target_method%` will be substituted with `Foobar` 268 | and `%target_method_full%` will be substituted with `testFoobar`. 269 | 270 | 271 | #### TmpDir 272 | 273 | *Example:* 274 | 275 | ```php 276 | /** 277 | * @log %tmp_dir%/%target_class%.%target_method%.log testing Foobar 278 | */ 279 | public function testFoobar() : void 280 | { 281 | // ... 282 | } 283 | ``` 284 | 285 | In the above example, `%tmp_dir%` will be substituted with the result 286 | of the [sys_get_temp_dir()](https://www.php.net/manual/en/function.sys-get-temp-dir.php) call. 287 | 288 | 289 | ### Creating your own annotation 290 | 291 | As an example, let's implement the annotation `@sql` from the picture above. To do this, create a processor class 292 | with the name `SqlProcessor`: 293 | 294 | ```php 295 | namespace App\Tests\PhpUnit; 296 | 297 | use PHPUnitExtras\Annotation\Processor\Processor; 298 | 299 | final class SqlProcessor implements Processor 300 | { 301 | private $conn; 302 | 303 | public function __construct(\PDO $conn) 304 | { 305 | $this->conn = $conn; 306 | } 307 | 308 | public function getName() : string 309 | { 310 | return 'sql'; 311 | } 312 | 313 | public function process(string $value) : void 314 | { 315 | $this->conn->exec($value); 316 | } 317 | } 318 | ``` 319 | 320 | That's it. All this processor does is register the `@sql` tag and call `PDO::exec()`, passing everything 321 | that comes after the tag as an argument. In other words, an annotation such as `@sql TRUNCATE TABLE foo` 322 | is equivalent to `$this->conn->exec('TRUNCATE TABLE foo')`. 323 | 324 | Also, just for the purpose of example, let's create a placeholder resolver that replaces `%table_name%` 325 | with a unique table name for a specific test method or/and class. That will allow using dynamic table names 326 | instead of hardcoded ones: 327 | 328 | ```php 329 | namespace App\Tests\PhpUnit; 330 | 331 | use PHPUnitExtras\Annotation\PlaceholderResolver\PlaceholderResolver; 332 | use PHPUnitExtras\Annotation\Target; 333 | 334 | final class TableNameResolver implements PlaceholderResolver 335 | { 336 | public function getName() : string 337 | { 338 | return 'table_name'; 339 | } 340 | 341 | /** 342 | * Replaces all occurrences of "%table_name%" with 343 | * "table_[_]". 344 | */ 345 | public function resolve(string $value, Target $target) : string 346 | { 347 | $tableName = 'table_'.$target->getClassShortName(); 348 | if ($target->isOnMethod()) { 349 | $tableName .= '_'.$target->getMethodShortName(); 350 | } 351 | 352 | return strtr($value, ['%table_name%' => $tableName]); 353 | } 354 | } 355 | ``` 356 | 357 | The only thing left is to register our new annotation: 358 | 359 | ```php 360 | namespace App\Tests; 361 | 362 | use App\Tests\PhpUnit\SqlProcessor; 363 | use App\Tests\PhpUnit\TableNameResolver; 364 | use PHPUnitExtras\Annotation\AnnotationProcessorBuilder; 365 | use PHPUnitExtras\TestCase as BaseTestCase; 366 | 367 | abstract class TestCase extends BaseTestCase 368 | { 369 | protected function createAnnotationProcessorBuilder() : AnnotationProcessorBuilder 370 | { 371 | return parent::createAnnotationProcessorBuilder() 372 | ->addProcessor(new SqlProcessor($this->getConnection())) 373 | ->addPlaceholderResolver(new TableNameResolver()); 374 | } 375 | 376 | protected function getConnection() : \PDO 377 | { 378 | // TODO: Implement getConnection() method. 379 | } 380 | } 381 | ``` 382 | 383 | After that all classes inherited from `App\Tests\TestCase` will be able to use the tag `@sql`. 384 | 385 | > *Don't worry if you forgot to inherit from the base class where your annotations are registered 386 | > or if you made a mistake in the annotation name, the library will warn you about an unknown annotation.* 387 | 388 | As mentioned [earlier](#registering-an-extension), another way to register annotations is through PHPUnit extensions. 389 | As in the example above, you need to override the `createAnnotationProcessorBuilder()` method, 390 | but now for the `AnnotationExtension` class: 391 | 392 | ```php 393 | namespace App\Tests\PhpUnit; 394 | 395 | use PHPUnitExtras\Annotation\AnnotationExtension as BaseAnnotationExtension; 396 | use PHPUnitExtras\Annotation\AnnotationProcessorBuilder; 397 | 398 | class AnnotationExtension extends BaseAnnotationExtension 399 | { 400 | private $dsn; 401 | private $conn; 402 | 403 | public function __construct($dsn = 'mysql:host=localhost;dbname=test') 404 | { 405 | $this->dsn = $dsn; 406 | } 407 | 408 | protected function createAnnotationProcessorBuilder() : AnnotationProcessorBuilder 409 | { 410 | return parent::createAnnotationProcessorBuilder() 411 | ->addProcessor(new SqlProcessor($this->getConnection())) 412 | ->addPlaceholderResolver(new TableNameResolver()); 413 | } 414 | 415 | protected function getConnection() : \PDO 416 | { 417 | return $this->conn ?? $this->conn = new \PDO($this->dsn); 418 | } 419 | } 420 | ``` 421 | After that, register your extension: 422 | 423 | ```xml 424 | 428 | 429 | 430 | 431 | 432 | 433 | 434 | ``` 435 | 436 | To change the default connection settings, pass the new DSN value as an argument: 437 | 438 | ```xml 439 | 440 | 441 | sqlite::memory: 442 | 443 | 444 | ``` 445 | 446 | > *For more information on configuring extensions, please follow this [link](https://phpunit.readthedocs.io/en/latest/extending-phpunit.html#configuring-extensions).* 447 | 448 | 449 | 450 | ## Expectations 451 | 452 | PHPUnit has a number of methods to set up expectations for code executed under test. Probably the most commonly used 453 | are the [expectException*](https://phpunit.readthedocs.io/en/latest/writing-tests-for-phpunit.html#testing-exceptions) 454 | and [expectOutput*](https://phpunit.readthedocs.io/en/latest/writing-tests-for-phpunit.html#testing-output) family of methods. 455 | The library provides the possibility to create your own expectations with ease. 456 | 457 | 458 | ### Usage example 459 | 460 | As an example, let's create an expectation, which verifies that the code under test creates a file. 461 | Let's call it `FileCreatedExpectation`: 462 | 463 | ```php 464 | namespace App\Tests\PhpUnit; 465 | 466 | use PHPUnit\Framework\Assert; 467 | use PHPUnitExtras\Expectation\Expectation; 468 | 469 | final class FileCreatedExpectation implements Expectation 470 | { 471 | private $filename; 472 | 473 | public function __construct(string $filename) 474 | { 475 | Assert::assertFileDoesNotExist($filename); 476 | $this->filename = $filename; 477 | } 478 | 479 | public function verify() : void 480 | { 481 | Assert::assertFileExists($this->filename); 482 | } 483 | } 484 | ``` 485 | 486 | Now, to be able to use this expectation, inherit your test case class from `PHPUnitExtras\TestCase` 487 | (recommended) or include the `PHPUnitExtras\Expectation\Expectations` trait: 488 | 489 | ```php 490 | use PHPUnit\Framework\TestCase; 491 | use PHPUnitExtras\Expectation\Expectations; 492 | 493 | final class MyTest extends TestCase 494 | { 495 | use Expectations; 496 | 497 | protected function tearDown() : void 498 | { 499 | $this->verifyExpectations(); 500 | } 501 | 502 | // ... 503 | } 504 | ``` 505 | After that, call your expectation as shown below: 506 | 507 | ```php 508 | public function testDumpPdfToFile() : void 509 | { 510 | $filename = sprintf('%s/foobar.pdf', sys_get_temp_dir()); 511 | 512 | $this->expect(new FileCreatedExpectation($filename)); 513 | $this->generator->dump($filename); 514 | } 515 | ``` 516 | 517 | For convenience, you can put this statement in a separate method and group your expectations into a trait: 518 | 519 | ```php 520 | namespace App\Tests\PhpUnit; 521 | 522 | use PHPUnitExtras\Expectation\Expectation; 523 | 524 | trait FileExpectations 525 | { 526 | public function expectFileToBeCreated(string $filename) : void 527 | { 528 | $this->expect(new FileCreatedExpectation($filename)); 529 | } 530 | 531 | // ... 532 | 533 | abstract protected function expect(Expectation $expectation) : void; 534 | } 535 | ``` 536 | 537 | ### Advanced example 538 | 539 | Thanks to the Symfony [ExpressionLanguage](https://symfony.com/doc/current/components/expression_language.html) component, 540 | you can create expectations with more complex verification rules without much hassle. 541 | 542 | As an example let's implement the `expectSelectStatementToBeExecutedOnce()` method from the picture above. 543 | To do this, create an expression context that will be responsible for collecting the necessary statistics 544 | on `SELECT` statement calls: 545 | 546 | ```php 547 | namespace App\Tests\PhpUnit; 548 | 549 | use PHPUnitExtras\Expectation\ExpressionContext; 550 | 551 | final class SelectStatementCountContext implements ExpressionContext 552 | { 553 | private $conn; 554 | private $expression; 555 | private $initialValue; 556 | private $finalValue; 557 | 558 | private function __construct(\PDO $conn, string $expression) 559 | { 560 | $this->conn = $conn; 561 | $this->expression = $expression; 562 | $this->initialValue = $this->getValue(); 563 | } 564 | 565 | public static function exactly(\PDO $conn, int $count) : self 566 | { 567 | return new self($conn, "new_count === old_count + $count"); 568 | } 569 | 570 | public static function atLeast(\PDO $conn, int $count) : self 571 | { 572 | return new self($conn, "new_count >= old_count + $count"); 573 | } 574 | 575 | public static function atMost(\PDO $conn, int $count) : self 576 | { 577 | return new self($conn, "new_count <= old_count + $count"); 578 | } 579 | 580 | public function getExpression() : string 581 | { 582 | return $this->expression; 583 | } 584 | 585 | public function getValues() : array 586 | { 587 | if (null === $this->finalValue) { 588 | $this->finalValue = $this->getValue(); 589 | } 590 | 591 | return [ 592 | 'old_count' => $this->initialValue, 593 | 'new_count' => $this->finalValue, 594 | ]; 595 | } 596 | 597 | private function getValue() : int 598 | { 599 | $stmt = $this->conn->query("SHOW GLOBAL STATUS LIKE 'Com_select'"); 600 | $stmt->execute(); 601 | 602 | return (int) $stmt->fetchColumn(1); 603 | } 604 | } 605 | ``` 606 | 607 | Now create a trait which holds all our statement expectations: 608 | 609 | ```php 610 | namespace App\Tests\PhpUnit; 611 | 612 | use PHPUnitExtras\Expectation\Expectation; 613 | use PHPUnitExtras\Expectation\ExpressionExpectation; 614 | 615 | trait SelectStatementExpectations 616 | { 617 | public function expectSelectStatementToBeExecuted(int $count) : void 618 | { 619 | $context = SelectStatementCountContext::exactly($this->getConnection(), $count); 620 | $this->expect(new ExpressionExpectation($context)); 621 | } 622 | 623 | public function expectSelectStatementToBeExecutedOnce() : void 624 | { 625 | $this->expectSelectStatementToBeExecuted(1); 626 | } 627 | 628 | // ... 629 | 630 | abstract protected function expect(Expectation $expectation) : void; 631 | abstract protected function getConnection() : \PDO; 632 | } 633 | ``` 634 | 635 | And finally, include that trait in your test case class: 636 | 637 | ```php 638 | use App\Tests\PhpUnit\SelectStatementExpectations; 639 | use PHPUnitExtras\TestCase; 640 | 641 | final class CacheableRepositoryTest extends TestCase 642 | { 643 | use SelectStatementExpectations; 644 | 645 | public function testFindByIdCachesResultSet() : void 646 | { 647 | $repository = $this->createRepository(); 648 | 649 | $this->expectSelectStatementToBeExecutedOnce(); 650 | 651 | $repository->findById(1); 652 | $repository->findById(1); 653 | } 654 | 655 | // ... 656 | 657 | protected function getConnection() : \PDO 658 | { 659 | // TODO: Implement getConnection() method. 660 | } 661 | } 662 | ``` 663 | 664 | > *For inspiration and more examples of expectations take a look 665 | > at the [tarantool/phpunit-extras](https://github.com/tarantool-php/phpunit-extras#expectations) package.* 666 | 667 | 668 | ## Testing 669 | 670 | Before running tests, the development dependencies must be installed: 671 | 672 | ```bash 673 | composer install 674 | ``` 675 | 676 | Then, to run all the tests: 677 | 678 | ```bash 679 | vendor/bin/phpunit 680 | vendor/bin/phpunit -c phpunit-extension.xml 681 | ``` 682 | 683 | 684 | ## License 685 | 686 | The library is released under the MIT License. See the bundled [LICENSE](LICENSE) file for details. 687 | --------------------------------------------------------------------------------