mcpField
L
laravel-testing

laravel-testing

github.com/leeoveryテスト・品質1562 回インストール2 週間前に更新
リポジトリ

laravel-testing について

Laravel 開発者向けに、Pest を使ったユニットテストとフィーチャーテストの指針をまとめています。Arrange-Act-Assert、ファクトリー、コンテナ経由の Action、Null Driver により、読みやすく再現性のあるテストを目指します。

主な機能

  1. 01Arrange-Act-Assert のテスト構成
  2. 02宣言的なモデル・DTO ファクトリー
  3. 03コンテナ経由の Action テスト
  4. 04自作依存のモック
  5. 05Null Driver による再現性の高いテスト
  6. 06HTTP フィーチャー・ユニットテストのパターン

活用シーン

  1. 01Laravel のドメイン Action の単体テスト
  2. 02API のリクエスト・レスポンスフローの検証
  3. 03ネットワーク呼び出しなしでのサービス障害テスト

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/leeovery/agentic-skills --skill laravel-testing

インストール後、Claude Code で/laravel-testingを入力して読み込まれたか確認します。

同梱ファイルreferences/testing-conventions.mdreferences/testing-factories.md

詳細

Laravel のテスト

Pest を使ったテストパターン:Arrange-Act-Assert、適切なモック、null ドライバー、宣言的なファクトリー。

関連ガイド:

考え方

テストは次のようにあるべきです:

  • 分離されている - 一度に1つのことをテストします
  • 信頼できる - 毎回一貫した結果を得られます
  • 保守しやすい - コードが変更されても簡単に更新できます
  • 高速である - 素早いフィードバックループを実現します
  • 現実的である - ハードコードされた値ではなく、ファクトリーを使用します

Triple-A パターン

すべてのテストは Arrange-Act-Assert パターンに従う必要があります:

1. テスト環境を整える

ファクトリーを使って、必要なすべてのデータと依存関係を設定します:

it('creates an order with items', function () {
    // Arrange: Create the world state
    $user = User::factory()->create();
    $product = Product::factory()->active()->create(['price' => 1000]);

    $data = CreateOrderData::from([
        'customer_email' => '[email protected]',
        'items' => [
            ['product_id' => $product->id, 'quantity' => 2],
        ],
    ]);

    // Act: Perform the operation
    $order = resolve(CreateOrderAction::class)($user, $data);

    // Assert: Verify the results
    expect($order)
        ->toBeInstanceOf(Order::class)
        ->and($order->items)->toHaveCount(1)
        ->and($order->total)->toBe(2000);
});
2. テスト対象を実行する

テストする単一の操作を実行します:

// ✅ Good - Single, clear action
$order = resolve(CreateOrderAction::class)($user, $data);

// ❌ Bad - Multiple actions mixed with assertions
$order = resolve(CreateOrderAction::class)($user, $data);
expect($order)->toBeInstanceOf(Order::class);
$order->refresh();
expect($order->total)->toBe(2000);
3. 結果を検証する

操作の結果を検証します:

// ✅ Good - Clear, focused assertions
expect($order)
    ->toBeInstanceOf(Order::class)
    ->and($order->status)->toBe(OrderStatus::Pending)
    ->and($order->items)->toHaveCount(2);

assertDatabaseHas('orders', [
    'id' => $order->id,
    'user_id' => $user->id,
]);

// ❌ Bad - Testing implementation details
expect($order->getAttribute('status'))->toBe('pending');

アクションのテスト

アクションはドメインロジックの中心であり、分離して徹底的にテストする必要があります。

基本的なアクションテスト
use App\Actions\Order\CreateOrderAction;
use App\Data\CreateOrderData;
use App\Enums\OrderStatus;
use App\Models\User;
use function Pest\Laravel\assertDatabaseHas;

it('creates an order', function () {
    // Arrange
    $user = User::factory()->create();
    $data = CreateOrderData::testFactory()->make([
        'status' => OrderStatus::Pending,
    ]);

    // Act
    $order = resolve(CreateOrderAction::class)($user, $data);

    // Assert
    expect($order)->toBeInstanceOf(Order::class);
    assertDatabaseHas('orders', [
        'id' => $order->id,
        'user_id' => $user->id,
        'status' => OrderStatus::Pending->value,
    ]);
});
アクションのガードメソッドをテストする
it('throws exception when user has too many pending orders', function () {
    // Arrange
    $user = User::factory()
        ->has(Order::factory()->pending()->count(5))
        ->create();

    $data = CreateOrderData::testFactory()->make();

    // Act & Assert
    expect(fn () => resolve(CreateOrderAction::class)($user, $data))
        ->toThrow(OrderException::class, 'Too many pending orders');
});
アクションの合成をテストする

