laravel-testing

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Laravel Testing

Laravel 测试

Testing patterns with Pest: Arrange-Act-Assert, proper mocking, null drivers, declarative factories.
Related guides:
  • testing-conventions.md - Test file structure and RESTful ordering
  • testing-factories.md - Declarative factory methods for readable tests
  • validation-testing.md - Form request validation testing
  • Actions - Action pattern for unit testing
  • Controllers - Controller patterns for feature testing
  • DTOs - DTO test factories
  • Services - Service layer with null drivers
基于Pest的测试模式:Arrange-Act-Assert(准备-执行-断言)、正确的Mocking、空驱动、声明式工厂。
相关指南:
  • testing-conventions.md - 测试文件结构与RESTful排序
  • testing-factories.md - 用于可读性测试的声明式工厂方法
  • validation-testing.md - 表单请求验证测试
  • Actions - 用于单元测试的Action模式
  • Controllers - 用于功能测试的控制器模式
  • DTOs - DTO测试工厂
  • Services - 带空驱动的服务层

Philosophy

理念

Testing should be:
  • Isolated - Test one thing at a time
  • Reliable - Consistent results every time
  • Maintainable - Easy to update when code changes
  • Fast - Quick feedback loop
  • Realistic - Use factories, not hardcoded values
测试应具备以下特性:
  • 隔离性 - 每次只测试一件事
  • 可靠性 - 每次运行都得到一致结果
  • 可维护性 - 代码变更时易于更新
  • 高效性 - 快速反馈循环
  • 真实性 - 使用工厂生成数据,而非硬编码值

The Triple-A Pattern

Triple-A 模式

Every test should follow the Arrange-Act-Assert pattern:
每个测试都应遵循**Arrange-Act-Assert(准备-执行-断言)**模式:

1. Arrange the World

1. 准备测试环境

Set up all the data and dependencies needed using factories:
php
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' => 'customer@example.com',
        '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);
});
使用工厂设置所需的所有数据和依赖:
php
it('creates an order with items', function () {
    // Arrange: 创建测试环境状态
    $user = User::factory()->create();
    $product = Product::factory()->active()->create(['price' => 1000]);

    $data = CreateOrderData::from([
        'customer_email' => 'customer@example.com',
        'items' => [
            ['product_id' => $product->id, 'quantity' => 2],
        ],
    ]);

    // Act: 执行操作
    $order = resolve(CreateOrderAction::class)($user, $data);

    // Assert: 验证结果
    expect($order)
        ->toBeInstanceOf(Order::class)
        ->and($order->items)->toHaveCount(1)
        ->and($order->total)->toBe(2000);
});

2. Act on the World

2. 执行测试操作

Perform the single operation you're testing:
php
// ✅ 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);
执行你要测试的单一操作
php
// ✅ 良好实践 - 单一、清晰的操作
$order = resolve(CreateOrderAction::class)($user, $data);

// ❌ 不良实践 - 操作与断言混合
$order = resolve(CreateOrderAction::class)($user, $data);
expect($order)->toBeInstanceOf(Order::class);
$order->refresh();
expect($order->total)->toBe(2000);

3. Assert on the Results

3. 断言测试结果

Verify the outcomes of your action:
php
// ✅ 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');
验证操作的输出结果
php
// ✅ 良好实践 - 清晰、聚焦的断言
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,
]);

// ❌ 不良实践 - 测试实现细节
expect($order->getAttribute('status'))->toBe('pending');

Testing Actions

测试Actions

Actions are the heart of your domain logic and should be thoroughly tested in isolation.
Actions是领域逻辑的核心,应进行彻底的隔离测试。

Basic Action Test

基础Action测试

php
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,
    ]);
});
php
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,
    ]);
});

Testing Action Guard Methods

测试Action的守卫方法

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

Testing Action Composition

测试Action组合

Critical pattern: Always resolve actions from the container using
resolve()
so dependencies are recursively resolved. Use
swap()
to replace dependencies with mocked versions.
php
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);
});
Why this pattern:
  • resolve()
    ensures the action is pulled from the container with all dependencies
  • swap()
    replaces the dependency in the container with your mock
  • Container handles recursive dependency resolution automatically
  • If a dependency adds a new dependency, your tests don't break
