Laravelでモックを活用したテストの書き方【初心者】

## はじめに

Laravelでテストを書く際に、外部依存を避けたり、DBとの接続を避けたい場合があります。その際に役立つのがモックです。今回は、商品購入を例に、モックの使い方やメソッドについて理解を深められればと思います。

## モックとは？

モックは、実際のオブジェクトの代わりに使用できる「偽物」のオブジェクトです。テスト時に以下のような場面で役立ちます：

- 外部APIの呼び出しを避けたい
- データベースへのアクセスを避けたい
- 特定の条件をシミュレートしたい

## サンプルコードの解説

### 1. テスト対象のクラス

```php
class PaymentService
{
    public function purchase(Product $product, int $quantity): bool
    {
        // 在庫チェック
        if (!$product->reduceStock($quantity)) {
            return false;
        }

        // 支払い処理を実行
        return $this->processPayment($product->price * $quantity);
    }

    protected function processPayment(int $amount): bool
    {
        // 実際の支払い処理は外部APIを呼び出すなど複雑な処理.一旦結果をtrueで返す
        return true;
    }
}

// 関連モデル
class Product extends Model
{
    protected $fillable = [
        'name',
        'price',
        'stock',
    ];

    // 在庫を減らす
    public function reduceStock(int $quantity): bool
    {
        if ($this->stock < $quantity) {
            return false;
        }

        $this->stock -= $quantity;
        return $this->save();
    }
}
```

## テストコードの解説
* setUp
* test
* tearDown
の順に書いていきます。全体のコードは最下部に添付します。

#### setUp
```php
class PaymentServiceTest extends TestCase
{
    private Product $product;
    private LegacyMockInterface|PaymentService $paymentService;
    private const QUANTITY = 2;
    private const PRICE = 1000;

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

        // 商品のモックを作成
        $this->product = Mockery::mock(Product::class);

        // PaymentServiceのモックを作成
        $this->paymentService = Mockery::mock(PaymentService::class)
            ->makePartial()
            ->shouldAllowMockingProtectedMethods();

        // 価格の取得をモック化
        $this->product->shouldReceive('getAttribute')
            ->with('price')
            ->andReturn(self::PRICE);
    }
}
```

#### モックの基本設定
#### PaymentService
   - `Mockery::mock(Product::class)`: Productクラスのモックを作成
   - `makePartial()`: クラスの一部のみをモック化し、残りは実際の実装を使用
   - `shouldAllowMockingProtectedMethods()`: protectedメソッドもモック化可能に
#### Product
   - `shouldReceive('getAttribute')`: getAttributeメソッドの呼び出しを期待
   - `with('price')`: 引数に'price'が渡されることを期待
   - `andReturn(self::PRICE)`: 戻り値としてPRICE定数（1000）を返す

---

### テストケースの解説

**成功パターンのテスト**
```php
public function test_purchase_success()
{
    // 在庫減少のモック化
    $this->product->shouldReceive('reduceStock')
        ->once()  // 1回だけ呼び出されることを期待
        ->with(self::QUANTITY)  // 引数にQUANTITYが渡されることを期待
        ->andReturn(true);  // trueを返す

    // 支払い処理のモック化
    $this->paymentService->shouldReceive('processPayment')
        ->once()
        ->with(self::PRICE * self::QUANTITY)
        ->andReturn(true);

    // makePartialしているので実際に実行
    $result = $this->paymentService->purchase($this->product, self::QUANTITY);
    $this->assertTrue($result);
}
```

**在庫不足パターンのテスト**
```php
public function test_purchase_failure_due_to_insufficient_stock()
{
    // 在庫減少のモック化（falseを返す）
    $this->product->shouldReceive('reduceStock')
        ->once()
        ->with(self::QUANTITY)
        ->andReturn(false);  // 在庫不足をシミュレート

    $result = $this->paymentService->purchase($this->product, self::QUANTITY);
    $this->assertFalse($result);
}
```

**支払い処理失敗パターンのテスト**
```php
public function test_purchase_failure_due_to_payment_error()
{
    // 在庫減少は成功
    $this->product->shouldReceive('reduceStock')
        ->once()
        ->with(self::QUANTITY)
        ->andReturn(true);

    // 支払い処理が失敗
    $this->paymentService->shouldReceive('processPayment')
        ->once()
        ->with(self::PRICE * self::QUANTITY)
        ->andReturn(false);

    $result = $this->paymentService->purchase($this->product, self::QUANTITY);
    $this->assertFalse($result);
}
```

