Why Clean Architecture and Pest Are a Natural Pair
Clean architecture splits your Laravel app into layers — domain actions, DTOs, repositories — but without test discipline those layers collapse back into fat controllers within months. Pest's fluent API, arch() plugin, and first-class fake support give you three distinct levers to keep boundaries honest: unit-level action tests, fake collaborators that replace the service container, and static architectural assertions that run in CI.
Testing Actions in Isolation
An action is a single-responsibility class. Test it without booting the framework.
// app/Actions/RegisterUser.php
final class RegisterUser
{
public function __construct(
private readonly UserRepository $users,
private readonly Hasher $hasher,
) {}
public function handle(RegisterUserData $data): User
{
$hashed = $this->hasher->make($data->password);
return $this->users->create($data->email, $hashed);
}
}
// tests/Unit/Actions/RegisterUserTest.php
use App\Actions\RegisterUser;
use App\Data\RegisterUserData;
it('hashes the password before persisting', function () {
$hasher = Mockery::mock(Hasher::class);
$hasher->expects('make')->with('secret')->andReturn('hashed');
$repo = Mockery::mock(UserRepository::class);
$repo->expects('create')
->with('alice@example.com', 'hashed')
->andReturn(new User);
$action = new RegisterUser($repo, $hasher);
$action->handle(new RegisterUserData('alice@example.com', 'secret'));
});
No RefreshDatabase, no HTTP overhead. These tests run in milliseconds.
Fake Collaborators via the Service Container
For integration-level tests you still want real Laravel wiring but fake I/O. Bind a fake inside a beforeEach rather than scattering Mail::fake() calls everywhere.
// tests/Feature/RegisterUserFeatureTest.php
use App\Contracts\UserRepository;
use App\Fakes\InMemoryUserRepository;
beforeEach(function () {
$this->app->bind(UserRepository::class, InMemoryUserRepository::class);
});
it('stores a new user through the HTTP layer', function () {
$response = $this->postJson('/register', [
'email' => 'bob@example.com',
'password' => 'password',
]);
$response->assertCreated();
$repo = $this->app->make(UserRepository::class);
expect($repo->all())->toHaveCount(1)
->and($repo->all()->first()->email)->toBe('bob@example.com');
});
InMemoryUserRepository implements the same contract as the Eloquent version — no database, no migrations, deterministic.
Enforcing Boundaries with arch()
Pest's arch() helper turns architectural rules into executable tests. Add these to a dedicated tests/ArchTest.php.
arch('domain layer has no framework dependencies')
->expect('App\Domain')
->toUseNothing()
->ignoring('Illuminate\Contracts'); // interfaces are fine
arch('actions are final')
->expect('App\Actions')
->toBeFinal();
arch('DTOs are readonly')
->expect('App\Data')
->toBeReadonly();
arch('repositories live behind contracts')
->expect('App\Repositories')
->toImplement('App\Contracts\Repository');
These assertions run statically — no database, no HTTP. They fail the moment a junior dev imports Illuminate\Http\Request inside a domain class.
Combining Expectations for Richer Assertions
Pest's expectation API chains cleanly with custom higher-order helpers.
expect(RegisterUserData::class)
->toBeReadonly()
->and(RegisterUserData::class)
->toHaveConstructor()
->and(new RegisterUserData('a@b.com', 'pw'))
->email->toBe('a@b.com');
This single chain verifies the DTO is immutable, constructable, and maps properties correctly — three concerns, one readable block.
Takeaways
- Test actions directly with constructor injection; skip the framework bootstrap for pure domain logic.
- Bind in-memory fakes in
beforeEachrather than using static facade fakes — your tests stay container-aware without coupling to concrete implementations. arch()assertions are free CI guards — they catch boundary violations before code review.- Readonly DTOs + final actions are enforceable conventions, not just style guidelines, when you back them with Pest.
- Keep a single
ArchTest.phpas the living documentation of your architectural rules.