关键模式: 始终使用
resolve()
从容器中解析Actions,确保依赖被递归解析。使用
swap()
将依赖替换为Mock版本。
php
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依赖Action并替换到容器中
    $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(),确保注入Mock依赖
    $result = resolve(ProcessOrderAction::class)($order);

    // Assert
    expect($result->total)->toBe(10000);
});
该模式的优势:
  • resolve()
    确保从容器中获取带有所有依赖的Action
  • swap()
    将容器中的依赖替换为你的Mock
  • 容器自动处理递归依赖解析
  • 如果依赖新增了子依赖,你的测试不会中断

Mocking Guidelines

Mocking 指南

Only Mock What You Own

仅Mock你拥有的代码

Critical principle: Only mock code that you control. Never mock external services directly.
关键原则: 仅Mock你控制的代码。永远不要直接Mock外部服务。

✅ Good - Mock Your Own Actions

✅ 良好实践 - Mock你自己的Actions

php
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);
php
use function Pest\Laravel\mock;
use function Pest\Laravel\swap;

// Mock你自己的Action并替换到容器中
$sendEmail = mock(SendWelcomeEmailAction::class);
$sendEmail->shouldReceive('__invoke')
    ->once()
    ->with(Mockery::type(User::class));
swap(SendWelcomeEmailAction::class, $sendEmail);

// 然后解析要测试的Action - 它会接收Mock依赖
$result = resolve(RegisterUserAction::class)($data);

✅ Advanced - Verify Mock Arguments with Assertions

✅ 进阶 - 用断言验证Mock参数

Use
withArgs()
with a closure to verify the exact instances and values being passed:
php
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);
});
使用
withArgs()
和闭包来验证传递的精确实例和值
php
it('processes match with correct arguments', function () {
    $matchAttempt = MatchAttempt::factory()->create();
    $data = MatchData::testFactory()->make();

    // Mock并使用expect()断言验证精确参数
    $mockAction = mock(CreateMatchResultAction::class);
    $mockAction->shouldReceive('__invoke')
        ->once()
        ->withArgs(function (MatchAttempt $_matchAttempt, MatchData $_data) use ($data, $matchAttempt) {
            // 验证传递的是精确的模型实例
            expect($_matchAttempt->is($matchAttempt))->toBeTrue()
                // 验证传递的是精确的DTO值
                ->and($_data)->toBe($data->matches->first());

            return true; // 返回true以通过断言
        });
    swap(CreateMatchResultAction::class, $mockAction);

    // Act
    resolve(ProcessMatchAction::class)($matchAttempt, $data);
});

✅ Good - Mock Your Own Services (via Facade)

✅ 良好实践 - Mock你自己的服务(通过Facade)

php
// Mock your own service through its facade
Payment::shouldReceive('createPaymentIntent')
    ->once()
    ->with(10000, 'usd')
    ->andReturn(PaymentIntentData::from([
        'id' => 'pi_test_123',
        'status' => 'succeeded',
    ]));
php
// 通过Facade Mock你自己的服务
Payment::shouldReceive('createPaymentIntent')
    ->once()
    ->with(10000, 'usd')
    ->andReturn(PaymentIntentData::from([
        'id' => 'pi_test_123',
        'status' => 'succeeded',
    ]));

❌ Bad - Mocking External Libraries Directly

❌ 不良实践 - 直接Mock外部库

php
// ❌ 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
php
// ❌ 不要这样做 - 直接Mock Stripe SDK
$stripe = Mockery::mock(\Stripe\StripeClient::class);
$stripe->shouldReceive('paymentIntents->create')
    ->andReturn(/* ... */);

// 这种方式很脆弱,Stripe更新SDK时会失效

When You Need to Mock Something You Don't Own

当你需要Mock非自有代码时

