404 Not Found

404 Not Found


nginx

Laravel自動テスト

テストはLaravelの「セーフティネット」です—コードを変更するたびにテストを実行することで, バグがすぐに検出され, 本番環境に漏れ込むのを防ぎます。

1. 学ぶこと


2. 夜勤開発者の実話

(1) 痛み:デプロイのたびにバグが発生する

Bobは金曜の夜にShopMetricsの新バージョンをデプロイしました—注文割引ロジックを変更し, いくつかのシナリオを手動テストして問題ないことを確認してからリリースしました。しかし月曜の朝, Aliceがネガティブな割引によって注文合計がマイナスになり, 100人以上のユーザーが無料で商品を受け取ってしまったと報告しました。Charlieは言いました。「自動テストがあれば, コードがコミットされた時にこのバグは検出されていたはずです。」

(2) 自動テストによる解決策

自動テストはコード変更のたびに自動的に実行されます—割引ロジックはテストで完全にカバーされ, ネガティブ割引のテストケースがすぐに失敗し, バグが本番環境に入るのを防ぎます。

PHP
// ネガティブ割引が拒否されることをテスト
test('order rejects negative discount', function () {
    $response = $this->postJson('/api/v1/orders', [
        'items' => [['product_id' => 1, 'quantity' => 1]],
        'discount' => -10,  // 拒否されるべき!
    ]);
    $response->assertJsonValidationErrors('discount');
});

(3) 成果

Bobがテストを追加した後, ネガティブ割引のバグは開発中に検出され, Aliceはもう「無料ショッピング」のインシデントに遭遇することはありませんでした。


3. テスト環境設定

(1) phpunit.xml

XML
<!-- phpunit.xml -->
<phpunit>
    <testsuites>
        <testsuite name="Unit">
            <directory>tests/Unit</directory>
        </testsuite>
        <testsuite name="Feature">
            <directory>tests/Feature</directory>
        </testsuite>
    </testsuites>
    <php>
        <env name="APP_ENV" value="testing"/>
        <env name="CACHE_DRIVER" value="array"/>
        <env name="SESSION_DRIVER" value="array"/>
        <env name="QUEUE_CONNECTION" value="sync"/>
        <env name="DB_CONNECTION" value="sqlite"/>
        <env name="DB_DATABASE" value=":memory:"/>
    </php>
</phpunit>

(2) .env.testing

BASH
# .env.testing — 専用テスト環境
APP_ENV=testing
APP_KEY=base64:test-key-for-testing-only
DB_CONNECTION=sqlite
DB_DATABASE=:memory:
CACHE_DRIVER=array
SESSION_DRIVER=array
QUEUE_CONNECTION=sync
MAIL_MAILER=array

(3) テストピラミッド

100%
graph TD
    A["ユニットテスト<br/>(高速, 多数)<br/>Modelメソッド, Serviceロジック"] --> B["機能テスト<br/>(中速, 適度)<br/>HTTPリクエスト, DBクエリ"]
    B --> C["API/E2Eテスト<br/>(低速, 少数)<br/>完全なリクエストライフサイクル"]
レベル 数量 速度 テスト内容
ユニット 多数 高速 (ミリ秒) 純粋ロジック, Modelメソッド
機能 中程度 中速 (100ミリ秒) HTTPリクエスト, DB操作
API/E2E 少数 低速 (秒) 完全なリクエストチェーン

(1) ▶ サンプル:ShopMetricsテスト環境設定

BASH
# .env.testingの作成
cp .env .env.testing

# .env.testingの編集
# DB_CONNECTION=sqlite
# DB_DATABASE=:memory:
# QUEUE_CONNECTION=sync

# テストの実行
php artisan test           # 全テスト
php artisan test --parallel  # 並列実行 (高速)
php artisan test --coverage  # カバレッジレポート付き

出力:

TEXT
# コマンド実行成功

4. ユニットテスト

(1) PHPUnit vs. Pest

観点 PHPUnit Pest
構文 クラス + メソッド 関数型
サンプルコード 多い 少ない
可読性 ✅ 高い
互換性 100% PHPUnitベース
適している 複雑なテスト シンプルで高速

(2) PHPUnitスタイル

PHP
// tests/Unit/Models/ShopTest.php
class ShopTest extends TestCase
{
    public function test_shop_generates_slug(): void
    {
        $shop = Shop::factory()->make(['name' => 'Alice Store']);
        $this->assertEquals('alice-store', Str::slug($shop->name));
    }

