Testing Laravel Actions &amp; Boundaries with Pest | Mohamed Said        [  ![Mohamed Said](https://cdn.msaied.com/01KT78WE565VEMM3PSNQAAB0MH.png)   Mohamed Said Laravel Backend Engineer  ](https://www.msaied.com) [ Home ](https://www.msaied.com) [ Projects ](https://www.msaied.com/projects) [ Articles  ](https://www.msaied.com/articles) [ Certificates ](https://www.msaied.com/certificates) [ Contact ](https://www.msaied.com#contact-section) 

       [  ](https://github.com/EG-Mohamed)       

 [ Home ](https://www.msaied.com) [ Projects ](https://www.msaied.com/projects) [ Articles ](https://www.msaied.com/articles) [ Certificates ](https://www.msaied.com/certificates) [ Contact ](https://www.msaied.com#contact-section) 

  [ home ](https://www.msaied.com)    [ articles ](https://www.msaied.com/articles)    Clean Architecture Testing with Pest: Actions, Fakes, and Boundary Contracts        On this page       1. [  The Problem with Testing Framework-Coupled Code ](#the-problem-with-testing-framework-coupled-code)
2. [  Actions as the Unit of Work ](#actions-as-the-unit-of-work)
3. [  Testing Actions with Fakes ](#testing-actions-with-fakes)
4. [  Enforcing Boundaries with arch() ](#enforcing-boundaries-with-codearchcode)
5. [  Contract Assertions on Value Objects ](#contract-assertions-on-value-objects)
6. [  Takeaways ](#takeaways)

  ![Clean Architecture Testing with Pest: Actions, Fakes, and Boundary Contracts](https://cdn.msaied.com/257/eb96b08443e07a2edd8694c0f6f8b524.png)

  #laravel   #pest   #clean-architecture   #testing   #ddd  

 Clean Architecture Testing with Pest: Actions, Fakes, and Boundary Contracts 
==============================================================================

     21 Jun 2026      3 min read    ![Mohamed Said](https://cdn.msaied.com/01KT78WE565VEMM3PSNQAAB0MJ.jpg)  Mohamed Said  

       Table of contents

1. [  01   The Problem with Testing Framework-Coupled Code  ](#the-problem-with-testing-framework-coupled-code)
2. [  02   Actions as the Unit of Work  ](#actions-as-the-unit-of-work)
3. [  03   Testing Actions with Fakes  ](#testing-actions-with-fakes)
4. [  04   Enforcing Boundaries with arch()  ](#enforcing-boundaries-with-codearchcode)
5. [  05   Contract Assertions on Value Objects  ](#contract-assertions-on-value-objects)
6. [  06   Takeaways  ](#takeaways)

 The Problem with Testing Framework-Coupled Code
-----------------------------------------------

Most Laravel test suites test *routes* and *controllers* end-to-end. That works until your domain logic grows complex enough that a single HTTP test exercises five unrelated concerns. The fix is not more mocking — it is pushing logic into plain PHP actions and testing those in isolation, then asserting that boundaries are respected at the architecture level.

This article shows exactly how to do that with Pest.

---

Actions as the Unit of Work
---------------------------

An action is a single-responsibility class with an `execute` method. It receives a DTO, does one thing, and returns a result. No controller, no request object, no `Auth::user()` calls buried inside.

```php
// app/Domain/Billing/Actions/ChargeSubscription.php
final class ChargeSubscription
{
    public function __construct(
        private readonly PaymentGateway $gateway,
        private readonly SubscriptionRepository $subscriptions,
    ) {}

    public function execute(ChargeSubscriptionData $data): ChargeResult
    {
        $subscription = $this->subscriptions->findOrFail($data->subscriptionId);

        $charge = $this->gateway->charge(
            amount: $data->amount,
            customerId: $subscription->gatewayCustomerId,
        );

        return new ChargeResult(
            chargeId: $charge->id,
            status: $charge->status,
        );
    }
}

```

No `Stripe::charge()` static call. No `request()` helper. Pure dependencies through the constructor — which means Pest can swap them trivially.

---

Testing Actions with Fakes
--------------------------

Create a fake that implements the same interface your action depends on:

```php
// tests/Fakes/FakePaymentGateway.php
final class FakePaymentGateway implements PaymentGateway
{
    public array $charged = [];

    public function charge(Money $amount, string $customerId): ChargeResponse
    {
        $this->charged[] = compact('amount', 'customerId');

        return new ChargeResponse(id: 'ch_fake_123', status: 'succeeded');
    }
}

```

Now the Pest test is fast, deterministic, and readable:

```php
// tests/Unit/Domain/Billing/ChargeSubscriptionTest.php
use App\Domain\Billing\Actions\ChargeSubscription;
use App\Domain\Billing\Data\ChargeSubscriptionData;

beforeEach(function () {
    $this->gateway = new FakePaymentGateway();
    $this->subscriptions = new InMemorySubscriptionRepository();
    $this->action = new ChargeSubscription($this->gateway, $this->subscriptions);
});

it('charges the correct amount to the gateway customer', function () {
    $subscription = $this->subscriptions->create(gatewayCustomerId: 'cus_abc');

    $result = $this->action->execute(new ChargeSubscriptionData(
        subscriptionId: $subscription->id,
        amount: Money::of(4900, 'USD'),
    ));

    expect($result->status)->toBe('succeeded')
        ->and($this->gateway->charged)->toHaveCount(1)
        ->and($this->gateway->charged[0]['customerId'])->toBe('cus_abc');
});

```

No database. No HTTP. Runs in milliseconds.

---

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

Pest's `arch()` helper lets you encode architectural rules as executable tests. This is where clean architecture gets teeth.

```php
// tests/Architecture/DomainTest.php

arch('domain layer has no framework dependencies')
    ->expect('App\Domain')
    ->not->toUse([
        'Illuminate\Http\Request',
        'Illuminate\Support\Facades',
        'Illuminate\Database\Eloquent\Model',
    ]);

arch('actions are final and not extended')
    ->expect('App\Domain\**\Actions')
    ->toBeFinal();

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

```

These run in CI and fail the moment a developer accidentally imports `Auth::user()` inside a domain action. No code review required.

---

Contract Assertions on Value Objects
------------------------------------

Value objects should be immutable and comparable. Test those properties explicitly:

```php
it('is equal to another instance with the same currency and amount', function () {
    $a = Money::of(1000, 'USD');
    $b = Money::of(1000, 'USD');

    expect($a->equals($b))->toBeTrue();
});

it('throws on negative amounts', function () {
    expect(fn () => Money::of(-1, 'USD'))
        ->toThrow(\InvalidArgumentException::class);
});

```

No Laravel bootstrap needed. These are pure PHP tests.

---

Takeaways
---------

- **Actions are the right unit** — they are small enough to test in isolation and large enough to represent real domain work.
- **Fakes beat mocks** for domain boundaries; they are explicit, reusable, and readable.
- **`arch()` rules are living documentation** — they fail loudly when boundaries erode.
- **Readonly DTOs and final actions** are not style preferences; they are constraints that make tests predictable.
- **Keep Eloquent at the edge** — repositories translate between domain objects and persistence, so domain tests never touch the database.

 Found this useful?

          [  ](https://twitter.com/intent/tweet?url=https%3A%2F%2Fwww.msaied.com%2Farticles%2Fclean-architecture-testing-with-pest-actions-fakes-and-boundary-contracts&text=Clean+Architecture+Testing+with+Pest%3A+Actions%2C+Fakes%2C+and+Boundary+Contracts) [  ](https://www.linkedin.com/sharing/share-offsite/?url=https%3A%2F%2Fwww.msaied.com%2Farticles%2Fclean-architecture-testing-with-pest-actions-fakes-and-boundary-contracts) 

 Frequently Asked Questions 
----------------------------

  3 questions  

     Q01  Should I use Mockery or fakes for testing actions?        Prefer hand-written fakes for domain boundaries. Fakes are explicit about what they record and return, making test intent clearer. Mockery is fine for incidental collaborators where writing a full fake would be disproportionate effort. 

      Q02  Does arch() slow down the test suite significantly?        Pest's arch() performs static analysis on your source files rather than executing code, so it adds only a few seconds to a typical suite. Run it in a dedicated CI step if you want to keep your unit test feedback loop fast. 

      Q03  How do I handle Laravel's service container when testing actions in isolation?        You don't need the container for unit tests. Instantiate the action directly with its fakes in beforeEach(). Reserve the container for integration or feature tests where you want to verify the full wiring. 

  Continue reading

 More Articles 
---------------

 [ View all    ](https://www.msaied.com/articles) 

 [ ![Read/Write Splitting, Connection Pooling, and Sticky Reads in Laravel](https://cdn.msaied.com/295/d977bd189583149245c03d6d763d9db5.png) laravel database performance 

### Read/Write Splitting, Connection Pooling, and Sticky Reads in Laravel

Learn how Laravel's database layer handles read/write splitting, when sticky reads matter, and how to layer Pg...

  ![Mohamed Said](https://cdn.msaied.com/01KT78WE565VEMM3PSNQAAB0MJ.jpg)  Mohamed Said 

 26 Jun 2026     4 min read  

  Read    

 ](https://www.msaied.com/articles/readwrite-splitting-connection-pooling-and-sticky-reads-in-laravel-2) [ ![Laravel Service Container: Contextual Binding, Tagging, and Method Injection](https://cdn.msaied.com/294/e5b9d047bd33c3f8b80069ef6a178884.png) laravel service-container dependency-injection 

### Laravel Service Container: Contextual Binding, Tagging, and Method Injection

Go beyond basic binding. Learn how contextual binding resolves different implementations per consumer, how tag...

  ![Mohamed Said](https://cdn.msaied.com/01KT78WE565VEMM3PSNQAAB0MJ.jpg)  Mohamed Said 

 26 Jun 2026     3 min read  

  Read    

 ](https://www.msaied.com/articles/laravel-service-container-contextual-binding-tagging-and-method-injection-1) [ ![PostgreSQL JSONB in Laravel: Indexing, Querying, and Casting Without the Pain](https://cdn.msaied.com/293/f392a5ea52536901eac9677ffa2d070d.png) laravel postgresql eloquent 

### PostgreSQL JSONB in Laravel: Indexing, Querying, and Casting Without the Pain

JSONB columns unlock flexible schemas, but most Laravel apps leave performance on the table. Learn how to inde...

  ![Mohamed Said](https://cdn.msaied.com/01KT78WE565VEMM3PSNQAAB0MJ.jpg)  Mohamed Said 

 25 Jun 2026     4 min read  

  Read    

 ](https://www.msaied.com/articles/postgresql-jsonb-in-laravel-indexing-querying-and-casting-without-the-pain) 

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

Explore

- [Home](https://www.msaied.com)
- [Projects](https://www.msaied.com/projects)
- [Articles](https://www.msaied.com/articles)
- [Certificates](https://www.msaied.com/certificates)
- [Contact](https://www.msaied.com#contact-section)

Connect

- [   hello@msaied.com ](mailto:hello@msaied.com)
- [   +20 109 461 9204 ](tel:+201094619204)

© 2026 Mohamed Said. All rights reserved.

 [  ](https://github.com/EG-Mohamed) [  ](https://www.linkedin.com/in/msaiedm/) [  ](https://wa.me/201094619204) [  ](mailto:hello@msaied.com) [  ](https://drive.google.com/file/u/0/d/1MF20IPRJyzfy32mhEutjL5EpSls0w2Q8/view)