If you find yourself needing to mock an external service, create an abstraction:
  1. Create a Service Layer with the Manager pattern
  2. Define a Driver Contract (interface)
  3. Implement the Real Driver (wraps external API)
  4. Create a Null Driver for testing
  5. Add a Facade for convenience
See Services for complete implementation examples.
如果你发现需要Mock外部服务,创建一个抽象层
  1. 创建服务层,使用Manager模式
  2. 定义驱动契约(接口)
  3. 实现真实驱动(封装外部API)
  4. 创建空驱动用于测试
  5. 添加Facade以方便使用
完整实现示例请参见 Services

Using Null Drivers

使用空驱动

The null driver pattern provides deterministic, fast tests without external dependencies:
php
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);
});
Benefits of null drivers:
  • No mocking required
  • Fast execution (no network calls)
  • Deterministic results
  • Can test error scenarios by extending null driver
  • Matches real driver interface exactly
空驱动模式提供可预测、快速的测试,无需外部依赖:
php
it('processes payment successfully', function () {
    // Arrange - 使用空驱动(在phpunit.xml或.env.testing中配置)
    Config::set('payment.default', 'null');

    $order = Order::factory()->create(['total' => 10000]);
    $data = PaymentData::from(['amount' => 10000, 'currency' => 'usd']);

    // Act - 无需Mock,空驱动返回测试数据
    $payment = resolve(ProcessPaymentAction::class)($order, $data);

    // Assert
    expect($payment)
        ->toBeInstanceOf(Payment::class)
        ->and($payment->status)->toBe(PaymentStatus::Completed);
});
空驱动的优势:
  • 无需Mock
  • 执行速度快(无网络请求)
  • 结果可预测
  • 可通过扩展空驱动测试错误场景
  • 与真实驱动接口完全匹配

Testing Error Scenarios

测试错误场景

Extend the null driver for specific test scenarios:
php
// 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');
});
扩展空驱动以测试特定场景:
php
// tests/Fakes/FailingPaymentDriver.php
class FailingPaymentDriver implements PaymentDriver
{
    public function createPaymentIntent(int $amount, string $currency): PaymentIntentData
    {
        throw PaymentException::failedToCharge('Card declined');
    }
}

// 在测试中
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');
});

Using Factories

使用工厂

Factories create realistic, randomized test data that makes tests more robust.
工厂生成真实、随机的测试数据,让测试更健壮。

Model Factories

模型工厂

php
// 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();
php
// 使用工厂准备数据
$user = User::factory()->create();
$product = Product::factory()->active()->create();
$order = Order::factory()->for($user)->create();

// 带状态的工厂
$pendingOrder = Order::factory()->pending()->create();
$paidOrder = Order::factory()->paid()->create();

// 带关联关系的工厂
$user = User::factory()
    ->has(Order::factory()->count(3))
    ->create();

Declarative Factory Methods

声明式工厂方法

Critical principle: Make tests declarative and readable by hiding database implementation details behind factory methods.
php
// ❌ 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();
→ Complete declarative factory patterns: testing-factories.md
关键原则: 通过工厂方法隐藏数据库实现细节,让测试声明式且易读
php
// ❌ 不良实践 - 数据库架构暴露到测试中
$calendar = Calendar::factory()->create([
    'status' => 'accepted',
    'reminder_sent_at' => null,
    'approved_by' => User::factory()->create()->id,
    'approved_at' => now(),
]);

// ✅ 良好实践 - 声明式且易读
$calendar = Calendar::factory()->accepted()->create();
→ 完整的声明式工厂模式:testing-factories.md

DTO Test Factories

DTO测试工厂

DTOs should provide test factories for consistent test data:
php
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();
DTO应提供测试工厂以确保测试数据一致:
php
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),
                ],
            ],
        );
    }
}

// 在测试中使用
$data = CreateOrderData::testFactory();

Testing Strategy

测试策略

Feature Tests (HTTP Layer)

功能测试(HTTP层)