    public function test_active_scope_filters_active_shops(): void
    {
        Shop::factory()->create(['status' => 'active']);
        Shop::factory()->create(['status' => 'suspended']);

        $activeShops = Shop::active()->get();

        $this->assertCount(1, $activeShops);
        $this->assertEquals('active', $activeShops->first()->status);
    }

    public function test_revenue_formatted_accessor(): void
    {
        $shop = Shop::factory()->make(['revenue' => 12345.67]);
        $this->assertEquals('$12,345.67', $shop->revenue_formatted);
    }
}

(3) Pestスタイル

PHP
// tests/Unit/Models/ShopTest.php
uses(\Tests\TestCase::class, \Illuminate\Foundation\Testing\RefreshDatabase::class);

it('generates slug from name', function () {
    $shop = Shop::factory()->make(['name' => 'Alice Store']);
    expect(Str::slug($shop->name))->toBe('alice-store');
});

it('filters active shops via scope', function () {
    Shop::factory()->create(['status' => 'active']);
    Shop::factory()->create(['status' => 'suspended']);

    $active = Shop::active()->get();

    expect($active)->toHaveCount(1);
    expect($active->first()->status)->toBe('active');
});

it('formats revenue with currency symbol', function () {
    $shop = Shop::factory()->make(['revenue' => 12345.67]);
    expect($shop->revenue_formatted)->toBe('$12,345.67');
});

(1) ▶ サンプル:ShopMetrics Modelユニットテスト

PHP
// tests/Unit/Models/OrderTest.php
uses(\Tests\TestCase::class, \Illuminate\Foundation\Testing\RefreshDatabase::class);

it('calculates order total from items', function () {
    $order = Order::factory()->create();
    $order->items()->createMany([
        ['product_id' => 1, 'quantity' => 2, 'price' => 10.00],
        ['product_id' => 2, 'quantity' => 1, 'price' => 25.00],
    ]);

    $order->updateTotal();

    expect($order->fresh()->total)->toBe(45.00);
});

it('rejects invalid status transition', function () {
    $order = Order::factory()->create(['status' => 'completed']);

    expect(fn () => $order->update(['status' => 'pending']))
        ->toThrow(InvalidArgumentException::class);
});

it('scope completed returns only completed orders', function () {
    Order::factory()->create(['status' => 'completed']);
    Order::factory()->create(['status' => 'pending']);
    Order::factory()->create(['status' => 'cancelled']);

    expect(Order::completed()->count())->toBe(1);
});

出力:

TEXT
// 実行成功

5. 機能テスト

(1) HTTPリクエストテスト

PHP
// tests/Feature/ShopControllerTest.php
uses(\Tests\TestCase::class, \Illuminate\Foundation\Testing\RefreshDatabase::class);

it('displays shops list on index page', function () {
    $shops = Shop::factory()->count(3)->create();

    $response = $this->get(route('shops.index'));

    $response->assertStatus(200);
    $response->assertViewIs('shops.index');
    foreach ($shops as $shop) {
        $response->assertSee($shop->name);
    }
});

it('creates a shop with valid data', function () {
    $user = User::factory()->create(['role' => 'tenant_owner']);

    $response = $this->actingAs($user)->post(route('shops.store'), [
        'name' => 'New Shop',
        'slug' => 'new-shop',
        'description' => 'A test shop',
    ]);

    $response->assertRedirect(route('shops.index'));
    $this->assertDatabaseHas('shops', ['name' => 'New Shop']);
});

it('validates required fields on shop creation', function () {
    $user = User::factory()->create(['role' => 'tenant_owner']);

    $response = $this->actingAs($user)->post(route('shops.store'), []);

    $response->assertSessionHasErrors(['name', 'slug']);
});

it('prevents non-owners from creating shops', function () {
    $user = User::factory()->create(['role' => 'analyst']);

    $response = $this->actingAs($user)->post(route('shops.store'), [
        'name' => 'Unauthorized Shop',
        'slug' => 'unauthorized',
    ]);

    $response->assertForbidden();
});

(2) データベースアサーション

アサーションメソッド 説明
assertDatabaseHas() レコードがデータベースに存在する
assertDatabaseMissing() レコードがデータベースに存在しない
assertDatabaseCount() レコード数が一致する
assertSoftDeleted() ソフトデリートされたレコードが存在する

(1) ▶ サンプル:ShopMetrics CRUD機能テスト

