Clean Architecture Testing with Pest: Actions, Fakes, and Boundary Contracts
#laravel #pest #clean-architecture #testing #ddd

Clean Architecture Testing with Pest: Actions, Fakes, and Boundary Contracts

3 min read Mohamed Said Mohamed Said

The Problem with Testing Framework-Coupled Code

Most Laravel test suites test routes and controllers end-to-end. That works until your domain logic grows complex enough that a single HTTP test exercises five unrelated concerns. The fix is not more mocking — it is pushing logic into plain PHP actions and testing those in isolation, then asserting that boundaries are respected at the architecture level.

This article shows exactly how to do that with Pest.


Actions as the Unit of Work

An action is a single-responsibility class with an execute method. It receives a DTO, does one thing, and returns a result. No controller, no request object, no Auth::user() calls buried inside.

// app/Domain/Billing/Actions/ChargeSubscription.php
final class ChargeSubscription
{
    public function __construct(
        private readonly PaymentGateway $gateway,
        private readonly SubscriptionRepository $subscriptions,
    ) {}

    public function execute(ChargeSubscriptionData $data): ChargeResult
    {
        $subscription = $this->subscriptions->findOrFail($data->subscriptionId);

        $charge = $this->gateway->charge(
            amount: $data->amount,
            customerId: $subscription->gatewayCustomerId,
        );

        return new ChargeResult(
            chargeId: $charge->id,
            status: $charge->status,
        );
    }
}

No Stripe::charge() static call. No request() helper. Pure dependencies through the constructor — which means Pest can swap them trivially.


Testing Actions with Fakes

Create a fake that implements the same interface your action depends on:

// tests/Fakes/FakePaymentGateway.php
final class FakePaymentGateway implements PaymentGateway
{
    public array $charged = [];

    public function charge(Money $amount, string $customerId): ChargeResponse
    {
        $this->charged[] = compact('amount', 'customerId');

        return new ChargeResponse(id: 'ch_fake_123', status: 'succeeded');
    }
}

Now the Pest test is fast, deterministic, and readable:

// tests/Unit/Domain/Billing/ChargeSubscriptionTest.php
use App\Domain\Billing\Actions\ChargeSubscription;
use App\Domain\Billing\Data\ChargeSubscriptionData;

beforeEach(function () {
    $this->gateway = new FakePaymentGateway();
    $this->subscriptions = new InMemorySubscriptionRepository();
    $this->action = new ChargeSubscription($this->gateway, $this->subscriptions);
});

it('charges the correct amount to the gateway customer', function () {
    $subscription = $this->subscriptions->create(gatewayCustomerId: 'cus_abc');

    $result = $this->action->execute(new ChargeSubscriptionData(
        subscriptionId: $subscription->id,
        amount: Money::of(4900, 'USD'),
    ));

    expect($result->status)->toBe('succeeded')
        ->and($this->gateway->charged)->toHaveCount(1)
        ->and($this->gateway->charged[0]['customerId'])->toBe('cus_abc');
});

No database. No HTTP. Runs in milliseconds.


Enforcing Boundaries with arch()

Pest's arch() helper lets you encode architectural rules as executable tests. This is where clean architecture gets teeth.

// tests/Architecture/DomainTest.php

arch('domain layer has no framework dependencies')
    ->expect('App\Domain')
    ->not->toUse([
        'Illuminate\Http\Request',
        'Illuminate\Support\Facades',
        'Illuminate\Database\Eloquent\Model',
    ]);

arch('actions are final and not extended')
    ->expect('App\Domain\**\Actions')
    ->toBeFinal();

arch('DTOs are readonly')
    ->expect('App\Domain\**\Data')
    ->toBeReadonly();

These run in CI and fail the moment a developer accidentally imports Auth::user() inside a domain action. No code review required.


Contract Assertions on Value Objects

Value objects should be immutable and comparable. Test those properties explicitly:

it('is equal to another instance with the same currency and amount', function () {
    $a = Money::of(1000, 'USD');
    $b = Money::of(1000, 'USD');

    expect($a->equals($b))->toBeTrue();
});

it('throws on negative amounts', function () {
    expect(fn () => Money::of(-1, 'USD'))
        ->toThrow(\InvalidArgumentException::class);
});

No Laravel bootstrap needed. These are pure PHP tests.


Takeaways

  • Actions are the right unit — they are small enough to test in isolation and large enough to represent real domain work.
  • Fakes beat mocks for domain boundaries; they are explicit, reusable, and readable.
  • arch() rules are living documentation — they fail loudly when boundaries erode.
  • Readonly DTOs and final actions are not style preferences; they are constraints that make tests predictable.
  • Keep Eloquent at the edge — repositories translate between domain objects and persistence, so domain tests never touch the database.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Should I use Mockery or fakes for testing actions?
Prefer hand-written fakes for domain boundaries. Fakes are explicit about what they record and return, making test intent clearer. Mockery is fine for incidental collaborators where writing a full fake would be disproportionate effort.
Q02 Does arch() slow down the test suite significantly?
Pest's arch() performs static analysis on your source files rather than executing code, so it adds only a few seconds to a typical suite. Run it in a dedicated CI step if you want to keep your unit test feedback loop fast.
Q03 How do I handle Laravel's service container when testing actions in isolation?
You don't need the container for unit tests. Instantiate the action directly with its fakes in beforeEach(). Reserve the container for integration or feature tests where you want to verify the full wiring.

Continue reading

More Articles

View all