mcpField
L
laravel-testing

laravel-testing

github.com/leeovery测试与质量1562 次安装2 周前更新
代码仓库

关于 laravel-testing

面向 Laravel 开发者的 Pest 测试指南,涵盖单元测试与功能测试。重点介绍 Arrange-Act-Assert、工厂、容器解析的 Action 和 Null Driver,以提升可读性并获得确定性结果。

主要功能

  1. 01Arrange-Act-Assert 测试结构
  2. 02声明式模型与 DTO 工厂
  3. 03基于容器的 Action 测试
  4. 04模拟自有依赖
  5. 05使用 Null Driver 获得确定性测试
  6. 06HTTP 功能与单元测试模式

适用场景

  1. 01隔离测试 Laravel 领域 Action
  2. 02覆盖 API 请求与响应流程
  3. 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 驱动、声明式工厂。

相关指南:

理念

测试应当:

  • 隔离 - 一次只测试一件事
  • 可靠 - 每次结果一致
  • 易于维护 - 代码变更时易于更新
  • 快速 - 快速获得反馈
  • 贴近实际 - 使用工厂,而不是硬编码值

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
需要模拟自己无法控制的代码时

如果你发现自己需要模拟外部服务,请创建一个抽象层:

  1. 创建服务层,使用 Manager 模式
  2. 定义驱动契约(接口)
  3. 实现真实驱动(封装外部 API)
  4. 创建 Null 驱动用于测试
  5. 添加 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);

评论

支持 Markdown