PHP
// tests/Feature/ProductControllerTest.php
uses(\Tests\TestCase::class, \Illuminate\Foundation\Testing\RefreshDatabase::class);

beforeEach(function () {
    $this->owner = User::factory()->create(['role' => 'tenant_owner']);
    $this->shop = Shop::factory()->create(['tenant_id' => $this->owner->tenant_id]);
});

it('lists products for a shop', function () {
    Product::factory()->count(5)->create(['shop_id' => $this->shop->id]);

    $response = $this->actingAs($this->owner)
        ->get(route('shops.products.index', $this->shop));

    $response->assertOk();
    $response->assertViewHas('products');
});

it('stores a new product', function () {
    Storage::fake('public');

    $response = $this->actingAs($this->owner)
        ->post(route('products.store'), [
            'shop_id' => $this->shop->id,
            'name' => 'Widget Pro',
            'sku' => 'WP-001',
            'price' => 49.99,
            'stock' => 100,
        ]);

    $response->assertRedirect();
    $this->assertDatabaseHas('products', ['sku' => 'WP-001']);
});

it('rejects negative price', function () {
    $response = $this->actingAs($this->owner)
        ->post(route('products.store'), [
            'shop_id' => $this->shop->id,
            'name' => 'Bad Product',
            'sku' => 'BP-001',
            'price' => -10,
            'stock' => 50,
        ]);

    $response->assertSessionHasErrors('price');
});

it('deletes a product with soft delete', function () {
    $product = Product::factory()->create(['shop_id' => $this->shop->id]);

    $this->actingAs($this->owner)
        ->delete(route('products.destroy', $product));

    $this->assertSoftDeleted($product);
});

出力:

TEXT
// 実行成功

6. APIテスト

(1) Sanctumトークン認証テスト

PHP
// tests/Feature/Api/ShopApiTest.php
uses(\Tests\TestCase::class, \Illuminate\Foundation\Testing\RefreshDatabase::class);

it('requires authentication for API access', function () {
    $this->getJson('/api/v1/shops')
        ->assertUnauthorized();
});

it('authenticates with Sanctum token', function () {
    $user = User::factory()->create();
    $token = $user->createToken('test-token', ['read'])->plainTextToken;

    $this->withHeader('Authorization', "Bearer {$token}")
        ->getJson('/api/v1/shops')
        ->assertOk();
});

it('lists shops via API', function () {
    $user = User::factory()->create();
    $shops = Shop::factory()->count(3)->create(['tenant_id' => $user->tenant_id]);

    $response = $this->actingAs($user)
        ->getJson('/api/v1/shops');

    $response->assertOk()
        ->assertJsonStructure([
            'data' => [
                '*' => ['id', 'name', 'slug', 'status', 'created_at'],
            ],
        ]);
});

it('creates shop via API', function () {
    $user = User::factory()->create(['role' => 'tenant_owner']);

    $response = $this->actingAs($user)
        ->postJson('/api/v1/shops', [
            'name' => 'API Shop',
            'slug' => 'api-shop',
        ]);

    $response->assertCreated()
        ->assertJsonPath('data.name', 'API Shop');
});

it('rejects duplicate slug', function () {
    $user = User::factory()->create(['role' => 'tenant_owner']);
    Shop::factory()->create(['tenant_id' => $user->tenant_id, 'slug' => 'existing']);

    $response = $this->actingAs($user)
        ->postJson('/api/v1/shops', [
            'name' => 'Duplicate',
            'slug' => 'existing',
        ]);

    $response->assertUnprocessable()
        ->assertJsonValidationErrors('slug');
});

(2) JSONアサーションメソッド

メソッド 説明
assertJson() JSONに指定されたフラグメントが含まれる
assertJsonPath() 指定パスの値が一致する
assertJsonStructure() JSON構造が一致する
assertJsonValidationErrors() バリデーションエラーにフィールドが含まれる
assertJsonCount() 配列の長さが一致する
assertJsonFragment() JSONにスニペットが含まれる

(1) ▶ サンプル:ShopMetrics APIテストスイート

PHP
// tests/Feature/Api/OrderApiTest.php
uses(\Tests\TestCase::class, \Illuminate\Foundation\Testing\RefreshDatabase::class);

beforeEach(function () {
    $this->owner = User::factory()->create(['role' => 'tenant_owner']);
    $this->shop = Shop::factory()->create(['tenant_id' => $this->owner->tenant_id]);
});

