| name | laravel-testing |
| description | Comprehensive testing patterns with Pest. Use when writing or modifying tests, mocking, factories, or test patterns. |
Laravel Testing
Testing patterns with Pest: Arrange-Act-Assert, proper mocking, null drivers, declarative factories.
Related guides:
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
Every test should follow the Arrange-Act-Assert pattern:
1. Arrange the World
Set up all the data and dependencies needed using factories:
it('creates an order with items', function () {
$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],
],
]);
$order = resolve(CreateOrderAction::class)($user, $data);
expect($order)
->toBeInstanceOf(Order::class)
->and($order->items)->toHaveCount(1)
->and($order->total)->toBe(2000);
});
2. Act on the World
Perform the single operation you're testing:
$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
Verify the outcomes of your action:
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 are the heart of your domain logic and should be thoroughly tested in isolation.
Basic Action Test
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 () {
$user = User::factory()->create();
$data = CreateOrderData::testFactory()->make([
'status' => OrderStatus::Pending,
]);
$order = resolve(CreateOrderAction::class)($user, $data);
expect($order)->toBeInstanceOf(Order::class);
assertDatabaseHas('orders', [
'id' => $order->id,
'user_id' => $user->id,
'status' => OrderStatus::Pending->value,
]);
});
Testing Action Guard Methods
it('throws exception when user has too many pending orders', function () {
$user = User::factory()
->has(Order::factory()->pending()->count(5))
->create();
$data = CreateOrderData::testFactory()->make();
expect(fn () => resolve(CreateOrderAction::class)($user, $data))
->toThrow(OrderException::class, 'Too many pending orders');
});
Testing Action Composition
Critical pattern: Always resolve actions from the container using resolve() so dependencies are recursively resolved. Use swap() to replace dependencies with mocked versions.
use function Pest\Laravel\mock;
use function Pest\Laravel\swap;
it('processes order and sends notification', function () {
$user = User::factory()->create();
$order = Order::factory()->for($user)->create();
$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);
$result = resolve(ProcessOrderAction::class)($order);
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
Mocking Guidelines
Only Mock What You Own
Critical principle: Only mock code that you control. Never mock external services directly.
✅ Good - Mock Your Own Actions
use function Pest\Laravel\mock;
use function Pest\Laravel\swap;
$sendEmail = mock(SendWelcomeEmailAction::class);
$sendEmail->shouldReceive('__invoke')
->once()
->with(Mockery::type(User::class));
swap(SendWelcomeEmailAction::class, $sendEmail);
$result = resolve(RegisterUserAction::class)($data);
✅ Advanced - Verify Mock Arguments with Assertions
Use withArgs() with a closure to verify the exact instances and values being passed:
it('processes match with correct arguments', function () {
$matchAttempt = MatchAttempt::factory()->create();
$data = MatchData::testFactory()->make();
$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;
});
swap(CreateMatchResultAction::class, $mockAction);
resolve(ProcessMatchAction::class)($matchAttempt, $data);
});
✅ Good - Mock Your Own Services (via Facade)
Payment::shouldReceive('createPaymentIntent')
->once()
->with(10000, 'usd')
->andReturn(PaymentIntentData::from([
'id' => 'pi_test_123',
'status' => 'succeeded',
]));
❌ Bad - Mocking External Libraries Directly
$stripe = Mockery::mock(\Stripe\StripeClient::class);
$stripe->shouldReceive('paymentIntents->create')
->andReturn();
When You Need to Mock Something You Don't Own
If you find yourself needing to mock an external service, create an abstraction:
- Create a Service Layer with the Manager pattern
- Define a Driver Contract (interface)
- Implement the Real Driver (wraps external API)
- Create a Null Driver for testing
- Add a Facade for convenience
See Services for complete implementation examples.
Using Null Drivers
The null driver pattern provides deterministic, fast tests without external dependencies:
it('processes payment successfully', function () {
Config::set('payment.default', 'null');
$order = Order::factory()->create(['total' => 10000]);
$data = PaymentData::from(['amount' => 10000, 'currency' => 'usd']);
$payment = resolve(ProcessPaymentAction::class)($order, $data);
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
Testing Error Scenarios
Extend the null driver for specific test scenarios:
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()->make();
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
$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.
$calendar = Calendar::factory()->create([
'status' => 'accepted',
'reminder_sent_at' => null,
'approved_by' => User::factory()->create()->id,
'approved_at' => now(),
]);
$calendar = Calendar::factory()->accepted()->create();
→ Complete declarative factory patterns: testing-factories.md
DTO Test Factories
DTOs use the HasTestFactory trait with DataTestFactory base class — mirroring Laravel's model factory API for Data objects.
$data = CreateOrderData::testFactory()->make();
$data = CreateOrderData::testFactory()->make([
'status' => OrderStatus::Pending,
]);
$items = OrderItemData::testFactory()->collect(count: 3);
See DTOs for test factory setup and implementation.
Testing Strategy
Feature Tests (HTTP Layer)
Test the complete request/response cycle:
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)
Test domain logic in isolation:
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
How to Avoid Brittleness
1. Use Real Instances When Possible
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);
});
it('calculates order total', function () {
$item1 = Mockery::mock(OrderItem::class);
$item1->shouldReceive('getPrice')->andReturn(1000);
});
2. Test Behavior, Not Implementation
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);
});
});
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
$user = User::factory()->create();
$data = ProfileData::testFactory()->make();
$data = new ProfileData(
firstName: 'John',
lastName: 'Doe',
phone: '555-1234',
bio: 'Test bio',
);
4. Minimize Mocking
Rule of thumb: Mock collaborators, not data.
$notifier = mock(NotificationService::class);
$notifier->shouldReceive('send')->once();
swap(NotificationService::class, $notifier);
resolve(ShipOrderAction::class)($order);
$order = Mockery::mock(Order::class);
Common Testing Patterns
Testing State Transitions
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
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
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);
});
Quick Reference
Test Structure
it('does something', function () {
$model = Model::factory()->active()->create();
$data = Data::testFactory()->make();
$result = resolve(Action::class)($model, $data);
expect($result)->;
});
Mocking Pattern
use function Pest\Laravel\mock;
use function Pest\Laravel\swap;
$mockAction = mock(YourDependencyAction::class);
$mockAction->shouldReceive('__invoke')
->once()
->with()
->andReturn();
swap(YourDependencyAction::class, $mockAction);
$result = resolve(ActionUnderTest::class)();
Database Assertions
use function Pest\Laravel\assertDatabaseHas;
use function Pest\Laravel\assertDatabaseCount;
assertDatabaseHas('orders', ['id' => $order->id]);
assertDatabaseCount('orders', 1);