Laravel: Laravel自动化测试

最后更新:2026-08-26

测试是 Laravel 的"安全网"——每次改动代码都跑一遍测试,有 Bug 立刻抓住,不会漏到生产环境。

1. 你将学到


2. 一个深夜值班开发者的真实故事

(1) 痛点:每次上线都出 Bug

Bob 周五晚上部署 ShopMetrics 新版本——改了订单折扣逻辑,手动测了几个场景没问题就上了。结果周一早上 Alice 报告:负数折扣导致订单金额变成负数,100 多个用户免费拿了商品。Charlie 说:"如果你有自动化测试,这个 Bug 在提交代码时就会被发现。"

(2) 自动化测试的解法

自动化测试在每次代码变更时自动运行——折扣逻辑有测试覆盖,负数折扣的测试用例会立刻报红,Bug 根本不会进生产。

PHP
// Test that negative discount is rejected
test('order rejects negative discount', function () {
    $response = $this->postJson('/api/v1/orders', [
        'items' => [['product_id' => 1, 'quantity' => 1]],
        'discount' => -10,  // Should be rejected!
    ]);
    $response->assertJsonValidationErrors('discount');
});

(3) 收益

Bob 加了测试后,负数折扣的 Bug 在开发时就被测试抓住,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 — dedicated test environment
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["Unit Tests<br/>(Fast, many)<br/>Model methods, Service logic"] --> B["Feature Tests<br/>(Medium, some)<br/>HTTP requests, DB queries"]
    B --> C["API/E2E Tests<br/>(Slow, few)<br/>Full request lifecycle"]
层级 数量 速度 测试内容
Unit 快(ms) 纯逻辑、模型方法
Feature 中(100ms) HTTP 请求、DB 操作
API/E2E 慢(s) 完整请求链路

▶ 示例:ShopMetrics 测试环境配置

BASH
# Create .env.testing
cp .env .env.testing

# Edit .env.testing
# DB_CONNECTION=sqlite
# DB_DATABASE=:memory:
# QUEUE_CONNECTION=sync

# Run tests
php artisan test           # All tests
php artisan test --parallel  # Parallel (faster)
php artisan test --coverage  # With coverage report

输出:

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');
});

▶ 示例:ShopMetrics 模型单元测试

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() 软删除记录存在

▶ 示例: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 Token 认证测试

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 包含片段

▶ 示例: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
# Generate HTML coverage report
php artisan test --coverage-html=coverage

# Minimum coverage threshold
php artisan test --coverage --min=80

# Coverage for specific directory
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

▶ 示例: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
// ============================================
// Comprehensive: ShopMetrics Test Suite
// Covers: unit, feature, API tests with 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();

    // Create order
    $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');

    // Verify order in database
    $this->assertDatabaseHas('orders', ['id' => $orderId, 'status' => 'pending']);

    // Update status to processing
    $this->actingAs($user)
        ->patchJson("/api/v1/orders/{$orderId}/status", ['status' => 'processing'])
        ->assertOk();

    // Update status to 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 测试中如何 Mock 外部服务?
A 用 Http::fake() 模拟 HTTP 响应:Http::fake(['api.stripe.com/*' => Http::response(['status' => 'ok'])])。邮件用 Mail::fake(),事件用 Event::fake(),队列用 Queue::fake()。
Q 测试覆盖率多少合适?
A 核心业务逻辑 80%+,模型方法 70%+,控制器 60%+。追求 100% 覆盖率性价比低——重点覆盖关键业务路径和复杂逻辑,而不是每个 getter/setter。
Q 如何在 CI 中并行跑测试?
A php artisan test --parallel=4 使用 4 个进程并行运行。需要每个测试独立(不依赖其他测试的数据),使用 SQLite 内存数据库避免数据库竞争。
Q 测试中如何处理文件上传?
A 用 Storage::fake('disk') 模拟文件系统,用 UploadedFile::fake()->create('test.jpg') 创建假文件。测试后假文件自动清理。

📖 小节


📝 作业

  1. 基础题(⭐):为 Shop 模型编写 3 个单元测试(active scope、revenue_formatted accessor、slug 生成),使用 Pest 语法,确保全部通过。

  2. 进阶题(⭐⭐):编写 ShopController CRUD 的功能测试(index/show/store/update/destroy),包含验证失败和权限检查的断言,使用 RefreshDatabase trait。

  3. 挑战题(⭐⭐⭐):编写完整的 API 订单流程测试——创建订单→更新状态→验证数据库变更→Mock 事件和队列→确认 Event::assertDispatched 和 Queue::assertPushed 通过,覆盖率 ≥ 80%。

Web-Tutorial.com

Web-Tutorial 技术团队

由多位开发者共同维护的编程教程平台。每篇教程由对应领域的开发者编写和审核,确保内容准确可靠。如发现任何问题,欢迎向我们反馈。

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