it('lists orders with pagination', function () {
    Order::factory()->count(25)->create([
        'tenant_id' => $this->owner->tenant_id,
        'shop_id' => $this->shop->id,
    ]);

    $response = $this->actingAs($this->owner)
        ->getJson('/api/v1/orders?per_page=10');

    $response->assertOk()
        ->assertJsonCount(10, 'data')
        ->assertJsonStructure(['meta' => ['current_page', 'total']]);
});

it('shows order with items', function () {
    $order = Order::factory()->create([
        'tenant_id' => $this->owner->tenant_id,
        'shop_id' => $this->shop->id,
    ]);
    $order->items()->create(['product_id' => 1, 'quantity' => 2, 'price' => 10]);

    $response = $this->actingAs($this->owner)
        ->getJson("/api/v1/orders/{$order->id}");

    $response->assertOk()
        ->assertJsonPath('data.order_number', $order->order_number)
        ->assertJsonStructure(['data' => ['items']]);
});

it('updates order status', function () {
    $order = Order::factory()->create([
        'tenant_id' => $this->owner->tenant_id,
        'status' => 'pending',
    ]);

    Event::fake(OrderStatusChanged::class);

    $response = $this->actingAs($this->owner)
        ->patchJson("/api/v1/orders/{$order->id}/status", [
            'status' => 'completed',
        ]);

    $response->assertOk();
    expect($order->fresh()->status)->toBe('completed');
    Event::assertDispatched(OrderStatusChanged::class);
});

出力:

TEXT
// 実行成功

7. テストカバレッジとCI

(1) カバレッジレポート

BASH
# HTMLカバレッジレポートの生成
php artisan test --coverage-html=coverage

# 最小カバレッジ閾値
php artisan test --coverage --min=80

# 特定ディレクトリのカバレッジ
php artisan test --coverage-filter=app/Services

(2) GitHub Actions CI

YAML
# .github/workflows/tests.yml
name: Tests
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    services:
      mysql:
        image: mysql:8.0
        env:
          MYSQL_ROOT_PASSWORD: password
          MYSQL_DATABASE: shopmetrics_test
        ports:
          - 3306:3306
      redis:
        image: redis:7
        ports:
          - 6379:6379
    steps:
      - uses: actions/checkout@v4
      - uses: shivammathur/setup-php@v2
        with:
          php-version: 8.3
          coverage: xdebug
      - run: composer install --no-interaction
      - run: cp .env.testing .env
      - run: php artisan key:generate
      - run: php artisan test --coverage --min=80

(1) ▶ サンプル:ShopMetrics CIテスト設定

YAML
# .github/workflows/ci.yml
name: CI
on: [push]
jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        php: [8.2, 8.3]
    steps:
      - uses: actions/checkout@v4
      - uses: shivammathur/setup-php@v2
        with:
          php-version: ${{ matrix.php }}
      - run: composer install
      - run: php artisan test --parallel
      - run: php artisan test --coverage --min=70
        if: matrix.php == '8.3'

  larastan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: shivammathur/setup-php@v2
        with:
          php-version: 8.3
      - run: composer install
      - run: ./vendor/bin/phpstan analyse --level=5

出力:

TEXT
CONTAINER ID   IMAGE          STATUS         PORTS
abc123         nginx:latest   Up 2 hours     0.0.0.0:80->80/tcp

8. 総合サンプル:ShopMetricsテストスイート

PHP
// ============================================
// 総合:ShopMetricsテストスイート
// 対象:ユニット, 機能, APIテスト (Pest使用)
// ============================================

// tests/Unit/Services/OrderServiceTest.php
uses(\Tests\TestCase::class, \Illuminate\Foundation\Testing\RefreshDatabase::class);

it('calculates order total correctly', function () {
    $service = app(OrderService::class);
    $order = Order::factory()->create(['subtotal' => 0, 'total' => 0]);
    $order->items()->createMany([
        ['product_id' => 1, 'quantity' => 2, 'price' => 15.00],
        ['product_id' => 2, 'quantity' => 3, 'price' => 10.00],
    ]);

    $service->calculateTotal($order);

    expect($order->fresh()->total)->toBe(60.00);
});

it('applies discount within limits', function () {
    $service = app(OrderService::class);
    $order = Order::factory()->create(['subtotal' => 100, 'discount' => 0, 'total' => 100]);

    $service->applyDiscount($order, 20);

    expect($order->fresh()->total)->toBe(80.00);
});

it('rejects discount exceeding subtotal', function () {
    $service = app(OrderService::class);
    $order = Order::factory()->create(['subtotal' => 100]);

    expect(fn () => $service->applyDiscount($order, 150))
        ->toThrow(InvalidArgumentException::class);
});

