Laravel自動テスト
テストはLaravelの「セーフティネット」です—コードを変更するたびにテストを実行することで, バグがすぐに検出され, 本番環境に漏れ込むのを防ぎます。
1. 学ぶこと
- テスト環境設定:phpunit.xmlと.env.testing
- ユニットテスト:ModelメソッドとServiceクラスの分離テスト
- 機能テスト:HTTPリクエストテストとDatabase Transactionsトレイト
- APIテスト:Sanctumトークン認証 + JSONアサーション
- テストカバレッジ:CIと統合されたカバレッジレポート
2. 夜勤開発者の実話
(1) 痛み:デプロイのたびにバグが発生する
Bobは金曜の夜にShopMetricsの新バージョンをデプロイしました—注文割引ロジックを変更し, いくつかのシナリオを手動テストして問題ないことを確認してからリリースしました。しかし月曜の朝, Aliceがネガティブな割引によって注文合計がマイナスになり, 100人以上のユーザーが無料で商品を受け取ってしまったと報告しました。Charlieは言いました。「自動テストがあれば, コードがコミットされた時にこのバグは検出されていたはずです。」
(2) 自動テストによる解決策
自動テストはコード変更のたびに自動的に実行されます—割引ロジックはテストで完全にカバーされ, ネガティブ割引のテストケースがすぐに失敗し, バグが本番環境に入るのを防ぎます。
// ネガティブ割引が拒否されることをテスト
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
<!-- 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
# .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) テストピラミッド
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テスト環境設定
# .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 # カバレッジレポート付き
出力:
# コマンド実行成功
4. ユニットテスト
(1) PHPUnit vs. Pest
| 観点 | PHPUnit | Pest |
|---|---|---|
| 構文 | クラス + メソッド | 関数型 |
| サンプルコード | 多い | 少ない |
| 可読性 | 中 | ✅ 高い |
| 互換性 | 100% | PHPUnitベース |
| 適している | 複雑なテスト | シンプルで高速 |
(2) PHPUnitスタイル
// 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スタイル
// 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ユニットテスト
// 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);
});
出力:
// 実行成功
5. 機能テスト
(1) HTTPリクエストテスト
// 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機能テスト
// 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);
});
出力:
// 実行成功
6. APIテスト
(1) Sanctumトークン認証テスト
// 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テストスイート
// 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);
});
出力:
// 実行成功
7. テストカバレッジとCI
(1) カバレッジレポート
# HTMLカバレッジレポートの生成
php artisan test --coverage-html=coverage
# 最小カバレッジ閾値
php artisan test --coverage --min=80
# 特定ディレクトリのカバレッジ
php artisan test --coverage-filter=app/Services
(2) GitHub Actions CI
# .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テスト設定
# .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
出力:
CONTAINER ID IMAGE STATUS PORTS
abc123 nginx:latest Up 2 hours 0.0.0.0:80->80/tcp
8. 総合サンプル:ShopMetricsテストスイート
// ============================================
// 総合: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');
});
❓ よくある質問
migrateを実行し, 各テストをトランザクションでラップします (高速なロールバック)。DatabaseMigrationsは各テストの前にmigrate, 後にrollbackを実行します。RefreshDatabaseの方が高速でお勧めです。Http::fake()でHTTPレスポンスをシミュレートします:Http::fake(['api.stripe.com/*' => Http::response(['status' => 'ok'])])。メールにはMail::fake(), イベントにはEvent::fake(), キューにはQueue::fake()を使用します。php artisan test --parallel=4で4プロセスを使用して並列実行します。各テストは独立している必要があり (他のテストのデータに依存しない), データベースの競合を避けるためにインメモリSQLiteデータベースを使用してください。Storage::fake('disk')でファイルシステムをシミュレートし, UploadedFile::fake()->create('test.jpg')でダミーファイルを作成します。ダミーファイルはテスト後に自動的にクリーンアップされます。📖 まとめ
- テスト環境はインメモリSQLiteデータベースを使用し, 最速のパフォーマンスを提供します
- ユニットテストは純粋ロジック (Model/Service)をカバーし, 機能テストはHTTPリクエストをカバーします
- Pestは簡潔な構文で, コアはPHPUnitと互換性があります
- APIテストは
actingAs()/ Bearerトークン + JSONアサーションを使用します - Event::fake()/Queue::fake()で副作用を分離します
- CIがテストを自動実行し, コードカバレッジ閾値は70〜80%
📝 練習問題
-
基本問題 (⭐):Pest構文を使用してShopモデルの3つのユニットテスト (active scope, revenue_formattedアクセサ, slug生成)を記述し, すべて合格することを確認してください。
-
応用問題 (⭐⭐):ShopController CRUD操作 (index/show/store/update/destroy)の機能テストを記述し, バリデーション失敗と権限チェックのアサーションを含め, RefreshDatabaseトレイトを使用してください。
-
チャレンジ (⭐⭐⭐):完全なAPI注文フローテストを記述してください—注文の作成 → ステータス更新 → データベース変更の確認 → イベントとキューのモック → Event::assertDispatchedとQueue::assertPushedが合格することを確認し, カバレッジ率≥ 80%。



