mcpField
L
laravel-testing

laravel-testing

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

关于 laravel-testing

Laravel Testing 是面向 Laravel 开发者的 Pest 测试技能,侧重 Arrange-Act-Assert、真实工厂数据、隔离测试 Action,以及谨慎使用模拟对象和 null driver。

主要功能

  1. 01Arrange-Act-Assert 测试结构
  2. 02Action 与 HTTP 功能测试
  3. 03使用工厂生成真实测试数据
  4. 04通过容器模拟自有依赖
  5. 05用 null driver 测试外部服务

适用场景

  1. 01为 Laravel Action 编写聚焦测试
  2. 02测试完整的 API 请求与响应
  3. 03通过声明式工厂编写易读测试
  4. 04无需网络调用即可测试外部服务结果

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/leeovery/claude-laravel --skill laravel-testing

安装后在 Claude Code 中输入/laravel-testing确认已加载。

附带文件references/testing-conventions.mdreferences/testing-factories.md

详细介绍

Laravel 测试

使用 Pest 的测试模式:Arrange-Act-Assert、恰当的模拟、空驱动程序、声明式工厂。

相关指南:

理念

测试应当:

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

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. 创建用于测试的空驱动程序
  5. 添加 Facade 以便使用

完整实现示例请参阅 Services。

使用空驱动程序

空驱动程序模式无需外部依赖即可提供确定且快速的测试:

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

空驱动程序的优点:

  • 无需模拟
  • 执行速度快(无网络调用)
  • 结果确定
  • 可通过扩展空驱动程序测试错误场景
  • 与真实驱动程序的接口完全一致
测试错误场景

针对特定测试场景扩展空驱动程序:

// 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();

    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 应提供测试工厂,以便生成一致的测试数据:

class CreateOrderData extends Data
{
    public function __construct(
        public string $customerEmail,
        public OrderStatus $status,
        public array $items,
    ) {}

    public static function testFactory(): self
    {
        return new self(
            customerEmail: fake()->email(),
            status: OrderStatus::Pending,
            items: [
                [
                    'product_id' => Product::factory()->create()->id,
                    'quantity' => fake()->numberBetween(1, 5),
                ],
            ],
        );
    }
}

// Usage in tests
$data = CreateOrderData::testFactory();

测试策略

功能测试(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);
});

避免脆弱的测试

即使实现发生变化、行为仍然正确,脆弱的测试也会失败。

脆弱测试的迹象
  • Mock 过多
  • 测试实现细节
  • 到处硬编码值
  • 设置过程复杂、步骤繁多
  • 重构时测试失败
如何避免脆弱性
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();
    $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();

// ❌ Bad - Hardcoded data
$data = new ProfileData(
    firstName: 'John',
    lastName: 'Doe',
    phone: '555-1234',
    bio: 'Test bio',
);
4. 尽量减少 Mock

**经验法则:**Mock 协作者,而非数据。

// ✅ 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);
});
测试邮件/通知
use Illuminate\Support\Facades\Mail;

it('sends welcome email to new user', function () {
    Mail::fake();
    $data = RegisterUserData::testFactory();

    $user = resolve(RegisterUserAction::class)($data);

    Mail::assertSent(WelcomeEmail::class, function ($mail) use ($user) {
        return $mail->hasTo($user->email);
    });
});
测试任务
use Illuminate\Support\Facades\Queue;

it('dispatches job to process order', function () {
    Queue::fake();
    $order = Order::factory()->create();

    resolve(ProcessOrderAction::class)($order);

    Queue::assertPushed(ProcessOrderJob::class, function ($job) use ($order) {
        return $job->order->id === $order->id;
    });
});

最佳实践摘要

✅ 应该这样做
  • 遵循 AAA 模式——Arrange、Act、Assert(准备、执行、断言)
  • 所有测试数据都使用工厂
  • 创建声明式工厂方法——使用 Calendar::factory()->accepted(),而不是 ['status' => 'accepted']
  • 隔离测试操作——对领域逻辑进行单元测试
  • Mock 自己拥有的对象——自己控制的操作和服务
  • 需要 Mock 外部服务时创建抽象
  • 使用 null 驱动程序测试外部服务
  • 测试行为,而非实现
  • 保持测试简单——每个测试只验证一个概念
  • 使用 DTO 测试工厂,确保数据一致
❌ 不要这样做
  • Mock 外部库——改为创建服务层
  • 硬编码测试数据——使用工厂
  • 在测试中泄露数据库架构——使用声明式工厂方法
  • 测试实现细节——应测试行为
  • 编写脆弱的测试——Mock 过多、过于具体
  • 跳过工厂——模型和 DTO 始终使用工厂
  • 混合准备和执行——将两者分开
  • 过度 Mock——尽可能使用真实实例

快速参考

测试结构
it('does something', function () {
    // Arrange - Set up the world with declarative factories
    $model = Model::factory()->active()->create();
    $data = Data::testFactory();

    // Act - Perform the operation
    $result = resolve(Action::class)($model, $data);

    // Assert - Verify the results
    expect($result)->/* assertions */;
});
Mock 模式
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