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
RefreshDatabasein unit tests. Reserve it for integration tests that genuinely need persistence.