Search code examples
phplaravelmockingmockery

Extend Mockery::on assertions for common Use Cases


Is extending Mockery for commonly used assertions possible?

Asserting that a mocked function receives an instance of a class AND matching an identifier is cumbersome.

Mockery::on(
  fn ($arg) => $arg instanceof App\Models\User && $arg->id === $user->id
)

Is it possible to...

  1. Simplify this call by extending Mockery (via a Macro?) so that this assertion could be Mockery::onUser($user) or Mockery::onUserOfId($id)?

  2. Pass in multiple arguments like Mockery::onUserOfRole($id, $user->role)?

Note: the $user object instance provided to the Mockery::on call in the Test is not the same instance as is passed to the mocked function being tested, hence checking the type and matching the identifier.

Possible Solution ... that's not flexable

A possible solution we have explored is extending the base TestCase class with a matchModel() function, but this extension is not "elegant". I have considered breaking it out into a Trait, but attaching these functions to all Test Cases, $this->onUserOfRole() seems wrong. The solution also fails or needs to be customized. (Illuminate\Database\Eloquent\Model@is() works, but...) if the Object is not a Laravel Model, then we're back to square one:

# Possible Solution that is not extensible and only works on Models
class TestCase extends Illuminate\Foundation\Testing\TestCase
    protected function matchModel(Model $model)
    {
        return new MockeryClosure(function ($arg) use ($model) {
            if ($model->is($arg)) {
                return true;
            }

            // print custom error message

            return false;
        });
    }
}

Solution

  • The Mockery::on method is just a one-line wrapper around the constructor for the \Mockery\Matcher\Closure class, which in turn defines only two pieces of custom logic:

    • a match method which takes the parameter being examined, and returns true or false
    • a __toString method returning the fixed string '<Closure===true>', presumably for use in error/debug messages

    The actual closure isn't defined directly as a property, it just relies on the constructor inherited from MatcherAbstract which sets an untyped property $_expected, but performs no other logic directly.

    So a replacement for your use case can simply over-ride that constructor with whatever arguments you want, and use them in the match method to implement your test, e.g.

    class MyMatcher extends Mockery\Matcher\MatcherAbstract
    {
        private string $expectedClass;
        private string $keyName;
        private mixed $keyValue;
    
        public function __construct(string $expectedClass, string $keyName, mixed $keyValue)
        {
            $this->expectedClass = $expectedClass;
            $this->keyName = $keyName;
            $this->keyValue = $keyValue;
        }
    
        /**
         * Check if the actual value matches the expected.
         *
         * @param mixed $actual
         * @return bool
         */
        public function match(&$actual)
        {
            return
                is_a($actual, $this->expectedClass, true)
                && 
                $actual->{$this->keyProperty} === $this->keyValue;
        }
    
        /**
         * Return a string representation of this Matcher
         *
         * @return string
         */
        public function __toString()
        {
            return "<Is {$this->expectedClass} and has {$this->keyProperty} of {$this->keyValue}>";
        }
    }
    

    Then this:

    Mockery::on(
      fn ($arg) => $arg instanceof App\Models\User && $arg->id === $user->id
    )
    

    Becomes this:

    new MyMatcher(App\Models\User::class, 'id', $user->id)
    

    If you want to shorten the new MyMatcher further, you can put a function wherever you like that wraps it in the same Mockery::on wraps new \Mockery\Matcher\Closure.

    If you want more complex or configurable tests, all you need to figure out is a way to return a boolean result from match.