Testing Laravel Clean Architecture with Pest | Mohamed Said       [Skip to content](#main)  [ ![](https://cdn.msaied.com/01KT78WE565VEMM3PSNQAAB0MH.png) Mohamed SaidLaravel Backend Engineer ](https://www.msaied.com/public) - [Home](https://www.msaied.com/public)
- [Projects](https://www.msaied.com/public/projects)
- [Articles](https://www.msaied.com/public/articles)
- [Certificates](https://www.msaied.com/public/certificates)
- [About](https://www.msaied.com/public#about)

           [  Contact](https://www.msaied.com/public#contact) Menu 

Menu
----

Close 

 - [HomeStart here](https://www.msaied.com/public)
- [ProjectsCase studies](https://www.msaied.com/public/projects)
- [ArticlesEngineering notes](https://www.msaied.com/public/articles)
- [CertificatesCredentials](https://www.msaied.com/public/certificates)
- [AboutHow I work](https://www.msaied.com/public#about)
- [ContactGet in touch](https://www.msaied.com/public#contact)

  [Start a conversation](https://www.msaied.com/public#contact) [WhatsApp](https://wa.me/201094619204) [Email](mailto:hello@msaied.com) 

 1. [Home](https://www.msaied.com/public)
2. /
3. [Articles](https://www.msaied.com/public/articles)
4. /
5. Clean Architecture Testing with Pest: Actions, Fakes, and Architectural Assertions

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

 Learn how to test Laravel actions, DTOs, and domain services with Pest — using fakes, higher-order tests, and arch() assertions to enforce clean boundaries without ceremony.

 ![](https://cdn.msaied.com/01M22N44A70A5MC2S599JP0MPH.webp) [Mohamed Said](https://www.msaied.com/public#person) Published 27 Sep 2026 · Updated 27 Sep 2026 · 3 min read

ShareCopy linkCopied

 ![Clean Architecture Testing with Pest: Actions, Fakes, and Architectural Assertions](https://cdn.msaied.com/709/ee356004b06e38b322823a2cf5305cee.png) 

  On this page +1. [The Problem with Testing "Clean" Code](#the-problem-with-testing-quotcleanquot-code)
2. [Testing Actions in Isolation](#testing-actions-in-isolation)
3. [Faking Infrastructure at the Service Container Level](#faking-infrastructure-at-the-service-container-level)
4. [Enforcing Boundaries with arch()](#enforcing-boundaries-with-codearchcode)
5. [Higher-Order Tests for Repetitive Assertions](#higher-order-tests-for-repetitive-assertions)
6. [Takeaways](#takeaways)

 The Problem with Testing "Clean" Code
-------------------------------------

Clean architecture in Laravel — actions, DTOs, value objects, domain services — is only as clean as the tests that guard it. Without deliberate test strategy, you end up with integration tests masquerading as unit tests, and domain logic that silently couples to Eloquent, queues, or HTTP.

Pest gives you the tools to fix this. Here's how to use them properly.

---

Testing Actions in Isolation
----------------------------

An action is a single-responsibility class. Test it like one — no HTTP, no database unless the action's explicit contract requires it.

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

    public function handle(RegisterUserData $data): User
    {
        return $this->users->create([
            'email' => $data->email->value,
            'password' => $this->hasher->make($data->password),
        ]);
    }
}

```

```php
// tests/Unit/Actions/RegisterUserTest.php
use App\Actions\RegisterUser;
use App\Data\RegisterUserData;
use App\Repositories\UserRepository;
use App\ValueObjects\Email;

it('creates a user with a hashed password', function () {
    $repo = Mockery::mock(UserRepository::class);
    $hasher = Mockery::mock(Hasher::class);

    $hasher->expects('make')
        ->with('secret')
        ->andReturn('hashed_secret');

    $repo->expects('create')
        ->with(['email' => 'jane@example.com', 'password' => 'hashed_secret'])
        ->andReturn(new User(['email' => 'jane@example.com']));

    $action = new RegisterUser($repo, $hasher);
    $user = $action->handle(new RegisterUserData(
        email: new Email('jane@example.com'),
        password: 'secret',
    ));

    expect($user->email)->toBe('jane@example.com');
});

```

No `RefreshDatabase`. No HTTP request. Pure unit test.

---

Faking Infrastructure at the Service Container Level
----------------------------------------------------

For feature tests, swap real infrastructure with fakes via `app()->bind()`:

```php
beforeEach(function () {
    $this->app->bind(UserRepository::class, FakeUserRepository::class);
});

it('dispatches a welcome email after registration', function () {
    Mail::fake();

    $this->post('/register', [
        'email' => 'jane@example.com',
        'password' => 'password',
        'password_confirmation' => 'password',
    ])->assertRedirect('/dashboard');

    Mail::assertSent(WelcomeMail::class, fn ($mail) =>
        $mail->hasTo('jane@example.com')
    );
});

```

The `FakeUserRepository` implements the same interface and stores users in memory. Your action never knows the difference.

---

Enforcing Boundaries with `arch()`
----------------------------------

Pest's `arch()` helper is the most underused feature in the ecosystem. It lets you write executable architecture rules:

```php
// tests/Arch/DomainTest.php

arch('domain classes do not depend on Illuminate')
    ->expect('App\Domain')
    ->not->toUse('Illuminate\\');

arch('actions are final')
    ->expect('App\Actions')
    ->toBeFinal();

arch('DTOs are readonly')
    ->expect('App\Data')
    ->toBeReadonly();

arch('value objects have no public setters')
    ->expect('App\ValueObjects')
    ->not->toHavePublicMethodsBesides(['value', '__toString', 'equals', 'from']);

```

These run in milliseconds and fail CI the moment someone accidentally injects `Request` into a domain service.

---

Higher-Order Tests for Repetitive Assertions
--------------------------------------------

When you have many value objects with the same contract, higher-order tests eliminate boilerplate:

```php
$validEmails = [
    'simple@example.com',
    'user+tag@sub.domain.org',
];

dataset('valid_emails', $validEmails);

it('accepts valid email addresses', function (string $email) {
    expect(new Email($email))->value->toBe($email);
})->with('valid_emails');

it('rejects malformed email addresses', function (string $email) {
    expect(fn () => new Email($email))->toThrow(InvalidEmailException::class);
})->with([
    'not-an-email',
    '@nodomain',
    'missing@',
]);

```

---

Takeaways
---------

- **Inject interfaces, not concretions** — makes unit testing actions trivial without a database.
- **Use `arch()` assertions** to enforce that domain code stays framework-agnostic; run them in CI.
- **Bind fakes in `beforeEach`** for feature tests rather than hitting real infrastructure.
- **`readonly` DTOs + `final` actions** are enforceable with Pest arch rules, not just convention.
- **Datasets** keep value object tests exhaustive without copy-paste test methods.
- Keep unit tests in `tests/Unit` and feature tests in `tests/Feature`; never let a unit test touch the database accidentally.

- [laravel](https://www.msaied.com/public/articles?search=laravel)
- [pest](https://www.msaied.com/public/articles?search=pest)
- [testing](https://www.msaied.com/public/articles?search=testing)
- [clean-architecture](https://www.msaied.com/public/articles?search=clean-architecture)

 Frequently asked questions 
---------------------------

  Does Pest's arch() work with namespaces that use PSR-4 subdirectories?Yes. Pest resolves classes via Composer's autoloader, so any PSR-4 namespace like `App\\Domain` maps correctly to `app/Domain`. Just ensure your composer.json autoload section covers the namespace.

   Should I use Mockery or Pest's built-in mock() helper for action tests?Pest's `mock()` helper wraps Mockery under the hood and integrates with expectations cleanly. For simple interface fakes, a hand-written fake class is often more readable and avoids mock assertion order surprises.

   How do I prevent arch() tests from slowing down the suite?Arch tests are static analysis over loaded class files — they don't boot the application or hit the database. Group them in a dedicated `tests/Arch` directory and run them as a separate Pest group in CI if needed, though in practice they're fast enough to run with every suite.

   ![Mohamed Said](https://cdn.msaied.com/01M22N44A70A5MC2S599JP0MPH.webp)About the author
----------------

[Mohamed Said](https://www.msaied.com/public#person)Senior Backend Engineer specializing in Laravel, scalable SaaS platforms, APIs, and cloud infrastructure. I build secure, high-performance web applications that help businesses grow.

[About](https://www.msaied.com/public#about) [GitHub ↗](https://github.com/EG-Mohamed) [LinkedIn ↗](https://www.linkedin.com/in/msaiedm/) [WhatsApp ↗](https://wa.me/201094619204) [Email Address ↗](mailto:hello@msaied.com) [My CV ↗](https://drive.google.com/file/u/0/d/1MF20IPRJyzfy32mhEutjL5EpSls0w2Q8/view)  

   [Previous articleMulti-Tenant SaaS with Laravel: Scoping Queries, Resolving Tenants, and Isolating State](https://www.msaied.com/public/articles/multi-tenant-saas-with-laravel-scoping-queries-resolving-tenants-and-isolating-state) [Next articleLaravel API Resources: Sparse Fieldsets, Conditional Relationships, and Cursor Pagination](https://www.msaied.com/public/articles/laravel-api-resources-sparse-fieldsets-conditional-relationships-and-cursor-pagination)  

   On this page
-------------

1. [The Problem with Testing "Clean" Code](#the-problem-with-testing-quotcleanquot-code)
2. [Testing Actions in Isolation](#testing-actions-in-isolation)
3. [Faking Infrastructure at the Service Container Level](#faking-infrastructure-at-the-service-container-level)
4. [Enforcing Boundaries with arch()](#enforcing-boundaries-with-codearchcode)
5. [Higher-Order Tests for Repetitive Assertions](#higher-order-tests-for-repetitive-assertions)
6. [Takeaways](#takeaways)

 ###  Have a technical challenge?

 Tell me what you’re building. I reply within two working days.

[Start a conversation](https://www.msaied.com/public#contact) 

   Related articles
-----------------

 [ ![](https://cdn.msaied.com/740/cce86edc21eddcbdd2f2454fadaf9c70.png)  · 3 min read### The Pipeline Pattern in Laravel: Custom Pipelines Beyond Middleware

5 Oct 2026 ](https://www.msaied.com/public/articles/the-pipeline-pattern-in-laravel-custom-pipelines-beyond-middleware-1) [ ![](https://cdn.msaied.com/739/2d6897fdcdcf090613f96f72a64b8a78.png)  · 4 min read### MySQL Full-Text Search in Laravel: Indexes, Relevance Scoring, and Boolean Mode

4 Oct 2026 ](https://www.msaied.com/public/articles/mysql-full-text-search-in-laravel-indexes-relevance-scoring-and-boolean-mode) [ ![](https://cdn.msaied.com/738/073696a3fefe18bec825beec5ac658f5.png)  · 4 min read### Laravel Queue Rate-Limited Middleware: Throttling Jobs Without Losing Work

4 Oct 2026 ](https://www.msaied.com/public/articles/laravel-queue-rate-limited-middleware-throttling-jobs-without-losing-work) 

  Have a technical challenge?
----------------------------

Tell me what you’re building. I reply within two working days.

 [Discuss your project ↗](https://www.msaied.com/public#contact) 

  © 2026 Mohamed Said · Built with Laravel, meant to last.Senior Backend Engineer specializing in Laravel, scalable SaaS platforms, APIs, and cloud infrastructure. I build secure, high-performance web applications that help businesses grow.

 - [Home](https://www.msaied.com/public)
- [Articles](https://www.msaied.com/public/articles)
- [Certificates](https://www.msaied.com/public/certificates)
- [GitHub](https://github.com/EG-Mohamed)
- [LinkedIn](https://www.linkedin.com/in/msaiedm/)
- [WhatsApp](https://wa.me/201094619204)
- [Email Address](mailto:hello@msaied.com)
- [My CV](https://drive.google.com/file/u/0/d/1MF20IPRJyzfy32mhEutjL5EpSls0w2Q8/view)
- [Sitemap](https://www.msaied.com/public/sitemap.xml)
