{"id":15014495,"url":"https://github.com/rybakit/phpunit-extras","last_synced_at":"2025-08-04T14:42:45.784Z","repository":{"id":45671393,"uuid":"247156388","full_name":"rybakit/phpunit-extras","owner":"rybakit","description":"Custom annotations and expectations for PHPUnit.","archived":false,"fork":false,"pushed_at":"2022-07-14T16:51:31.000Z","size":264,"stargazers_count":47,"open_issues_count":0,"forks_count":1,"subscribers_count":5,"default_branch":"master","last_synced_at":"2025-04-05T16:42:21.781Z","etag":null,"topics":["custom-annotations","phpunit","phpunit-assertions","phpunit-extension","phpunit-extras","phpunit-util"],"latest_commit_sha":null,"homepage":"","language":"PHP","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/rybakit.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":".github/FUNDING.yml","license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null},"funding":{"github":["rybakit"]}},"created_at":"2020-03-13T20:30:20.000Z","updated_at":"2025-02-17T08:46:19.000Z","dependencies_parsed_at":"2022-09-06T12:11:43.193Z","dependency_job_id":null,"html_url":"https://github.com/rybakit/phpunit-extras","commit_stats":null,"previous_names":[],"tags_count":6,"template":false,"template_full_name":null,"repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/rybakit%2Fphpunit-extras","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/rybakit%2Fphpunit-extras/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/rybakit%2Fphpunit-extras/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/rybakit%2Fphpunit-extras/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/rybakit","download_url":"https://codeload.github.com/rybakit/phpunit-extras/tar.gz/refs/heads/master","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":248182097,"owners_count":21060893,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["custom-annotations","phpunit","phpunit-assertions","phpunit-extension","phpunit-extras","phpunit-util"],"created_at":"2024-09-24T19:45:41.896Z","updated_at":"2025-04-12T08:08:57.771Z","avatar_url":"https://github.com/rybakit.png","language":"PHP","readme":"# PHPUnit Extras\n\n[![Quality Assurance](https://github.com/rybakit/phpunit-extras/workflows/QA/badge.svg)](https://github.com/rybakit/phpunit-extras/actions?query=workflow%3AQA)\n\nThis repository contains functionality that makes it easy to create and integrate\nyour own annotations and expectations into the [PHPUnit](https://phpunit.de/) framework.\nIn other words, with this library, your tests may look like this:\n\n![https://raw.githubusercontent.com/rybakit/phpunit-extras/media/phpunit-extras-example.png](../media/phpunit-extras-example.png?raw=true)\n\nwhere:\n1. `MySqlServer ^5.6|^8.0` is a custom requirement\n2. `@sql` is a custom annotation\n3. `%target_method%` is an annotation placeholder\n4. `expectSelectStatementToBeExecutedOnce()` is a custom expectation.\n\n\n## Table of contents\n\n * [Installation](#installation)\n * [Annotations](#annotations)\n   * [Processors](#processors)\n     * [Requires](#requires)\n   * [Requirements](#requirements)\n     * [Condition](#condition)\n     * [Constant](#constant)\n     * [Package](#package)\n   * [Placeholders](#placeholders)\n     * [TargetClass](#targetclass)\n     * [TargetMethod](#targetmethod)\n     * [TmpDir](#tmpdir)\n   * [Creating your own annotation](#creating-your-own-annotation)\n * [Expectations](#expectations)\n   * [Usage example](#usage-example)\n   * [Advanced example](#advanced-example)\n * [Testing](#testing)\n * [License](#license)\n\n\n## Installation\n\n```bash\ncomposer require --dev rybakit/phpunit-extras\n```\n\nIn addition, depending on which functionality you will use, you may need to install the following packages:\n\n*To use version-related requirements:*\n```bash\ncomposer require --dev composer/semver\n```\n\n*To use the \"package\" requirement:*\n```bash\ncomposer require --dev ocramius/package-versions\n```\n\n*To use expression-based requirements and/or expectations:*\n```bash\ncomposer require --dev symfony/expression-language\n```\n\nTo install everything in one command, run:\n```bash\ncomposer require --dev rybakit/phpunit-extras \\\n    composer/semver \\\n    ocramius/package-versions \\\n    symfony/expression-language\n```\n\n\n## Annotations\n\nPHPUnit supports a variety of annotations, the full list of which can be found [here](https://phpunit.readthedocs.io/en/latest/annotations.html).\nWith this library, you can easily expand this list by using one of the following options:\n\n#### Inheriting from the base test case class\n\n```php\nuse PHPUnitExtras\\TestCase;\n\nfinal class MyTest extends TestCase\n{\n    // ...\n}\n```\n\n#### Using a trait\n\n```php\nuse PHPUnit\\Framework\\TestCase;\nuse PHPUnitExtras\\Annotation\\Annotations;\n\nfinal class MyTest extends TestCase\n{\n    use Annotations;\n\n    protected function setUp() : void\n    {\n        $this-\u003eprocessAnnotations(static::class, $this-\u003egetName(false) ?? '');\n    }\n\n    // ...\n}\n```\n \n#### Registering an extension\n\n```xml\n\u003cphpunit xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\"\n    xsi:noNamespaceSchemaLocation=\"vendor/phpunit/phpunit/phpunit.xsd\"\n    bootstrap=\"vendor/autoload.php\"\n\u003e\n    \u003c!-- ... --\u003e\n\n    \u003cextensions\u003e\n        \u003cextension class=\"PHPUnitExtras\\Annotation\\AnnotationExtension\" /\u003e\n    \u003c/extensions\u003e\n\u003c/phpunit\u003e\n```\n\nYou can then use annotations provided by the library or created by yourself.\n\n\n### Processors\n\nThe annotation processor is a class that implements the behavior of your annotation.\n\n\u003e *The library is currently shipped with only the \"Required\" processor.\n\u003e For inspiration and more examples of annotation processors take a look\n\u003e at the [tarantool/phpunit-extras](https://github.com/tarantool-php/phpunit-extras#processors) package.*\n\n\n#### Requires\n\nThis processor extends the standard PHPUnit [@requires](https://phpunit.readthedocs.io/en/latest/annotations.html#requires) \nannotation by allowing you to add your own requirements.\n\n### Requirements\n\nThe library comes with the following requirements:\n\n#### Condition\n\n*Format:*\n\n```\n@requires condition \u003ccondition\u003e\n```\n\nwhere `\u003ccondition\u003e` is an arbitrary [expression](https://symfony.com/doc/current/components/expression_language.html#expression-syntax) \nthat 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) \nin expressions: `cookie`, `env`, `get`, `files`, `post`, `request` and `server`.\n\n*Example:*\n\n```php\n/**\n * @requires condition server.AWS_ACCESS_KEY_ID\n * @requires condition server.AWS_SECRET_ACCESS_KEY\n */\nfinal class AwsS3AdapterTest extends TestCase\n{\n    // ...\n}\n```\n\nYou can also define your own variables in expressions:\n\n```php\nuse PHPUnitExtras\\Annotation\\Requirement\\ConditionRequirement;\n\n// ...\n\n$context = ['db' =\u003e $this-\u003egetDbConnection()];\n$annotationProcessorBuilder-\u003eaddRequirement(new ConditionRequirement($context));\n```\n\n\n#### Constant\n\n*Format:*\n\n```\n@requires constant \u003cconstant-name\u003e\n```\nwhere `\u003cconstant-name\u003e` is the constant name.\n\n*Example:*\n\n```php\n/**\n * @requires constant Redis::SERIALIZER_MSGPACK\n */\npublic function testSerializeToMessagePack() : void \n{\n    // ...\n}\n```\n\n#### Package\n\n*Format:*\n\n```\n@requires package \u003cpackage-name\u003e [\u003cversion-constraint\u003e]\n```\nwhere `\u003cpackage-name\u003e` is the name of the required package and `\u003cversion-constraint\u003e` is a composer-like version constraint.\nFor details on supported constraint formats, please refer to the Composer [documentation](https://getcomposer.org/doc/articles/versions.md#writing-version-constraints).\n\n*Example:*\n\n```php\n/**\n * @requires package symfony/uid ^5.1\n */\npublic function testUseUuidAsPrimaryKey() : void \n{\n    // ...\n}\n```\n\n### Placeholders\n\nPlaceholders allow you to dynamically include specific values in your annotations.\nThe placeholder is any text surrounded by the symbol `%`. An annotation can have\nany number of placeholders. If the placeholder is unknown, an error will be thrown.\n\nBelow is a list of the placeholders available by default:\n\n#### TargetClass\n\n*Example:*\n\n```php\nnamespace App\\Tests;\n\n/**\n * @example %target_class%\n * @example %target_class_full%\n */\nfinal class FoobarTest extends TestCase\n{\n    // ...\n}\n```\n\nIn the above example, `%target_class%` will be substituted with `FoobarTest` \nand `%target_class_full%` will be substituted with `App\\Tests\\FoobarTest`.\n\n\n#### TargetMethod\n\n*Example:*\n\n```php\n/**\n * @example %target_method%\n * @example %target_method_full%\n */\npublic function testFoobar() : void \n{\n    // ...\n}\n```\n\nIn the above example, `%target_method%` will be substituted with `Foobar` \nand `%target_method_full%` will be substituted with `testFoobar`.\n\n\n#### TmpDir\n\n*Example:*\n\n```php\n/**\n * @log %tmp_dir%/%target_class%.%target_method%.log testing Foobar\n */\npublic function testFoobar() : void \n{\n    // ...\n}\n```\n\nIn the above example, `%tmp_dir%` will be substituted with the result \nof the [sys_get_temp_dir()](https://www.php.net/manual/en/function.sys-get-temp-dir.php) call.\n\n\n### Creating your own annotation\n\nAs an example, let's implement the annotation `@sql` from the picture above. To do this, create a processor class \nwith the name `SqlProcessor`:\n\n```php\nnamespace App\\Tests\\PhpUnit;\n\nuse PHPUnitExtras\\Annotation\\Processor\\Processor;\n\nfinal class SqlProcessor implements Processor\n{\n    private $conn;\n\n    public function __construct(\\PDO $conn)\n    {\n        $this-\u003econn = $conn;\n    }\n\n    public function getName() : string\n    {\n        return 'sql';\n    }\n\n    public function process(string $value) : void\n    {\n        $this-\u003econn-\u003eexec($value);\n    }\n}\n```\n\nThat's it. All this processor does is register the `@sql` tag and call `PDO::exec()`, passing everything\nthat comes after the tag as an argument. In other words, an annotation such as `@sql TRUNCATE TABLE foo` \nis equivalent to `$this-\u003econn-\u003eexec('TRUNCATE TABLE foo')`.\n\nAlso, just for the purpose of example, let's create a placeholder resolver that replaces `%table_name%`\nwith a unique table name for a specific test method or/and class. That will allow using dynamic table names\ninstead of hardcoded ones:\n\n```php\nnamespace App\\Tests\\PhpUnit;\n\nuse PHPUnitExtras\\Annotation\\PlaceholderResolver\\PlaceholderResolver;\nuse PHPUnitExtras\\Annotation\\Target;\n\nfinal class TableNameResolver implements PlaceholderResolver\n{\n    public function getName() : string\n    {\n        return 'table_name';\n    }\n\n    /**\n     * Replaces all occurrences of \"%table_name%\" with \n     * \"table_\u003cshort-class-name\u003e[_\u003cshort-method-name\u003e]\".\n     */\n    public function resolve(string $value, Target $target) : string\n    {\n        $tableName = 'table_'.$target-\u003egetClassShortName();\n        if ($target-\u003eisOnMethod()) {\n            $tableName .= '_'.$target-\u003egetMethodShortName();\n        }\n\n        return strtr($value, ['%table_name%' =\u003e $tableName]);\n    }\n}\n```\n\nThe only thing left is to register our new annotation:\n\n```php\nnamespace App\\Tests;\n\nuse App\\Tests\\PhpUnit\\SqlProcessor;\nuse App\\Tests\\PhpUnit\\TableNameResolver;\nuse PHPUnitExtras\\Annotation\\AnnotationProcessorBuilder;\nuse PHPUnitExtras\\TestCase as BaseTestCase;\n\nabstract class TestCase extends BaseTestCase\n{\n    protected function createAnnotationProcessorBuilder() : AnnotationProcessorBuilder\n    {\n        return parent::createAnnotationProcessorBuilder()\n            -\u003eaddProcessor(new SqlProcessor($this-\u003egetConnection()))\n            -\u003eaddPlaceholderResolver(new TableNameResolver());\n    }\n\n    protected function getConnection() : \\PDO\n    {\n        // TODO: Implement getConnection() method.\n    }\n}\n```\n\nAfter that all classes inherited from `App\\Tests\\TestCase` will be able to use the tag `@sql`.\n\n\u003e *Don't worry if you forgot to inherit from the base class where your annotations are registered \n\u003e or if you made a mistake in the annotation name, the library will warn you about an unknown annotation.*\n\nAs mentioned [earlier](#registering-an-extension), another way to register annotations is through PHPUnit extensions.\nAs in the example above, you need to override the `createAnnotationProcessorBuilder()` method,\nbut now for the `AnnotationExtension` class:\n\n```php\nnamespace App\\Tests\\PhpUnit;\n\nuse PHPUnitExtras\\Annotation\\AnnotationExtension as BaseAnnotationExtension;\nuse PHPUnitExtras\\Annotation\\AnnotationProcessorBuilder;\n\nclass AnnotationExtension extends BaseAnnotationExtension\n{\n    private $dsn;\n    private $conn;\n\n    public function __construct($dsn = 'mysql:host=localhost;dbname=test')\n    {\n        $this-\u003edsn = $dsn;\n    }\n\n    protected function createAnnotationProcessorBuilder() : AnnotationProcessorBuilder\n    {\n        return parent::createAnnotationProcessorBuilder()\n            -\u003eaddProcessor(new SqlProcessor($this-\u003egetConnection()))\n            -\u003eaddPlaceholderResolver(new TableNameResolver());\n    }\n\n    protected function getConnection() : \\PDO\n    {\n        return $this-\u003econn ?? $this-\u003econn = new \\PDO($this-\u003edsn);\n    }\n}\n```\nAfter that, register your extension:\n\n```xml\n\u003cphpunit xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\"\n    xsi:noNamespaceSchemaLocation=\"vendor/phpunit/phpunit/phpunit.xsd\"\n    bootstrap=\"vendor/autoload.php\"\n\u003e\n    \u003c!-- ... --\u003e\n\n    \u003cextensions\u003e\n        \u003cextension class=\"App\\Tests\\PhpUnit\\AnnotationExtension\" /\u003e\n    \u003c/extensions\u003e\n\u003c/phpunit\u003e\n```\n\nTo change the default connection settings, pass the new DSN value as an argument:\n\n```xml\n\u003cextension class=\"App\\Tests\\PhpUnit\\AnnotationExtension\"\u003e\n    \u003carguments\u003e\n        \u003cstring\u003esqlite::memory:\u003c/string\u003e\n    \u003c/arguments\u003e\n\u003c/extension\u003e\n```\n\n\u003e *For more information on configuring extensions, please follow this [link](https://phpunit.readthedocs.io/en/latest/extending-phpunit.html#configuring-extensions).*\n\n\n\n## Expectations\n\nPHPUnit has a number of methods to set up expectations for code executed under test. Probably the most commonly used\nare the [expectException*](https://phpunit.readthedocs.io/en/latest/writing-tests-for-phpunit.html#testing-exceptions)\nand [expectOutput*](https://phpunit.readthedocs.io/en/latest/writing-tests-for-phpunit.html#testing-output) family of methods.\nThe library provides the possibility to create your own expectations with ease.\n\n\n### Usage example\n\nAs an example, let's create an expectation, which verifies that the code under test creates a file.\nLet's call it `FileCreatedExpectation`:\n\n```php\nnamespace App\\Tests\\PhpUnit;\n\nuse PHPUnit\\Framework\\Assert;\nuse PHPUnitExtras\\Expectation\\Expectation;\n\nfinal class FileCreatedExpectation implements Expectation\n{\n    private $filename;\n\n    public function __construct(string $filename)\n    {\n        Assert::assertFileDoesNotExist($filename);\n        $this-\u003efilename = $filename;\n    }\n\n    public function verify() : void\n    {\n        Assert::assertFileExists($this-\u003efilename);\n    }\n}\n```\n\nNow, to be able to use this expectation, inherit your test case class from `PHPUnitExtras\\TestCase`\n(recommended) or include the `PHPUnitExtras\\Expectation\\Expectations` trait:\n\n```php\nuse PHPUnit\\Framework\\TestCase;\nuse PHPUnitExtras\\Expectation\\Expectations;\n\nfinal class MyTest extends TestCase\n{\n    use Expectations;\n\n    protected function tearDown() : void\n    {\n        $this-\u003everifyExpectations();\n    }\n\n    // ...\n}\n```\nAfter that, call your expectation as shown below:\n\n```php\npublic function testDumpPdfToFile() : void\n{\n    $filename = sprintf('%s/foobar.pdf', sys_get_temp_dir());\n\n    $this-\u003eexpect(new FileCreatedExpectation($filename));\n    $this-\u003egenerator-\u003edump($filename);\n}\n```\n\nFor convenience, you can put this statement in a separate method and group your expectations into a trait:\n\n```php\nnamespace App\\Tests\\PhpUnit;\n\nuse PHPUnitExtras\\Expectation\\Expectation;\n\ntrait FileExpectations\n{\n    public function expectFileToBeCreated(string $filename) : void\n    {\n        $this-\u003eexpect(new FileCreatedExpectation($filename));\n    }\n\n    // ...\n\n    abstract protected function expect(Expectation $expectation) : void;\n}\n```\n\n### Advanced example\n\nThanks to the Symfony [ExpressionLanguage](https://symfony.com/doc/current/components/expression_language.html) component, \nyou can create expectations with more complex verification rules without much hassle.\n\nAs an example let's implement the `expectSelectStatementToBeExecutedOnce()` method from the picture above.\nTo do this, create an expression context that will be responsible for collecting the necessary statistics \non `SELECT` statement calls:\n\n```php\nnamespace App\\Tests\\PhpUnit;\n\nuse PHPUnitExtras\\Expectation\\ExpressionContext;\n\nfinal class SelectStatementCountContext implements ExpressionContext\n{\n    private $conn;\n    private $expression;\n    private $initialValue;\n    private $finalValue;\n\n    private function __construct(\\PDO $conn, string $expression)\n    {\n        $this-\u003econn = $conn;\n        $this-\u003eexpression = $expression;\n        $this-\u003einitialValue = $this-\u003egetValue();\n    }\n\n    public static function exactly(\\PDO $conn, int $count) : self\n    {\n        return new self($conn, \"new_count === old_count + $count\");\n    }\n\n    public static function atLeast(\\PDO $conn, int $count) : self\n    {\n        return new self($conn, \"new_count \u003e= old_count + $count\");\n    }\n\n    public static function atMost(\\PDO $conn, int $count) : self\n    {\n        return new self($conn, \"new_count \u003c= old_count + $count\");\n    }\n\n    public function getExpression() : string\n    {\n        return $this-\u003eexpression;\n    }\n\n    public function getValues() : array\n    {\n        if (null === $this-\u003efinalValue) {\n            $this-\u003efinalValue = $this-\u003egetValue();\n        }\n\n        return [\n            'old_count' =\u003e $this-\u003einitialValue,\n            'new_count' =\u003e $this-\u003efinalValue,\n        ];\n    }\n\n    private function getValue() : int\n    {\n        $stmt = $this-\u003econn-\u003equery(\"SHOW GLOBAL STATUS LIKE 'Com_select'\");\n        $stmt-\u003eexecute();\n\n        return (int) $stmt-\u003efetchColumn(1);\n    }\n}\n```\n\nNow create a trait which holds all our statement expectations:\n\n```php\nnamespace App\\Tests\\PhpUnit;\n\nuse PHPUnitExtras\\Expectation\\Expectation;\nuse PHPUnitExtras\\Expectation\\ExpressionExpectation;\n\ntrait SelectStatementExpectations\n{\n    public function expectSelectStatementToBeExecuted(int $count) : void\n    {\n        $context = SelectStatementCountContext::exactly($this-\u003egetConnection(), $count);\n        $this-\u003eexpect(new ExpressionExpectation($context));\n    }\n\n    public function expectSelectStatementToBeExecutedOnce() : void\n    {\n        $this-\u003eexpectSelectStatementToBeExecuted(1);\n    }\n\n    // ...\n\n    abstract protected function expect(Expectation $expectation) : void;\n    abstract protected function getConnection() : \\PDO;\n}\n```\n\nAnd finally, include that trait in your test case class:\n\n```php\nuse App\\Tests\\PhpUnit\\SelectStatementExpectations;\nuse PHPUnitExtras\\TestCase;\n\nfinal class CacheableRepositoryTest extends TestCase\n{\n    use SelectStatementExpectations;\n\n    public function testFindByIdCachesResultSet() : void\n    {\n        $repository = $this-\u003ecreateRepository();\n\n        $this-\u003eexpectSelectStatementToBeExecutedOnce();\n\n        $repository-\u003efindById(1);\n        $repository-\u003efindById(1);\n    }\n\n    // ...\n\n    protected function getConnection() : \\PDO\n    {\n        // TODO: Implement getConnection() method.\n    }\n}\n```\n\n\u003e *For inspiration and more examples of expectations take a look\n\u003e at the [tarantool/phpunit-extras](https://github.com/tarantool-php/phpunit-extras#expectations) package.*\n\n\n## Testing\n\nBefore running tests, the development dependencies must be installed:\n\n```bash\ncomposer install\n```\n\nThen, to run all the tests:\n\n```bash\nvendor/bin/phpunit\nvendor/bin/phpunit -c phpunit-extension.xml\n```\n\n\n## License\n\nThe library is released under the MIT License. See the bundled [LICENSE](LICENSE) file for details.\n","funding_links":["https://github.com/sponsors/rybakit"],"categories":[],"sub_categories":[],"project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Frybakit%2Fphpunit-extras","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Frybakit%2Fphpunit-extras","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Frybakit%2Fphpunit-extras/lists"}