Help us understand the problem. What is going on with this article?

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回しか触ったことがないので、全然おなじみではない。 

Why do not you register as a user and use Qiita more conveniently?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away