关于 laravel-testing
面向 Laravel 开发者的 Pest 测试指南,涵盖单元测试与功能测试。重点介绍 Arrange-Act-Assert、工厂、容器解析的 Action 和 Null Driver,以提升可读性并获得确定性结果。
主要功能
- 01Arrange-Act-Assert 测试结构
- 02声明式模型与 DTO 工厂
- 03基于容器的 Action 测试
- 04模拟自有依赖
- 05使用 Null Driver 获得确定性测试
- 06HTTP 功能与单元测试模式
适用场景
- 01隔离测试 Laravel 领域 Action
- 02覆盖 API 请求与响应流程
- 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 驱动、声明式工厂。
相关指南:
- testing-conventions.md - 测试文件结构和 RESTful 排序
- testing-factories.md - 让测试更易读的声明式工厂方法
- validation-testing.md - 表单请求验证测试
- Actions - 用于单元测试的 Action 模式
- Controllers - 用于功能测试的控制器模式
- DTOs - DTO 测试工厂
- Services - 使用 null 驱动的服务层
理念
测试应当:
- 隔离 - 一次只测试一件事
- 可靠 - 每次结果一致
- 易于维护 - 代码变更时易于更新
- 快速 - 快速获得反馈
- 贴近实际 - 使用工厂,而不是硬编码值
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');
测试 Actions
Actions 是领域逻辑的核心,应当在隔离状态下进行全面测试。
基本 Action 测试
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,
]);
});
测试 Action 守卫方法
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');
});
测试 Action 组合
关键模式: 始终使用 resolve() 从容器中解析 Actions,以便递归解析依赖项。使用 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()确保从容器中获取 Action,并解析其所有依赖项swap()用模拟对象替换容器中的依赖项- 容器会自动处理递归依赖项解析
- 如果依赖项新增了依赖项,你的测试也不会失效
模拟指南
仅模拟自己编写的代码
关键原则: 仅模拟由你控制的代码。切勿直接模拟外部服务。
✅ 良好做法 - 模拟自己的 Actions
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
需要模拟自己无法控制的代码时
如果你发现自己需要模拟外部服务,请创建一个抽象层:
- 创建服务层,使用 Manager 模式
- 定义驱动契约(接口)
- 实现真实驱动(封装外部 API)
- 创建 Null 驱动用于测试
- 添加 Facade以便使用
请参阅 Services,查看完整的实现示例。
使用 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 trait 和 DataTestFactory 基类——模仿 Laravel 模型工厂 API,为 Data 对象创建工厂。
// 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);
有关测试工厂的设置和实现,请参阅 DTOs。
测试策略
功能测试(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);