DEV Community

Recca Tsai
Recca Tsai

Posted on • Originally published at recca0120.github.io

Write Deterministic PHPUnit Assertions with Mockery::capture

Originally published at recca0120.github.io

How do you write assertions when a method internally generates a random value, making the return value unpredictable?

Random Values Make Tests Unpredictable

Suppose we have a RandomHash class that generates a random number between 1 and 10, then hashes it:

class Hash
{
    public function make($data): string
    {
        return hash_hmac('sha256', $data, false);
    }
}

class RandomHash
{
    public function __construct(public Hash $hash)
    {
    }

    /**
     * @throws \Exception
     */
    public function hash(): string
    {
        $random = md5(random_int(1, 10));

        return $this->hash->make($random);
    }
}
Enter fullscreen mode Exit fullscreen mode

Since random_int returns a different value each time, the result of hash() changes too, making it impossible to write a definitive expected value:

class RandomHashTest extends TestCase
{
    public function test_mockery_capturing_arguments(): void
    {
        $hash = new Hash();
        $randomHash = new RandomHash($hash);

        // no way to write a definitive assertion here
        $actual = $randomHash->hash();
    }
}
Enter fullscreen mode Exit fullscreen mode

Use Mockery::capture to Extract the Intermediate Value

Mockery's Capturing Arguments can store the arguments passed to a method call. Combined with passthru() to let the original method execute normally, we can capture both the intermediate value and the final result:

composer require --dev mockery/mockery
Enter fullscreen mode Exit fullscreen mode
class RandomHashTest extends TestCase
{
    /**
     * @throws \Exception
     */
    public function test_mockery_capturing_arguments(): void
    {
        $hash = Mockery::spy(new Hash());
        // capture the intermediate value; passthru lets make() execute normally
        $hash->allows('make')->with(Mockery::capture($random))->passthru();
        $randomHash = new RandomHash($hash);

        $actual = $randomHash->hash();

        // now $random is known, so we can compute the expected value
        self::assertEquals((new Hash)->make($random), $actual);
    }
}
Enter fullscreen mode Exit fullscreen mode

This way, even with internal randomness, the test can still make a definitive assertion.

Top comments (3)

Collapse
 
xwero profile image
david duymelinck

Wouldn't better test be the return of a string and executing the method multiple times to check if the output is different.

Checking if a random output is equal doesn't make sense.

Collapse
 
recca0120 profile image
Recca Tsai

You're right to question this. I actually realized there's a bug in my example.

Using passthru() makes the assertion tautological — since $actual is already the result of Hash::make($random) via passthru, asserting (new Hash)->make($random) == $actual is always true. It doesn't catch anything.

The correct approach is to use a fixed return value instead:

$hash = Mockery::mock(Hash::class);
$hash->shouldReceive('make')
->with(Mockery::capture($random))
->andReturn('expected-hash');

$randomHash = new RandomHash($hash);
$actual = $randomHash->hash();

self::assertEquals('expected-hash', $actual);

That said, your suggestion (run multiple times, check outputs differ) tests a different thing — whether the method is random. This version tests whether the return value comes directly from Hash::make() without extra manipulation. Both are valid, just for different purposes.

Collapse
 
xwero profile image
david duymelinck

The main reason I made the comment was because it is a gray box test. The problem with gray box tests is that they tend to break more often than black box tests.

While I can see Mockery::capture is a powerful tool. For me it has a niche usage.