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.