#### モックの主要なメソッド解説

``shouldReceive()``
   - モック化するメソッドを指定
   - 例：`shouldReceive('reduceStock')`

``once()``
   - メソッドが1回だけ呼び出されることを期待
   - 他にも `twice()`（2回）、`times(3)`（3回）などが使用可能

``with()``
   - メソッドに渡される引数を指定
   - 例：`with(self::QUANTITY)`

``andReturn()``
   - メソッドの戻り値を指定
   - 例：`andReturn(true)`

``makePartial()``
   - クラスの一部のみをモック化
   - モック化されていないメソッドは実際の実装を使用
   - ※``makePartial``をしているので``$this->paymentService->purchase``が実行可能。partialしない場合はエラーになる

``shouldAllowMockingProtectedMethods()``
   - protectedメソッドもモック化可能に
   - 例：`processPayment`メソッドのモック化

---

#### テストの後片付け
```php
protected function tearDown(): void
{
    Mockery::close();
    parent::tearDown();
}
```

``Mockery::close()``
* 使用済みのモックオブジェクトを解放します
* テスト中に設定した期待値（expectations）が実際に実行されたかを検証します期待値と実際の実行結果が一致しない場合、テストは失敗します

## あとがき
今回はモックについての記事でした。``shouldReceive``や``makePartial``がイマイチわかりませんでした、実際にコードを動かしながら確認する事で少し理解が深まりました。

## テストコード
```php
class PaymentServiceTest extends TestCase
{
    private Product $product;
    private LegacyMockInterface|PaymentService $paymentService;
    private const QUANTITY = 2;
    private const PRICE = 1000;

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

        // 商品のモックを作成
        $this->product = Mockery::mock(Product::class);

        // PaymentServiceのモックを作成（protectedメソッドのモック化を許可）
        $this->paymentService = Mockery::mock(PaymentService::class)
            ->makePartial()
            ->shouldAllowMockingProtectedMethods();

        // 価格の取得をモック化（共通で使用）
        $this->product->shouldReceive('getAttribute')
            ->with('price')
            ->andReturn(self::PRICE);
    }


    public function test_purchase_正常系のテスト()
    {
        // 在庫減少のモック化
        $this->product->shouldReceive('reduceStock')
            ->once()
            ->with(self::QUANTITY)
            ->andReturn(true);

        // processPaymentメソッドをモック化
        $this->paymentService->shouldReceive('processPayment')
            ->once()
            ->with(self::PRICE * self::QUANTITY)
            ->andReturn(true);

        // テスト実行
        $result = $this->paymentService->purchase($this->product, self::QUANTITY);

        // アサーション
        $this->assertTrue($result);
    }

    public function test_purchase_在庫不足の場合のテスト()
    {
        // 在庫減少のモック化（falseを返す）
        $this->product->shouldReceive('reduceStock')
            ->once()
            ->with(self::QUANTITY)
            ->andReturn(false);

        // テスト実行
        $result = $this->paymentService->purchase($this->product, self::QUANTITY);

        // アサーション
        $this->assertFalse($result);
    }

    public function test_purchase_支払い処理失敗の場合のテスト()
    {
        // 在庫減少のモック化
        $this->product->shouldReceive('reduceStock')
            ->once()
            ->with(self::QUANTITY)
            ->andReturn(true);

        // processPaymentメソッドをモック化（falseを返す）
        $this->paymentService->shouldReceive('processPayment')
            ->once()
            ->with(self::PRICE * self::QUANTITY)
            ->andReturn(false);

        // テスト実行
        $result = $this->paymentService->purchase($this->product, self::QUANTITY);

        // アサーション
        $this->assertFalse($result);
    }

    protected function tearDown(): void
    {
        Mockery::close();
        parent::tearDown();
    }
}
```

## 参考リンク
- [Mockery公式ドキュメント](http://docs.mockery.io/en/latest/)
- [Laravelテストドキュメント](https://laravel.com/docs/testing)