Testing Laravel’s Invokable Rules with PHPUnit

I have previously been writing custom Laravel validation rules with explicit passes() and message() methods. However, since Laravel 9, the preferred method is now a little different. The documentation shows making an invokable rule. This syntax is nice and short, but I had to figure out how to test them.

I was able to build a test which successfully asserted when the rule failed, but I couldn't work out a solution for when the rule passed. When an invokable rule passes, nothing is returned from the method itself nor is the $fail closure called. Luckily I came across a post by Freek Van der Herten called How to test Laravel's invokable rules which pointed me in a useful direction. Freek’s article covers using the Pest syntax, but I needed something for PHPUnit.

This is what I came up with;

<?php

namespace Tests\Unit\Rules;

use App\Rules\CustomRule as Rule;
use Illuminate\Contracts\Validation\InvokableRule;
use Tests\Unit\TestCase;

/**
 * @group rule
 * @group custom-rule
 */
class CustomRuleTest extends TestCase
{
    protected Rule $rule;

    /** @test */
    public function implementsClasses(): void
    {
        $contracts = class_implements($this->rule::class);

        $this->assertArrayHasKey(InvokableRule::class, $contracts);
    }

    /** @test */
    public function passes(): void
    {
        $passes = true;
        $attribute = 'email';
        $value = $this->faker->safeEmail();

        $this->app->call($this->rule, [
            'value' => $value,
            'attribute' => $attribute,
            'fail' => static function () use (&$passes): void {
                $passes = false;
            },
        ]);

        $this->assertTrue($passes);
    }

    /** @test */
    public function fails(): void
    {
        $passes = true;
        $attribute = 'email';
        $value = $this->faker->sentence();

        $this->app->call($this->rule, [
            'value' => $value,
            'attribute' => $attribute,
            'fail' => function (string $message) use (&$passes): void {
                $passes = false;
                $this->assertSame('Sorry, the validation rule has failed.', $message);
            },
        ]);

        $this->assertFalse($passes);
    }

    protected function setUp(): void
    {
        parent::setUp();

        $this->rule = new Rule();
    }
}