Test the complete request/response cycle:
php
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' => 'test@example.com',
            'items' => [
                ['product_id' => $product->id, 'quantity' => 2],
            ],
        ]);

    $response->assertCreated()
        ->assertJsonStructure([
            'data' => ['id', 'status', 'items'],
        ]);
});
测试完整的请求/响应周期
php
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' => 'test@example.com',
            'items' => [
                ['product_id' => $product->id, 'quantity' => 2],
            ],
        ]);

    $response->assertCreated()
        ->assertJsonStructure([
            'data' => ['id', 'status', 'items'],
        ]);
});

Unit Tests (Actions)

单元测试(Actions)

Test domain logic in isolation:
php
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);
});
隔离测试领域逻辑
php
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);
});

Avoiding Brittle Tests

避免脆弱的测试

Brittle tests break when implementation changes, even if behavior is correct.
当实现细节变更时,脆弱的测试会失效,即使行为是正确的。

Signs of Brittle Tests

脆弱测试的迹象

  • Too many mocks
  • Testing implementation details
  • Hardcoded values everywhere
  • Complex setup with many steps
  • Tests break with refactoring
  • 过多的Mocks
  • 测试实现细节
  • 到处都是硬编码值
  • 复杂的多步骤准备
  • 重构时测试失效

How to Avoid Brittleness

如何避免脆弱性

1. Use Real Instances When Possible

1. 尽可能使用真实实例

php
// ✅ 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
});
php
// ✅ 良好实践 - 使用真实实例
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);
});

// ❌ 不良实践 - Mock所有东西
it('calculates order total', function () {
    $item1 = Mockery::mock(OrderItem::class);
    $item1->shouldReceive('getPrice')->andReturn(1000);
    // ... 过多的Mock
});

2. Test Behavior, Not Implementation

2. 测试行为,而非实现

php
// ✅ 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
});
php
// ✅ 良好实践 - 测试行为
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);
    });
});

// ❌ 不良实践 - 测试实现细节
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';
        }));
    // 过于具体,模板名称变更时会失效
});

3. Use Factories Instead of Hardcoded Data

3. 使用工厂而非硬编码数据

php
// ✅ 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',
);
php
// ✅ 良好实践 - 使用工厂
$user = User::factory()->create();
$data = ProfileData::testFactory();

// ❌ 不良实践 - 硬编码数据
$data = new ProfileData(
    firstName: 'John',
    lastName: 'Doe',
    phone: '555-1234',
    bio: 'Test bio',
);

4. Minimize Mocking

4. 最小化Mock

Rule of thumb: Mock collaborators, not data.
php
// ✅ 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
经验法则: Mock协作对象,而非数据。
php
// ✅ 良好实践 - Mock通知服务(协作对象)
$notifier = mock(NotificationService::class);
$notifier->shouldReceive('send')->once();
swap(NotificationService::class, $notifier);

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

// ❌ 不良实践 - Mock数据(订单、用户)
$order = Mockery::mock(Order::class);
// ... Mock数据对象会让测试变得脆弱

Common Testing Patterns

常见测试模式

Testing State Transitions

测试状态转换

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

Testing Relationships

测试关联关系

php
it('creates order with items', function () {
    $user = User::factory()->create();
    $products = Product::factory()->count(3)->create();

    $data = CreateOrderData::from([
        'customer_email' => 'test@example.com',
        'items' => $products->map(fn ($p) => [
            'product_id' => $p->id,
            'quantity' => 2,
        ])->all(),
    ]);

    $order = resolve(CreateOrderAction::class)($user, $data);

    expect($order->items)->toHaveCount(3);
});
php
it('creates order with items', function () {
    $user = User::factory()->create();
    $products = Product::factory()->count(3)->create();

    $data = CreateOrderData::from([
        'customer_email' => 'test@example.com',
        'items' => $products->map(fn ($p) => [
            'product_id' => $p->id,
            'quantity' => 2,
        ])->all(),
    ]);

    $order = resolve(CreateOrderAction::class)($user, $data);

    expect($order->items)->toHaveCount(3);
});

Testing Transactions

测试事务

