Skip to content

Repository files navigation

PHPUnit Extras

Quality Assurance

This repository contains functionality that makes it easy to create custom attributes and expectations and use them with the PHPUnit framework. In other words, with this library, your tests may look like this:

use App\Tests\Attribute\RequiresMySqlServer;
use App\Tests\Attribute\Sql;
use PHPUnitExtras\TestCase;

#[RequiresMySqlServer('^5.6|^8.0')]
final class CacheableRepositoryTest extends TestCase
{
    #[Sql('DROP TABLE IF EXISTS %target_method%')]
    #[Sql('CREATE TABLE %target_method% (id INT UNSIGNED PRIMARY KEY)')]
    #[Sql('INSERT INTO %target_method% (id) VALUES (1)')]
    public function testFindByIdCachesResultSet() : void
    {
        $tableName = $this->resolvePlaceholders('%target_method%');
        $repository = $this->createRepository($tableName);

        $this->expectSelectStatementToBeExecutedOnce();

        $repository->findById(1);
        $repository->findById(1);
    }
}

Here:

  • #[RequiresMySqlServer('^5.6|^8.0')] is a custom requirement.
  • #[Sql(...)] is a custom attribute.
  • %target_method% is an attribute placeholder.
  • expectSelectStatementToBeExecutedOnce() is a custom expectation.

Table of contents

Installation

composer require --dev rybakit/phpunit-extras

Depending on which functionality you use, you may also need to install these packages:

To use version-related requirements:

composer require --dev composer/semver

To use the package requirement: Composer 2 is required for the built-in package version lookup used by the package requirement.

To use expression-based requirements and/or expectations:

composer require --dev symfony/expression-language

To install everything in one command, run:

composer require --dev rybakit/phpunit-extras \
    composer/semver \
    symfony/expression-language

Attributes

PHPUnit supports a variety of attributes, the full list of which can be found in the PHPUnit manual. With this library, you can easily expand this list by using one of the following options:

Inheriting from the base test case class

use PHPUnitExtras\TestCase;

final class MyTest extends TestCase
{
    // ...
}

Using a trait

use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\Before;
use PHPUnitExtras\Attribute\Attributes;

final class MyTest extends TestCase
{
    use Attributes;

    #[Before]
    protected function processTestAttributesBeforeTest() : void
    {
        $this->processTestAttributes(static::class, $this->name());
    }

    // ...
}

Registering an extension

<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
    bootstrap="vendor/autoload.php"
>
    <!-- ... -->

    <extensions>
        <bootstrap class="PHPUnitExtras\Attribute\AttributeExtension" />
    </extensions>
</phpunit>

You can then use attributes provided by the library or created by yourself.

Requirements

The library comes with the following requirements:

Condition

Format:

#[RequiresIf('<condition>')]

where <condition> is an arbitrary expression that should evaluate to true. By default, you can refer to the following superglobal variables in expressions: cookie, env, get, files, post, request and server.

Example:

use PHPUnitExtras\Attribute\RequiresIf;

#[RequiresIf('server.AWS_ACCESS_KEY_ID')]
#[RequiresIf('server.AWS_SECRET_ACCESS_KEY')]
final class AwsS3AdapterTest extends TestCase
{
    // ...
}

You can also define your own variables in expressions:

use PHPUnitExtras\Attribute\Requirement\IfRequirement;

// ...

$context = ['db' => $this->getDbConnection()];
$attributeProcessorBuilder->addRequirement(new IfRequirement($context));

For a custom requirement, define its attribute and a Requirement that handles that attribute class:

namespace App\Tests;

use PHPUnitExtras\Attribute\ProcessableAttribute;
use PHPUnitExtras\Attribute\PlaceholderResolver\PlaceholderResolver;
use PHPUnitExtras\Attribute\Requirement\Requirement;
use PHPUnitExtras\Attribute\Target;

#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
final class RequiresFeature implements ProcessableAttribute
{
    public function __construct(public readonly string $feature)
    {
    }
}

final class FeatureRequirement implements Requirement
{
    public function getAttributeClass() : string
    {
        return RequiresFeature::class;
    }

