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

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

4 min read Mohamed Said Mohamed Said

The Problem With Testing Laravel Actions

Most Laravel test suites drift toward one of two failure modes: they either test too much (full HTTP round-trips for every edge case) or too little (mocking so aggressively that the test proves nothing). Actions — single-responsibility classes that encapsulate one piece of business logic — sit in the sweet spot, but only if you test them correctly.

This article shows a concrete, opinionated approach: test actions in isolation with real collaborators where cheap, use targeted fakes where expensive, and enforce boundaries with Pest's arch() API.


A Concrete Action Worth Testing

// app/Actions/RegisterUser.php
final class RegisterUser
{
    public function __construct(
        private readonly UserRepository $users,
        private readonly Dispatcher $events,
        private readonly Hasher $hasher,
    ) {}

    public function handle(RegisterUserData $data): User
    {
        if ($this->users->existsByEmail($data->email)) {
            throw new EmailAlreadyTaken($data->email);
        }

        $user = $this->users->create([
            'name'     => $data->name,
            'email'    => $data->email,
            'password' => $this->hasher->make($data->password),
        ]);

        $this->events->dispatch(new UserRegistered($user));

        return $user;
    }
}

Three collaborators: a repository, the event dispatcher, and the hasher. Each has a different testing cost.


Testing Strategy: Real vs Fake

| Collaborator | Strategy | Reason | |---|---|---| | Hasher | Real (bcrypt) | Pure, fast, no I/O | | UserRepository | In-memory fake | Avoids DB migrations in unit tests | | Dispatcher | Laravel's Event::fake() | Lets us assert events dispatched |

Writing the In-Memory Fake

// tests/Fakes/InMemoryUserRepository.php
final class InMemoryUserRepository implements UserRepository
{
    /** @var array<int, User> */
    private array $store = [];
    private int $nextId = 1;

    public function existsByEmail(string $email): bool
    {
        return collect($this->store)
            ->contains(fn(User $u) => $u->email === $email);
    }

    public function create(array $attributes): User
    {
        $user = new User(array_merge($attributes, ['id' => $this->nextId++]));
        $this->store[] = $user;
        return $user;
    }
}

No database, no migrations, no RefreshDatabase. The test suite stays fast.


Pest Specs for the Action

// tests/Unit/Actions/RegisterUserTest.php
use App\Actions\RegisterUser;
use App\Events\UserRegistered;
use App\Data\RegisterUserData;
use App\Exceptions\EmailAlreadyTaken;
use Tests\Fakes\InMemoryUserRepository;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Hash;

beforeEach(function () {
    Event::fake();
    $this->repo   = new InMemoryUserRepository();
    $this->action = new RegisterUser($this->repo, app(Dispatcher::class), Hash::getFacadeRoot());
});

it('creates a user and dispatches UserRegistered', function () {
    $data = new RegisterUserData('Alice', 'alice@example.com', 'secret123');

    $user = $this->action->handle($data);

    expect($user->email)->toBe('alice@example.com');
    expect(Hash::check('secret123', $user->password))->toBeTrue();
    Event::assertDispatched(UserRegistered::class,
        fn($e) => $e->user->email === 'alice@example.com'
    );
});

it('throws EmailAlreadyTaken when email exists', function () {
    $data = new RegisterUserData('Alice', 'alice@example.com', 'secret123');
    $this->action->handle($data); // first registration

    expect(fn() => $this->action->handle($data))
        ->toThrow(EmailAlreadyTaken::class);
});

No HTTP, no database, no $this->artisan. Each test runs in microseconds.


Enforcing Architectural Rules with arch()

Pest's arch() API lets you encode boundaries as failing tests — not just documentation.

// tests/Arch/DomainTest.php
arch('actions are final and have no public properties')
    ->expect('App\Actions')
    ->toBeFinal()
    ->and('App\Actions')
    ->not->toHavePublicProperties();

arch('domain layer does not depend on Illuminate HTTP')
    ->expect('App\Domain')
    ->not->toUse('Illuminate\Http');

arch('value objects are readonly')
    ->expect('App\Values')
    ->toBeReadonly();

arch('actions only depend on contracts, not Eloquent models directly')
    ->expect('App\Actions')
    ->not->toUse('Illuminate\Database\Eloquent\Model');

These rules run on every CI push. A junior developer who reaches for User::find() inside an action gets a red build, not a code review comment three days later.


Takeaways

  • Fake at the boundary, not everywhere. Use real implementations for pure collaborators; reserve fakes for I/O.
  • In-memory fakes are faster and more honest than Mockery mocks — they exercise the contract, not just the call signature.
  • arch() rules are executable architecture docs. Write them early; they pay dividends as the team grows.
  • Actions should be final. It prevents accidental inheritance and signals single-responsibility intent.
  • Avoid RefreshDatabase in unit tests. Reserve it for integration tests that genuinely need persistence.

Found this useful?

Frequently Asked Questions

3 questions
Q01 When should I use Event::fake() versus a real event dispatcher in action tests?
Use Event::fake() whenever the test's purpose is to verify that an event was dispatched with the correct payload. If you need to test a listener's side effects, write a separate integration test that lets the event propagate through a real dispatcher.
Q02 Do Pest arch() rules slow down the test suite significantly?
No. Pest's architectural assertions perform static analysis on the class map rather than executing code, so they add only a few seconds to a full suite run and can be grouped into a dedicated arch test file that runs separately from unit tests if needed.
Q03 Should every action be tested in isolation, or are feature tests sufficient?
Both have a role. Isolated unit tests on actions give fast, precise feedback on business logic. Feature tests verify that the HTTP layer, middleware, and action wire together correctly. Relying solely on feature tests makes failures harder to diagnose and the suite slower.

Continue reading

More Articles

View all