php
it('rolls back transaction on failure', function () {
    $user = User::factory()->create();

    $data = CreateOrderData::from([
        'customer_email' => 'test@example.com',
        '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);
});
php
it('rolls back transaction on failure', function () {
    $user = User::factory()->create();

    $data = CreateOrderData::from([
        'customer_email' => 'test@example.com',
        'items' => [
            ['product_id' => 99999, 'quantity' => 1], // 不存在的产品
        ],
    ]);

    expect(fn () => resolve(CreateOrderAction::class)($user, $data))
        ->toThrow(Exception::class);

    assertDatabaseCount('orders', 0);
    assertDatabaseCount('order_items', 0);
});

Testing Email/Notifications

测试邮件/通知

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

Testing Jobs

测试任务

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

Best Practices Summary

最佳实践总结

✅ Do This

✅ 推荐做法

  • Follow triple-A pattern - Arrange, Act, Assert
  • Use factories for all test data
  • Create declarative factory methods -
    Calendar::factory()->accepted()
    not
    ['status' => 'accepted']
  • Test actions in isolation - Unit test your domain logic
  • Mock what you own - Actions, services you control
  • Create abstractions when you need to mock external services
  • Use null drivers for external service testing
  • Test behavior, not implementation
  • Keep tests simple - One concept per test
  • Use DTO test factories for consistent data
  • 遵循Triple-A模式 - 准备、执行、断言
  • 使用工厂生成所有测试数据
  • 创建声明式工厂方法 - 使用
    Calendar::factory()->accepted()
    而非
    ['status' => 'accepted']
  • 隔离测试Actions - 单元测试领域逻辑
  • Mock你拥有的代码 - 你控制的Actions、服务
  • 创建抽象层当需要Mock外部服务时
  • 使用空驱动测试外部服务
  • 测试行为,而非实现
  • 保持测试简单 - 每个测试一个概念
  • 使用DTO测试工厂确保数据一致

❌ Don't Do This

❌ 不推荐做法

  • Mock external libraries - Create service layer instead
  • Hardcode test data - Use factories
  • Leak database schema into tests - Use declarative factory methods
  • Test implementation details - Test behavior
  • Create brittle tests - Too many mocks, too specific
  • Skip factories - Always use factories for models and DTOs
  • Mix arrange and act - Keep them separate
  • Over-mock - Use real instances when possible
  • Mock外部库 - 改为创建服务层
  • 硬编码测试数据 - 使用工厂
  • 将数据库架构暴露到测试中 - 使用声明式工厂方法
  • 测试实现细节 - 测试行为
  • 创建脆弱的测试 - 过多Mock、过于具体
  • 跳过工厂 - 始终为模型和DTO使用工厂
  • 混合准备与执行步骤 - 保持两者分离
  • 过度Mock - 尽可能使用真实实例

Quick Reference

快速参考

Test Structure

测试结构

php
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 */;
});
php
it('does something', function () {
    // Arrange - 使用声明式工厂准备测试环境
    $model = Model::factory()->active()->create();
    $data = Data::testFactory();

    // Act - 执行操作
    $result = resolve(Action::class)($model, $data);

    // Assert - 验证结果
    expect($result)->/* 断言 */;
});

Mocking Pattern

Mocking模式

php
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 */);
php
use function Pest\Laravel\mock;
use function Pest\Laravel\swap;

// Mock依赖Action
$mockAction = mock(YourDependencyAction::class);
$mockAction->shouldReceive('__invoke')
    ->once()
    ->with(/* 预期参数 */)
    ->andReturn(/* 返回值 */);

// 替换到容器中
swap(YourDependencyAction::class, $mockAction);

// 解析要测试的Action - 容器会注入Mock依赖
$result = resolve(ActionUnderTest::class)(/* 参数 */);

Database Assertions

数据库断言

php
use function Pest\Laravel\assertDatabaseHas;
use function Pest\Laravel\assertDatabaseCount;

assertDatabaseHas('orders', ['id' => $order->id]);
assertDatabaseCount('orders', 1);
php
use function Pest\Laravel\assertDatabaseHas;
use function Pest\Laravel\assertDatabaseCount;

assertDatabaseHas('orders', ['id' => $order->id]);
assertDatabaseCount('orders', 1);