    public function check(ProcessableAttribute $attribute, Target $target, PlaceholderResolver $placeholderResolver) : ?string
    {
        if (!$attribute instanceof RequiresFeature) {
            throw new \InvalidArgumentException('FeatureRequirement only handles RequiresFeature attributes');
        }

        $feature = $placeholderResolver->resolve($attribute->feature, $target);

        return FeatureFlags::isEnabled($feature)
            ? null
            : \sprintf('Feature "%s" is required', $feature);
    }
}

Register the requirement in your test case builder with $builder->addRequirement(new FeatureRequirement()), then use #[RequiresFeature('new-checkout')]. The requirement gets the typed attribute, test target, and placeholder resolver. Return null to run the test; return a message to skip it.

Constant

Format: #[RequiresConstant('<constant-name>')] where <constant-name> is the constant name.

Example:

use PHPUnitExtras\Attribute\RequiresConstant;

#[RequiresConstant('Redis::SERIALIZER_MSGPACK')]
public function testSerializeToMessagePack() : void 
{
    // ...
}

Package

Format: #[RequiresPackage('<package-name> [<version-constraint>]')] where <package-name> is the required package name and <version-constraint> is a Composer-style version constraint. See the Composer documentation for supported constraint formats.

Example:

use PHPUnitExtras\Attribute\RequiresPackage;

#[RequiresPackage('symfony/uid ^5.1')]
public function testUseUuidAsPrimaryKey() : void 
{
    // ...
}

Placeholders

Placeholders let you include values that depend on the target test in string arguments to custom attributes. A placeholder is any text surrounded by %. An error is thrown for unknown placeholders.

Below is a list of the placeholders available by default:

TargetClass

Example:

namespace App\Tests;

#[Example('%target_class%')]
#[Example('%target_class_full%')]
final class FoobarTest extends TestCase
{
    // ...
}

In the example above, %target_class% is replaced with FoobarTest, and %target_class_full% is replaced with App\Tests\FoobarTest.

TargetMethod

Example:

#[Example('%target_method%')]
#[Example('%target_method_full%')]
public function testFoobar() : void 
{
    // ...
}

In the example above, %target_method% is replaced with Foobar, and %target_method_full% is replaced with testFoobar.

TmpDir

Example:

#[Log('%tmp_dir%/%target_class%.%target_method%.log testing Foobar')]
public function testFoobar() : void 
{
    // ...
}

In the example above, %tmp_dir% is replaced with the result of the sys_get_temp_dir() call.

Creating your own attribute

As an example, let's implement a #[Sql(...)] attribute. First, create a processor class named SqlProcessor:

namespace App\Tests\PhpUnit;

use PHPUnitExtras\Attribute\Processor\Processor;
use PHPUnitExtras\Attribute\ProcessableAttribute;
use PHPUnitExtras\Attribute\PlaceholderResolver\PlaceholderResolver;
use PHPUnitExtras\Attribute\Target;

final class SqlProcessor implements Processor
{
    private $conn;

    public function __construct(\PDO $conn)
    {
        $this->conn = $conn;
    }

    public function getAttributeClasses() : array
    {
        return [Sql::class];
    }

    public function process(ProcessableAttribute $attribute, Target $target, PlaceholderResolver $placeholderResolver) : void
    {
        \assert($attribute instanceof Sql);
        $sql = $placeholderResolver->resolve($attribute->sql, $target);
        $this->conn->exec($sql);
    }
}

The processor declares which attribute class it handles, resolves placeholders in its SQL, and calls PDO::exec(). An attribute such as #[Sql('TRUNCATE TABLE foo')] is equivalent to $this->conn->exec('TRUNCATE TABLE foo').

Next, create the attribute class. Its class is the key the processor registers for:

namespace App\Tests\PhpUnit;

use PHPUnitExtras\Attribute\ProcessableAttribute;

#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
final class Sql implements ProcessableAttribute
{
    public function __construct(public readonly string $sql)
    {
    }
}

The processor can use the placeholder resolver it receives to replace %table_name% with a unique table name for a specific test method or class. This lets you use dynamic table names instead of hard-coded ones:

namespace App\Tests\PhpUnit;

use PHPUnitExtras\Attribute\PlaceholderResolver\PlaceholderResolver;
use PHPUnitExtras\Attribute\Target;

final class TableNameResolver implements PlaceholderResolver
{
    public function getName() : string
    {
        return 'table_name';
    }