重要なパターン: 依存関係が再帰的に解決されるよう、必ず resolve() を使ってコンテナからアクションを解決してください。swap() を使って、依存関係をモックしたものに置き換えます。

use function Pest\Laravel\mock;
use function Pest\Laravel\swap;

it('processes order and sends notification', function () {
    // Arrange
    $user = User::factory()->create();
    $order = Order::factory()->for($user)->create();

    // Mock the dependency actions and swap them into the container
    $calculateTotal = mock(CalculateOrderTotalAction::class);
    $calculateTotal->shouldReceive('__invoke')
        ->once()
        ->with($order)
        ->andReturn(10000);
    swap(CalculateOrderTotalAction::class, $calculateTotal);

    $notifyOrder = mock(NotifyOrderCreatedAction::class);
    $notifyOrder->shouldReceive('__invoke')
        ->once()
        ->with($order);
    swap(NotifyOrderCreatedAction::class, $notifyOrder);

    // Act - resolve() from container so mocked dependencies are injected
    $result = resolve(ProcessOrderAction::class)($order);

    // Assert
    expect($result->total)->toBe(10000);
});

このパターンを使う理由:

  • resolve() により、すべての依存関係を含めてコンテナからアクションが取得されます
  • swap() は依存関係をコンテナ内のモックに置き換えます
  • コンテナが依存関係の再帰的な解決を自動的に処理します
  • 依存関係に新たな依存関係が追加されても、テストは壊れません

モックのガイドライン

自分が管理するものだけをモックする

重要な原則: 自分が管理するコードだけをモックしてください。外部サービスを直接モックしてはいけません。

✅ 良い例 - 自分のアクションをモックする
use function Pest\Laravel\mock;
use function Pest\Laravel\swap;

// Mock an action you own and swap it into the container
$sendEmail = mock(SendWelcomeEmailAction::class);
$sendEmail->shouldReceive('__invoke')
    ->once()
    ->with(Mockery::type(User::class));
swap(SendWelcomeEmailAction::class, $sendEmail);

// Then resolve the action under test - it will receive the mocked dependency
$result = resolve(RegisterUserAction::class)($data);
✅ 応用例 - アサーションでモックの引数を検証する

クロージャを使った withArgs() で、渡される正確なインスタンスと値を検証します:

it('processes match with correct arguments', function () {
    $matchAttempt = MatchAttempt::factory()->create();
    $data = MatchData::testFactory()->make();

    // Mock and verify exact arguments using expect() assertions
    $mockAction = mock(CreateMatchResultAction::class);
    $mockAction->shouldReceive('__invoke')
        ->once()
        ->withArgs(function (MatchAttempt $_matchAttempt, MatchData $_data) use ($data, $matchAttempt) {
            // Verify the exact model instance is passed
            expect($_matchAttempt->is($matchAttempt))->toBeTrue()
                // Verify the exact DTO value is passed
                ->and($_data)->toBe($data->matches->first());

            return true; // Return true to pass the assertion
        });
    swap(CreateMatchResultAction::class, $mockAction);

    // Act
    resolve(ProcessMatchAction::class)($matchAttempt, $data);
});
✅ 良い例 - 自分のサービスをモックする(Facade 経由)
// Mock your own service through its facade
Payment::shouldReceive('createPaymentIntent')
    ->once()
    ->with(10000, 'usd')
    ->andReturn(PaymentIntentData::from([
        'id' => 'pi_test_123',
        'status' => 'succeeded',
    ]));
❌ 悪い例 - 外部ライブラリを直接モックする
// ❌ DON'T DO THIS - Mocking Stripe SDK directly
$stripe = Mockery::mock(\Stripe\StripeClient::class);
$stripe->shouldReceive('paymentIntents->create')
    ->andReturn(/* ... */);

// This is brittle and breaks when Stripe updates their SDK
自分が管理していないものをモックする必要がある場合

外部サービスをモックする必要がある場合は、抽象化を作成してください:

  1. Manager パターンでサービスレイヤーを作成する
  2. ドライバーコントラクト(インターフェース)を定義する
  3. 実際のドライバーを実装する(外部 API をラップします)
  4. テスト用の Null ドライバーを作成する
  5. 利便性のために Facade を追加する

実装例については、サービスを参照してください。

Null ドライバーの使用

null ドライバーパターンを使うと、外部依存関係なしで決定論的かつ高速なテストを実行できます:

it('processes payment successfully', function () {
    // Arrange - Use null driver (configured in phpunit.xml or .env.testing)
    Config::set('payment.default', 'null');

    $order = Order::factory()->create(['total' => 10000]);
    $data = PaymentData::from(['amount' => 10000, 'currency' => 'usd']);

    // Act - No mocking needed, null driver returns test data
    $payment = resolve(ProcessPaymentAction::class)($order, $data);

    // Assert
    expect($payment)
        ->toBeInstanceOf(Payment::class)
        ->and($payment->status)->toBe(PaymentStatus::Completed);
});

null ドライバーの利点:

  • モックが不要です
  • 実行が高速です(ネットワーク呼び出しがありません)
  • 結果が決定論的です
  • null ドライバーを拡張してエラーシナリオをテストできます
  • 実際のドライバーと完全に一致するインターフェースを備えています
エラーシナリオのテスト

特定のテストシナリオ用に null ドライバーを拡張します:

// tests/Fakes/FailingPaymentDriver.php
class FailingPaymentDriver implements PaymentDriver
{
    public function createPaymentIntent(int $amount, string $currency): PaymentIntentData
    {
        throw PaymentException::failedToCharge('Card declined');
    }
}

// In test
it('handles payment failure gracefully', function () {
    $this->app->bind(PaymentManager::class, function () {
        $manager = new PaymentManager($this->app);
        $manager->extend('failing', fn () => new FailingPaymentDriver);
        return $manager;
    });

    Config::set('payment.default', 'failing');

    $order = Order::factory()->create();
    $data = PaymentData::testFactory()->make();

    expect(fn () => resolve(ProcessPaymentAction::class)($order, $data))
        ->toThrow(PaymentException::class, 'Card declined');
});

ファクトリーの使用

ファクトリーは現実的でランダムなテストデータを作成し、テストの堅牢性を高めます。

モデルファクトリー
// Arrange with factories
$user = User::factory()->create();
$product = Product::factory()->active()->create();
$order = Order::factory()->for($user)->create();

// Factory with state
$pendingOrder = Order::factory()->pending()->create();
$paidOrder = Order::factory()->paid()->create();

// Factory with relationships
$user = User::factory()
    ->has(Order::factory()->count(3))
    ->create();
宣言的なファクトリーメソッド

重要な原則: ファクトリーメソッドの背後にデータベース実装の詳細を隠し、テストを宣言的で読みやすくします。

// ❌ Bad - Database schema leaks into test
$calendar = Calendar::factory()->create([
    'status' => 'accepted',
    'reminder_sent_at' => null,
    'approved_by' => User::factory()->create()->id,
    'approved_at' => now(),
]);

// ✅ Good - Declarative and readable
$calendar = Calendar::factory()->accepted()->create();

→ 宣言的なファクトリーパターンの詳細:testing-factories.md

DTO テストファクトリー

DTO では、HasTestFactory トレイトを DataTestFactory 基底クラスとともに使用します。これは、Data オブジェクト向けに Laravel のモデルファクトリー API を再現したものです。

// Create DTO with defaults
$data = CreateOrderData::testFactory()->make();

// Create DTO with overrides
$data = CreateOrderData::testFactory()->make([
    'status' => OrderStatus::Pending,
]);

// Create collection
$items = OrderItemData::testFactory()->collect(count: 3);

テストファクトリーのセットアップと実装については、DTO を参照してください。

テスト戦略

フィーチャーテスト(HTTP レイヤー)

リクエストとレスポンスの完全なサイクルをテストします。

use function Pest\Laravel\actingAs;
use function Pest\Laravel\postJson;

it('creates an order via API', function () {
    $user = User::factory()->create();
    $product = Product::factory()->create();

    $response = actingAs($user)
        ->postJson('/api/orders', [
            'customer_email' => '[email protected]',
            'items' => [
                ['product_id' => $product->id, 'quantity' => 2],
            ],
        ]);

    $response->assertCreated()
        ->assertJsonStructure([
            'data' => ['id', 'status', 'items'],
        ]);
});
ユニットテスト(アクション)

ドメインロジックを分離してテストします。

it('calculates order total correctly', function () {
    $order = Order::factory()->create();
    $order->items()->createMany([
        ['price' => 1000, 'quantity' => 2],
        ['price' => 1500, 'quantity' => 1],
    ]);

    $total = resolve(CalculateOrderTotalAction::class)($order);

    expect($total)->toBe(3500);
});

壊れやすいテストを避ける

壊れやすいテストは、動作が正しくても実装の変更によって失敗します。

壊れやすいテストの兆候
  • モックが多すぎる
  • 実装の詳細をテストしている
  • あらゆるところで値がハードコードされている
  • 手順が多く、セットアップが複雑
  • リファクタリングでテストが失敗する