// tests/Feature/Api/CompleteOrderFlowTest.php
it('completes full order flow via API', function () {
    $user = User::factory()->create(['role' => 'tenant_owner']);
    $shop = Shop::factory()->create(['tenant_id' => $user->tenant_id]);
    $products = Product::factory()->count(3)->create(['shop_id' => $shop->id, 'stock' => 50]);

    Event::fake();
    Queue::fake();

    // 注文の作成
    $response = $this->actingAs($user)->postJson('/api/v1/orders', [
        'shop_id' => $shop->id,
        'items' => [
            ['product_id' => $products[0]->id, 'quantity' => 2],
            ['product_id' => $products[1]->id, 'quantity' => 1],
        ],
    ]);

    $response->assertCreated();
    $orderId = $response->json('data.id');

    // データベースで注文を確認
    $this->assertDatabaseHas('orders', ['id' => $orderId, 'status' => 'pending']);

    // ステータスをprocessingに更新
    $this->actingAs($user)
        ->patchJson("/api/v1/orders/{$orderId}/status", ['status' => 'processing'])
        ->assertOk();

    // ステータスをcompletedに更新
    $this->actingAs($user)
        ->patchJson("/api/v1/orders/{$orderId}/status", ['status' => 'completed'])
        ->assertOk();

    expect(Order::find($orderId)->status)->toBe('completed');
});

❓ よくある質問

Q PHPUnitとPestのどちらを選ぶべきですか?
A 新規プロジェクトにはPestをお勧めします—構文がより簡潔で可読性が高いです。PestはPHPUnitの上に構築されているため, PHPUnitの全機能をサポートします。既存のPHPUnitプロジェクトでは両方を併用できます。
Q RefreshDatabaseとDatabaseMigrationsの違いは何ですか?
A RefreshDatabaseは最初にmigrateを実行し, 各テストをトランザクションでラップします (高速なロールバック)。DatabaseMigrationsは各テストの前にmigrate, 後にrollbackを実行します。RefreshDatabaseの方が高速でお勧めです。
Q テスト中に外部サービスをモックするにはどうすればよいですか?
A Http::fake()でHTTPレスポンスをシミュレートします:Http::fake(['api.stripe.com/*' => Http::response(['status' => 'ok'])])。メールにはMail::fake(), イベントにはEvent::fake(), キューにはQueue::fake()を使用します。
Q テストカバレッジはどの程度が適切ですか?
A コアビジネスロジックは80%以上, Modelメソッドは70%以上, コントローラーは60%以上を目標にしてください。100%のカバレッジは費用対効果が高くありません—すべてのゲッターやセッターよりも, 重要なビジネスパスと複雑なロジックのカバーに注力してください。
Q CIでテストを並列実行するにはどうすればよいですか?
A php artisan test --parallel=4で4プロセスを使用して並列実行します。各テストは独立している必要があり (他のテストのデータに依存しない), データベースの競合を避けるためにインメモリSQLiteデータベースを使用してください。
Q テストでファイルアップロードを処理するにはどうすればよいですか?
A Storage::fake('disk')でファイルシステムをシミュレートし, UploadedFile::fake()->create('test.jpg')でダミーファイルを作成します。ダミーファイルはテスト後に自動的にクリーンアップされます。

📖 まとめ


📝 練習問題

  1. 基本問題 (⭐):Pest構文を使用してShopモデルの3つのユニットテスト (active scope, revenue_formattedアクセサ, slug生成)を記述し, すべて合格することを確認してください。

  2. 応用問題 (⭐⭐):ShopController CRUD操作 (index/show/store/update/destroy)の機能テストを記述し, バリデーション失敗と権限チェックのアサーションを含め, RefreshDatabaseトレイトを使用してください。

  3. チャレンジ (⭐⭐⭐):完全なAPI注文フローテストを記述してください—注文の作成 → ステータス更新 → データベース変更の確認 → イベントとキューのモック → Event::assertDispatchedとQueue::assertPushedが合格することを確認し, カバレッジ率≥ 80%。

Web-Tutorial.com

Web-Tutorial 技術チーム

複数の開発者によって共同維持されているプログラミングチュートリアルプラットフォーム。各チュートリアルは専門分野の開発者が執筆・レビューしています。正確で信頼性の高いコンテンツを目指しています — 問題を見つけた場合はお知らせください。

100%