Mockeryでメソッドの引数にオブジェクトを渡したい場合


最近、Mockery でモック作ろうとしてハマったので、メモ。

要約

  • Mockery の with() にオブジェクトを指定するとき
    • with($obj) とした場合は厳密比較( === )になってしまう。
    • \Hamcrest\Matchers::equalTo() を使えば緩やかな比較( == )になる。

環境

  • PHP 7.3.7
  • Laravel 5.8.29
    • PHPUnit 7.5.14
    • Mockery 1.2.2

前提

例えば、以下のコントローラをテストする際に、コントローラから呼んでいる ProductService::fetchProduct() をモックにしたいとします。

class ProductController extends Controller
{
    public function fetchProduct(int $paramProductId, ProductService $service)
    {
        $productId = new ProductId($paramProductId);

        // この fetchProduct() をモックにしたい
        $product = $service->fetchProduct($productId);

        return view('product_detail');
    }
}

ProductService::fetchProduct() の第1引数は、 クラス ProductId のインスタンスを受け取る想定です。1

class ProductService
{
    public function fetchProduct(ProductId $productId): Product
    {
        return $this->productRepo->fetch($productId);
    }
}

NG

さて、ここで以下のようなテストコードを書いてみます。
with(new ProductId(1)) で、 ProductId のインスタンスを指定していることに注意してください。

$this->mock(ProductService::class, function($mock) use ($product) {
     $mock->shouldReceive('fetchProduct')
         ->once()
         ->with(new ProductId(1))
         ->andReturn($product);
 })

このテストコードを実行すると、以下のようなエラーがログ出力されます。2

[2019-09-16 02:46:05] local.ERROR: No matching handler found for Mockery_2_App_Application_Services_ProductManage_ProductService::fetchProduct(object(App\Package\ProductManage\Model\ProductId)). 
Either the method was unexpected or its arguments matched no expected argument list for this method

ちゃんとメソッドの引数に ProductId のインスタンスを渡しているし、いったいなにがおかしいんだろう...

ヒント

Mockery の公式ドキュメントには、こう書いてあります。

このようなケースでは、Mockeryはまず引数の比較に===(厳密な比較)演算子を使用します。引数がプリミティブで、厳密な比較で不一致の場合、Mockeryは==(緩やかな比較)演算子をフォールバックとして使用します。

オブジェクトの引数のマッチングでは、Mockeryは厳密な===比較だけを行いますので、全く同じ$objectのみ一致します。

ま た お ま え か

PHP の === でオブジェクトを比較すると、インスタンスの参照先が同じかで比較します。

Mockery で作ったモックで渡した $productId と、コントローラで ProductSerive::fetchProduct() を呼ぶ際に渡した $productId は別々のインスタンスなので、参照先が同じになることはありません。

そして Mockery は === でしか比較してくれないようです。
どうりで、うまく動かないはずです。

OK

ここで万策尽きたかに見えましたが、公式ドキュメントには、続きが書いてありました。

オブジェクトに対してゆるい比較が必要であれば、HamcrestのequalToマッチャーを使用します。

これを踏まえて、 with() で Hamcrest の equalTo() を使うように修正しました。3
Hamcrest は JUnit をやったことがある人には、おなじみのアレですね。4
その PHP へ移植したバージョンが Mockery にはデフォルトで同梱されているようです。

ということで、以下のように書き直してみます。

$this->mock(ProductService::class, function($mock) use ($product) {
     $mock->shouldReceive('fetchProduct')
         ->once()
         ->with(\Hamcrest\Matchers::equalTo(new ProductId(1)))
         ->andReturn($product);
 })

今回はちゃんと動きました!

ちなみに

Hamcrest の equalTo()実装 を見ると、たしかに == で比較していることが分かります。

public function matches($arg)
{
    return (($arg == $this->_item) && ($this->_item == $arg));
}

参考


  1. 本論と関係ないですが、 ProductId はドメイン駆動開発の「値オブジェクト」のつもりで作っています。 

  2. 今回は Laravel を使っているので storage/logs/laravel-yyyy-mm-dd.log に出力されていました。 

  3. 残念ながら公式に書いてある with(equalTo(new stdClass)); のままでは、PHPUnit の equalTo メソッドと区別できず、うまく動かないようです。 

  4. 残念ながら私は案件で JUnit を1回しか触ったことがないので、全然おなじみではない。