Clean Architecture Testing with Pest: Actions, Fakes, and Architectural Assertions
#laravel #pest #testing #clean-architecture

Clean Architecture Testing with Pest: Actions, Fakes, and Architectural Assertions

3 min read Mohamed Said Mohamed Said

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 beforeEach rather 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.php as the living documentation of your architectural rules.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Does arch() perform runtime analysis or static analysis?
Pest's arch() uses static reflection — it inspects class metadata without executing code. This makes it fast and safe to run in CI without a database or running application.
Q02 When should I use an InMemoryRepository fake versus RefreshDatabase?
Use an in-memory fake when testing application logic that flows through a repository contract. Use RefreshDatabase when you specifically need to verify SQL queries, migrations, or Eloquent behaviour itself.
Q03 Can arch() assertions check for prohibited imports across namespaces?
Yes. Use ->toUseNothing() or ->toUseStrictly(['AllowedNamespace']) to whitelist exactly which dependencies a namespace may import, catching accidental cross-layer coupling automatically.

Continue reading

More Articles

View all