Laravel: Laravel自动化测试
最后更新:2026-08-26
测试是 Laravel 的"安全网"——每次改动代码都跑一遍测试,有 Bug 立刻抓住,不会漏到生产环境。
1. 你将学到
- 测试环境配置:phpunit.xml 与 .env.testing
- 单元测试:Model 方法、Service 类隔离测试
- 功能测试:HTTP 请求测试与数据库事务 Trait
- API 测试:Sanctum Token 认证 + JSON 断言
- 测试覆盖率:Coverage 报告与 CI 集成
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) 测试金字塔
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') 创建假文件。测试后假文件自动清理。
📖 小节
- 测试环境用 SQLite 内存数据库,速度最快
- 单元测试测纯逻辑(Model/Service),功能测试测 HTTP 请求
- Pest 语法简洁,底层兼容 PHPUnit
- API 测试用 actingAs()/Bearer Token + JSON 断言
- Event::fake()/Queue::fake() 隔离副作用
- CI 自动运行测试,覆盖率门槛 70-80%
📝 作业
-
基础题(⭐):为 Shop 模型编写 3 个单元测试(active scope、revenue_formatted accessor、slug 生成),使用 Pest 语法,确保全部通过。
-
进阶题(⭐⭐):编写 ShopController CRUD 的功能测试(index/show/store/update/destroy),包含验证失败和权限检查的断言,使用 RefreshDatabase trait。
-
挑战题(⭐⭐⭐):编写完整的 API 订单流程测试——创建订单→更新状态→验证数据库变更→Mock 事件和队列→确认 Event::assertDispatched 和 Queue::assertPushed 通过,覆盖率 ≥ 80%。