    /**
     * Replaces all occurrences of "%table_name%" with 
     * "table_<short-class-name>[_<short-method-name>]".
     */
    public function resolve(string $value, Target $target) : string
    {
        $tableName = 'table_'.$target->getClassShortName();
        if ($target->isOnMethod()) {
            $tableName .= '_'.$target->getMethodShortName();
        }

        return strtr($value, ['%table_name%' => $tableName]);
    }
}

The only thing left is to register our new processor:

namespace App\Tests;

use App\Tests\PhpUnit\SqlProcessor;
use App\Tests\PhpUnit\TableNameResolver;
use PHPUnitExtras\Attribute\AttributeProcessorBuilder;
use PHPUnitExtras\TestCase as BaseTestCase;

abstract class TestCase extends BaseTestCase
{
    protected function createAttributeProcessorBuilder() : AttributeProcessorBuilder
    {
        return parent::createAttributeProcessorBuilder()
            ->addProcessor(new SqlProcessor($this->getConnection()))
            ->addPlaceholderResolver(new TableNameResolver());
    }

    protected function getConnection() : \PDO
    {
        // TODO: Implement getConnection() method.
    }
}

After that, all classes that extend App\Tests\TestCase can use #[Sql(...)].

If no processor is registered for an attribute class, the library throws an InvalidAttributeException.

As mentioned earlier, you can also register attributes through PHPUnit extensions. To do this, override the createAttributeProcessorBuilder() method in your AttributeExtension class:

namespace App\Tests\PhpUnit;

use PHPUnitExtras\Attribute\AttributeExtension as BaseAttributeExtension;
use PHPUnitExtras\Attribute\AttributeProcessorBuilder;
use PHPUnit\Runner\Extension\Facade;
use PHPUnit\Runner\Extension\ParameterCollection;
use PHPUnit\TextUI\Configuration\Configuration;

class AttributeExtension extends BaseAttributeExtension
{
    private string $dsn = 'mysql:host=localhost;dbname=test';
    private ?\PDO $conn = null;

    public function bootstrap(Configuration $configuration, Facade $facade, ParameterCollection $parameters) : void
    {
        if ($parameters->has('dsn')) {
            $this->dsn = $parameters->get('dsn');
        }

        parent::bootstrap($configuration, $facade, $parameters);
    }

    protected function createAttributeProcessorBuilder() : AttributeProcessorBuilder
    {
        return parent::createAttributeProcessorBuilder()
            ->addProcessor(new SqlProcessor($this->getConnection()))
            ->addPlaceholderResolver(new TableNameResolver());
    }

    protected function getConnection() : \PDO
    {
        return $this->conn ?? $this->conn = new \PDO($this->dsn);
    }
}

Then register your extension:

	<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
	    xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.5/phpunit.xsd"
	    bootstrap="vendor/autoload.php"
	>
    <!-- ... -->

	    <extensions>
	        <bootstrap class="App\Tests\PhpUnit\AttributeExtension" />
	    </extensions>
	</phpunit>
	```

To change the default connection settings, pass the new DSN value as a parameter:

```xml
<bootstrap class="App\Tests\PhpUnit\AttributeExtension">
    <parameter name="dsn" value="sqlite::memory:" />
</bootstrap>

For more information on configuring extensions, please refer to the PHPUnit manual.

Expectations

PHPUnit provides several methods for setting expectations about code under test. The most commonly used are the expectException* and expectOutput* methods. This library also lets you create custom expectations.

Usage example

As an example, let's create an expectation that verifies the code under test creates a file. Let's call it FileCreatedExpectation:

namespace App\Tests\PhpUnit;

use PHPUnit\Framework\Assert;
use PHPUnitExtras\Expectation\Expectation;

final class FileCreatedExpectation implements Expectation
{
    private $filename;

    public function __construct(string $filename)
    {
        Assert::assertFileDoesNotExist($filename);
        $this->filename = $filename;
    }

    public function verify() : void
    {
        Assert::assertFileExists($this->filename);
    }
}

To use this expectation, extend PHPUnitExtras\TestCase (recommended) or include the PHPUnitExtras\Expectation\Expectations trait in your test case:

use PHPUnit\Framework\TestCase;
use PHPUnitExtras\Expectation\Expectations;