壊れにくくする方法
1. 可能な場合は実際のインスタンスを使用する
// ✅ Good - Use real instances
it('calculates order total', function () {
    $order = Order::factory()->create();
    $order->items()->createMany([
        ['price' => 1000, 'quantity' => 2],
        ['price' => 500, 'quantity' => 1],
    ]);

    $total = resolve(CalculateOrderTotalAction::class)($order);

    expect($total)->toBe(2500);
});

// ❌ Bad - Mock everything
it('calculates order total', function () {
    $item1 = Mockery::mock(OrderItem::class);
    $item1->shouldReceive('getPrice')->andReturn(1000);
    // ... too much mocking
});
2. 実装ではなく動作をテストする
// ✅ Good - Test the behavior
it('sends welcome email when user registers', function () {
    Mail::fake();

    $data = RegisterUserData::testFactory()->make();
    $user = resolve(RegisterUserAction::class)($data);

    Mail::assertSent(WelcomeEmail::class, function ($mail) use ($user) {
        return $mail->hasTo($user->email);
    });
});

// ❌ Bad - Test implementation details
it('sends welcome email when user registers', function () {
    $mailer = Mockery::mock(Mailer::class);
    $mailer->shouldReceive('send')
        ->with(Mockery::on(function ($email) {
            return $email->template === 'emails.welcome';
        }));
    // Too specific, breaks if template name changes
});
3. ハードコードしたデータではなくファクトリーを使用する
// ✅ Good - Use factories
$user = User::factory()->create();
$data = ProfileData::testFactory()->make();

// ❌ Bad - Hardcoded data
$data = new ProfileData(
    firstName: 'John',
    lastName: 'Doe',
    phone: '555-1234',
    bio: 'Test bio',
);
4. モックを最小限にする

**経験則:**データではなく、連携するオブジェクトをモックします。

// ✅ Good - Mock the notification service (collaborator)
$notifier = mock(NotificationService::class);
$notifier->shouldReceive('send')->once();
swap(NotificationService::class, $notifier);

resolve(ShipOrderAction::class)($order);

// ❌ Bad - Mock the data (order, user)
$order = Mockery::mock(Order::class);
// ... mocking data objects makes test brittle

よく使われるテストパターン

状態遷移のテスト
it('transitions order from pending to paid', function () {
    $order = Order::factory()->pending()->create();

    resolve(MarkOrderAsPaidAction::class)($order);

    expect($order->fresh()->status)->toBe(OrderStatus::Paid)
        ->and($order->fresh()->paid_at)->not->toBeNull();
});
リレーションシップのテスト
it('creates order with items', function () {
    $user = User::factory()->create();
    $products = Product::factory()->count(3)->create();

    $data = CreateOrderData::from([
        'customer_email' => '[email protected]',
        'items' => $products->map(fn ($p) => [
            'product_id' => $p->id,
            'quantity' => 2,
        ])->all(),
    ]);

    $order = resolve(CreateOrderAction::class)($user, $data);

    expect($order->items)->toHaveCount(3);
});
トランザクションのテスト
it('rolls back transaction on failure', function () {
    $user = User::factory()->create();

    $data = CreateOrderData::from([
        'customer_email' => '[email protected]',
        'items' => [
            ['product_id' => 99999, 'quantity' => 1], // Non-existent product
        ],
    ]);

    expect(fn () => resolve(CreateOrderAction::class)($user, $data))
        ->toThrow(Exception::class);

    assertDatabaseCount('orders', 0);
    assertDatabaseCount('order_items', 0);
});

クイックリファレンス

テスト構造
it('does something', function () {
    // Arrange - Set up the world with declarative factories
    $model = Model::factory()->active()->create();
    $data = Data::testFactory()->make();

    // Act - Perform the operation
    $result = resolve(Action::class)($model, $data);

    // Assert - Verify the results
    expect($result)->/* assertions */;
});
モックのパターン
use function Pest\Laravel\mock;
use function Pest\Laravel\swap;

// Mock a dependency action
$mockAction = mock(YourDependencyAction::class);
$mockAction->shouldReceive('__invoke')
    ->once()
    ->with(/* expected params */)
    ->andReturn(/* return value */);

// Swap into container
swap(YourDependencyAction::class, $mockAction);

// Resolve action under test - container injects mocked dependencies
$result = resolve(ActionUnderTest::class)(/* params */);
データベースのアサーション
use function Pest\Laravel\assertDatabaseHas;
use function Pest\Laravel\assertDatabaseCount;

assertDatabaseHas('orders', ['id' => $order->id]);
assertDatabaseCount('orders', 1);

コメント