Laravel のテストにおけるモックパターン

この記事は、[Fusic Advent Calendar 2024](https://qiita.com/advent-calendar/2024/fusic) 4日目の記事です。

昨日は @hkusaba さんの Laravel 愛溢れる記事でした。

https://qiita.com/hkusaba/items/de108824b05b17844fab

### 背景
最近 Laravel を仕事でがっつり使って知見が増えてきたので、それの共有というつもりで書くことにしました。

今回はユニットテストに焦点を当てて、モックを使ってテストを書くときに使っているパターンを紹介します。

## コントローラーのテスト
トップダウン的に説明したいので、コントローラから見ていきます。

コントローラーのテストを簡単にするため、以下のように Usecase クラスを作ります。
IndexUsecase の中で DB アクセスして $posts を返すといった想定です。

```php
class PostsController extends Controller
{
    public function index(IndexUsecase $usecase, int $userId): Response
    {
        $posts = $usecase($userId);

        return Inertia::render('Posts/Index', [
            'posts' => $posts,
        ]);
    }
}
```

そしてテストする際には IndexUsecase をモックしてテストします。

```php
class PostsControllerTest extends TestCase
{
   #[Test]
    public function index()
    {
        $userId = 123; // 適当な整数(乱数でも良い)
        
        $usecaseMock = Mockery::mock(IndexUsecase::class)
            ->shouldReceive('__invoke')
            ->with($userId) // IndexUsecase に $userId が渡されることを確認
            ->andReturn(Post::all())
            ->once() // 1回実行されることをテスト
            ->getMock();
    
        $this->app->instance(IndexUsecase::class, $usecaseMock); // DI コンテナに登録

        $response = $this->actingAs($this->user)->get(route('posts.index', [
            'userId' => $userId,
        ]));

        $response->assertStatus(200);
    }
}
```

ここで大事なポイントは、**get リクエストをする前に IndexUsecase のモックインスタンスを Laravel の DI コンテナに登録しておくこと**です。

これをすることで、Laravel がリクエストを処理するときに IndexUsecase の代わりにモックしたオブジェクトを使ってくれるようになります。

また、usecase がエラーを出した時のテストをしたい場合は、__invoke が呼ばれた時に例外を throw するようにテストを書けば良いです。

## ユースケースのテスト
次にコントローラーが依存しているユースケースのテストを考えます。
例えば以下のユースケースを想定します。

```php
class IndexUsecase
{
    public function __construct(
        private readonly PostRepository $postRepository
    ) {}

    public function __invoke(int $userId): Collection
    {
        return $this->postRepository->getByUserId($userId);
    }
}
```

このユースケースは postRepository に依存しており、これがモック対象になります。

```php
class IndexUsecaseTest extends TestCase
{
    private IndexUsecase $usecase;

    private PostRepository|MockInterface $postRepositoryMock;

    protected function setUp(): void
    {
        parent::setUp();

        $this->postRepositoryMock = Mockery::mock(PostRepository::class);
        
        // DI コンテナに登録
        $this->app->instance(PostRepository::class, $this->postRepositoryMock);

        // コンテナに登録した後なので、
        // IndexUsecase に PostRepositoryMock が注入された状態でインスタンス化される
        $this->usecase = $this->app->make(IndexUsecase::class);
    }

    #[Test]
    public function invoke(): void
    {
        $userId = 123; // 適当な整数

        $this->postRepositoryMock
            ->shouldReceive('getByUserId')
            ->once()
            ->with($userId)
            ->andReturn($postsMock = Mockery::mock(Collection::class));

        $result = ($this->usecase)($userId);

        $this->assertSame($postsMock, $result);
    }
}
```

このテストでは setUp のタイミングで Laravel の DI コンテナに登録と注入までやってます。
こうすることで invoke のテストメソッドではロジックのテストに集中できるのがお気に入りです。

ユースケースで __invoke の他のメソッドに切り出していて、それもテストしたいとなったら pirtialMock にして対応する必要があります。

## リポジトリのテスト
前述の postRepository のように、DB とやりとりするクラスをこう呼んでいます。
ここは Laravel 使ってる以上、素直に気合いで書くしか無いと思っています...

Laravel で上手くモックしてリポジトリのテストを書く、というのは難しいと思ってるので、テストを書くときはここが一番頭を使います。

## モックオブジェクトについての補足
```php
$postMock = Mockery::mock(Post::class);
```

というようにモックを作って、`$post->id` にアクセスしたら

```shell
Received Mockery_1_App_Models_Post::getAttribute(), but no expectations were specified
```

のようなエラーが出ると思います。これはモックオブジェクトの id を教えてないので、当然と言えば当然です。
このような場合は

```php
$postMock = Mockery::mock(Post::class)
    ->shouldReceive('getAttribute')
    ->with('id')
    ->andReturn(123)
    ->getMock();
```

のように書きましょう。

## 終わりに
上に書いたこと以外にも、初めはどう書けばいいか分からないケースに遭遇することも多いですが、慣れてしまえば一定のパターンでテストをかけるようになります。

ここで紹介できてないパターンもありますが、Laravel の DI コンテナに登録するタイミングと、その Mock が注入されるタイミングを意識することで柔軟に書けるようになってきます。

モックを使うと頭をあまり使わずにテストができることの他にも、テストの依存関係を減らすことにも寄与して品質が上がることも期待できるので、ぜひ自分なりのパターンの実装/テストを見つけてみましょう！

---
[Fusic Advent Calendar 2024](https://qiita.com/advent-calendar/2024/fusic) 5日目は @TsuMakoto さんが書きます、お楽しみに！