final class MyTest extends TestCase
{
    use Expectations;

    protected function tearDown() : void
    {
        $this->verifyExpectations();
    }

    // ...
}

Then use the expectation as shown below:

public function testDumpPdfToFile() : void
{
    $filename = sprintf('%s/foobar.pdf', sys_get_temp_dir());

    $this->expect(new FileCreatedExpectation($filename));
    $this->generator->dump($filename);
}

For convenience, you can put this statement in a separate method and group your expectations into a trait:

namespace App\Tests\PhpUnit;

use PHPUnitExtras\Expectation\Expectation;

trait FileExpectations
{
    public function expectFileToBeCreated(string $filename) : void
    {
        $this->expect(new FileCreatedExpectation($filename));
    }

    // ...

    abstract protected function expect(Expectation $expectation) : void;
}

Advanced example

The Symfony ExpressionLanguage component lets you create expectations with more complex verification rules.

As an example, let's implement the expectSelectStatementToBeExecutedOnce() method mentioned above. First, create an expression context that collects statistics on SELECT statement calls:

namespace App\Tests\PhpUnit;

use PHPUnitExtras\Expectation\ExpressionContext;

final class SelectStatementCountContext implements ExpressionContext
{
    private $conn;
    private $expression;
    private $initialValue;
    private $finalValue;

    private function __construct(\PDO $conn, string $expression)
    {
        $this->conn = $conn;
        $this->expression = $expression;
        $this->initialValue = $this->getValue();
    }

    public static function exactly(\PDO $conn, int $count) : self
    {
        return new self($conn, "new_count === old_count + $count");
    }

    public static function atLeast(\PDO $conn, int $count) : self
    {
        return new self($conn, "new_count >= old_count + $count");
    }

    public static function atMost(\PDO $conn, int $count) : self
    {
        return new self($conn, "new_count <= old_count + $count");
    }

    public function getExpression() : string
    {
        return $this->expression;
    }

    public function getValues() : array
    {
        if (null === $this->finalValue) {
            $this->finalValue = $this->getValue();
        }

        return [
            'old_count' => $this->initialValue,
            'new_count' => $this->finalValue,
        ];
    }

    private function getValue() : int
    {
        $stmt = $this->conn->query("SHOW GLOBAL STATUS LIKE 'Com_select'");
        $stmt->execute();

        return (int) $stmt->fetchColumn(1);
    }
}

Now create a trait which holds all our statement expectations:

namespace App\Tests\PhpUnit;

use PHPUnitExtras\Expectation\Expectation;
use PHPUnitExtras\Expectation\ExpressionExpectation;

trait SelectStatementExpectations
{
    public function expectSelectStatementToBeExecuted(int $count) : void
    {
        $context = SelectStatementCountContext::exactly($this->getConnection(), $count);
        $this->expect(new ExpressionExpectation($context));
    }

    public function expectSelectStatementToBeExecutedOnce() : void
    {
        $this->expectSelectStatementToBeExecuted(1);
    }

    // ...

    abstract protected function expect(Expectation $expectation) : void;
    abstract protected function getConnection() : \PDO;
}

And finally, include that trait in your test case class:

use App\Tests\PhpUnit\SelectStatementExpectations;
use PHPUnitExtras\TestCase;

final class CacheableRepositoryTest extends TestCase
{
    use SelectStatementExpectations;

    public function testFindByIdCachesResultSet() : void
    {
        $repository = $this->createRepository();

        $this->expectSelectStatementToBeExecutedOnce();

        $repository->findById(1);
        $repository->findById(1);
    }

    // ...

    protected function getConnection() : \PDO
    {
        // TODO: Implement getConnection() method.
    }
}

For inspiration and more examples of expectations take a look at the tarantool/phpunit-extras package.

Testing

Before running tests, the development dependencies must be installed:

composer install

Then, to run all the tests:

vendor/bin/phpunit
vendor/bin/phpunit -c phpunit-extension.xml

License

The library is released under the MIT License. See the bundled LICENSE file for details.

About

Custom annotations and expectations for PHPUnit.

Topics

Resources

Stars

47 stars

Watchers

4 watching

Forks

Releases

Sponsor this project

Packages

Used by

Contributors

Languages