Writing tests is sometimes a hard an time consuming task. Especially if you have functionality that cannot be easily tested with your testing framework. phpspec provides an awesome set of so called matchers. But sometimes the predefined matchers are not useful or suitable for implementing a certain test case.

In this article I will use a very trivial example for a custom matcher. Let’s asume you have some sort of random mechanism that outputs an integer in the range [1,5] or a random string from a list. It would be cool to have a matcher to do a call like $this->something()->shouldBeAnyOf(1,2,3,4,5) or $this-something()->shouldBeAnyOf('string1', 'string2', 'string3').

Inline matchers

The first approach is a very simple one. In a phpspec specification file you have the possibility to define a method called getMatchers().This method will return an array of custom matchers for this specification file. The key of each array entry is the matcher’s method signature name and the value is a callable that will get executed when the matcher is used. This callable gets at least one parameter, the $subject, this is the value returned from the method call. If your matcher uses input parameters, these will be added after the $subject parameter.

public function getMatchers()
{
    return [
        "beAnyOf" => function ($subject, ...$list) {
            if (!in_array($subject, $list)) {
                throw new FailureException(
                    sprintf(
                        'the return value "%s" is not contained in "%s"',
                        $subject,
                        implode(', ', $list);
                    )
                );
            }
            return true;
        })
    ];
}

Ok, now that we have implemented our inline matcher we can use it by calling:

$this->method()->shouldBeAnyOf(1, 2, 3);

If $this->method() returns the value 1, 2 or 3 the test will pass, otherwise a error message like the return value "4" is not contained in "1, 2, 3" will be printed.

This approach is valid for single-use matchers but when it comes to reusable matchers another approach is used.

Reusable matchers

To be able to share matchers across specification files and projects you need to extract the matcher logic into a phpspec extension. Writing a phpspec extension is dead simple but not well documented. I will demonstrate the plugin development with the matcher example from above.

To implement a plugin with a custom matcher you basically need two files. One for bootstrapping the extension and a second for the matcher implementation.

The extension class

The entry point of a phpspec extension is a class that implements the PhpSpec\Extension interface. This interface defines the method load which is used for bootstrapping the extension. The method accepts two parameters, the ServiceContainer and an array of additional parameters.

For our simple example it will be enough to use the ServiceContainer for registering our matcher. This can be done with the following code.

class CustomExtension implements PhpSpec\Extension
{
    public function load(ServiceContainer $container, array $params)
    {
        $container->define(
            'custom.matchers.be_any_of',
            function ($c) {
                return new BeAnyOfMatcher();
            },
            ['matchers']
        );
    }
}

The matcher class

The matcher implementation, in our case BeAnyOfMatcher, must implement the PhpSpec\Matcher interface. This interface defines four methods.

supports

The method accepts 3 arguments, the matcher name, the subject (the return value of the method call) and an array of arguments. The purpose of this message is to check if the given parameters are suitable for the matcher. In our example we want the matcher name to be equal to beAnyOf and there should be at least one matcher argument.

public function supports($name, $subject, array $arguments)
{
    return $name === 'beAnyOf' && count($arguments) > 0;
}

positiveMatch

This will be the method that gets called when shouldBeAnyOf is used in a test case. The method receives the same three arguments as the supports method. The method should check the positive case (the $subject should be contained in the $arguments array) and if the positive case is not matched, it should throw an exception.

public function positiveMatch($name, $subject, array $arguments)
{
    if (!in_array($subject, $arguments)) {
        throw new FailureException(
            sprintf(
                'the return value "%s" should be any of "%s"',
                $subject,
                implode(', ', $arguments)
            )
        );
    }
}

negativeMatch

This will be the method that gets called when shouldNotBeAnyOf is used. The method receives the same parameters as the two methods before. The method should evaluate the negative case ($subject is not contained in the $arguments array) and if it is not matched should throw an exception.

public function negativeMatch($name, $subject, array $arguments)
{
    if (in_array($subject, $arguments)) {
            throw new FailureException(
                sprintf(
                    'the return value "%s" should not be any of "%s"',
                    $subject,
                    implode(', ', $arguments)
                )
            );
        }
}

getPriority

This method returns an integer priority value that is used for some sort of matcher ranking. E.g. Matcher A should be evaluated before Matcher B –> Priority of A is greater than of B.

Using the extension

To be able to use the extension above we need to instruct phpspec to load the main entry point of our extension. To do so you simply add the following section to your phpspec.yml.

extensions:
    CustomNamespace\CustomExtension: ~

Wrap-Up

As you can see writing a custom matcher for phpspec is dead simple. You put the code either inline in the specification file or extract it into its own package. If recently started to collect a (hopefully) useful set of custom matchers in the phpspec-matchers package. This package will give you an idea about how to glue together the things described above.

If you have any comments on the package or find any bugs or enhancements, please feel free to file an issue or send a pull request.

Resources