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()` をモックにしたいとします。

```php
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]

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


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


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

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

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

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

```
[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]
[^3]: 残念ながら公式に書いてある `with(equalTo(new stdClass));` のままでは、PHPUnit の `equalTo` メソッドと区別できず、うまく動かないようです。

Hamcrest は JUnit をやったことがある人には、おなじみのアレですね。[^4]
[^4]: 残念ながら私は案件で JUnit を1回しか触ったことがないので、全然おなじみではない。

その PHP へ移植したバージョンが Mockery にはデフォルトで同梱されているようです。

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

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

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

# ちなみに
Hamcrest の `equalTo()` の [実装](https://github.com/hamcrest/hamcrest-php/blob/master/hamcrest/Hamcrest/Core/IsEqual.php) を見ると、たしかに `==` で比較していることが分かります。

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


# 参考
- Mockery 公式ドキュメント
  - [引数のバリデーション](https://readouble.com/mockery/1.0/ja/argument_validation.html)
- PHP 公式ドキュメント
  - [オブジェクトの比較](https://www.php.net/manual/ja/language.oop5.object-comparison.php)
- `Mockery::on()` を使うやり方
  - [Mockery with() でメソッドがObjectを渡す検証でNoMatchingExpectationException](https://qiita.com/ttn_tt/items/a35638ab12d4dc2ea488)