Testing Actions &amp; DTOs with Pest in Clean Architecture | 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 and Pest: Testing Actions, DTOs, and Domain Services Without the Framework        On this page       1. [  Why Your Domain Tests Should Not Boot Laravel ](#why-your-domain-tests-should-not-boot-laravel)
2. [  The Domain Layer Contract ](#the-domain-layer-contract)
3. [  Structuring the Pest Suite ](#structuring-the-pest-suite)
4. [  Testing Value Objects Directly ](#testing-value-objects-directly)
5. [  Where the Framework Re-Enters: Integration Tests ](#where-the-framework-re-enters-integration-tests)
6. [  Takeaways ](#takeaways)

  ![Clean Architecture and Pest: Testing Actions, DTOs, and Domain Services Without the Framework](https://cdn.msaied.com/318/1820c6cd7f0a31efd690d87090a192b9.png)

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

 Clean Architecture and Pest: Testing Actions, DTOs, and Domain Services Without the Framework 
===============================================================================================

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

       Table of contents

1. [  01   Why Your Domain Tests Should Not Boot Laravel  ](#why-your-domain-tests-should-not-boot-laravel)
2. [  02   The Domain Layer Contract  ](#the-domain-layer-contract)
3. [  03   Structuring the Pest Suite  ](#structuring-the-pest-suite)
4. [  04   Testing Value Objects Directly  ](#testing-value-objects-directly)
5. [  05   Where the Framework Re-Enters: Integration Tests  ](#where-the-framework-re-enters-integration-tests)
6. [  06   Takeaways  ](#takeaways)

 Why Your Domain Tests Should Not Boot Laravel
---------------------------------------------

Every time a Pest test calls `uses(RefreshDatabase::class)` for a piece of pure business logic, you're paying a tax you don't owe. Booting the service container, running migrations, and seeding state adds hundreds of milliseconds per suite. For domain actions, DTOs, and value objects — code that has no legitimate dependency on the framework — that cost is entirely avoidable.

This article shows a concrete approach: structure your domain so its core is framework-agnostic, then write Pest tests that verify it in pure PHP.

---

The Domain Layer Contract
-------------------------

A well-bounded action looks like this:

```php
// app/Domain/Billing/Actions/ApplyPromoCode.php
final class ApplyPromoCode
{
    public function __construct(
        private readonly PromoCodeRepository $promos,
        private readonly Clock $clock,
    ) {}

    public function execute(ApplyPromoCodeData $data): OrderTotal
    {
        $promo = $this->promos->findByCode($data->code)
            ?? throw new InvalidPromoCode($data->code);

        if ($promo->isExpiredAt($this->clock->now())) {
            throw new ExpiredPromoCode($data->code);
        }

        return OrderTotal::withDiscount($data->subtotal, $promo->discountPercent());
    }
}

```

Notice: no `Illuminate` imports, no facades, no Eloquent. The two dependencies are interfaces. That's the seam Pest exploits.

---

Structuring the Pest Suite
--------------------------

Keep domain tests in `tests/Unit/Domain/`. No `uses()` trait needed — no database, no HTTP.

```php
// tests/Unit/Domain/Billing/ApplyPromoCodeTest.php
use App\Domain\Billing\Actions\ApplyPromoCode;
use App\Domain\Billing\Data\ApplyPromoCodeData;
use App\Domain\Billing\Exceptions\ExpiredPromoCode;
use App\Domain\Billing\Exceptions\InvalidPromoCode;
use App\Domain\Billing\ValueObjects\Money;

beforeEach(function () {
    $this->promos = Mockery::mock(PromoCodeRepository::class);
    $this->clock  = new FrozenClock('2025-06-01 12:00:00');
    $this->action = new ApplyPromoCode($this->promos, $this->clock);
});

it('applies a valid promo code and returns a discounted total', function () {
    $promo = PromoCodeFactory::make(code: 'SAVE10', discountPercent: 10, expiresAt: '2025-12-31');
    $this->promos->allows('findByCode')->with('SAVE10')->andReturn($promo);

    $data   = new ApplyPromoCodeData(code: 'SAVE10', subtotal: Money::of(100_00));
    $result = $this->action->execute($data);

    expect($result->total())->toBe(Money::of(90_00));
});

it('throws when the promo code does not exist', function () {
    $this->promos->allows('findByCode')->andReturn(null);

    $data = new ApplyPromoCodeData(code: 'GHOST', subtotal: Money::of(50_00));

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

it('throws when the promo code is expired', function () {
    $promo = PromoCodeFactory::make(code: 'OLD20', discountPercent: 20, expiresAt: '2024-01-01');
    $this->promos->allows('findByCode')->andReturn($promo);

    $data = new ApplyPromoCodeData(code: 'OLD20', subtotal: Money::of(80_00));

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

```

These three tests run in under 10 ms combined. No database. No container.

---

Testing Value Objects Directly
------------------------------

Value objects are the easiest wins — pure functions with equality semantics:

```php
// tests/Unit/Domain/Billing/MoneyTest.php
dataset('addition', [
    [100_00, 50_00, 150_00],
    [0,      99_99, 99_99],
]);

it('adds two Money values correctly', function (int $a, int $b, int $expected) {
    expect(Money::of($a)->add(Money::of($b)))->toBe(Money::of($expected));
})->with('addition');

it('prevents mixing currencies', function () {
    expect(fn () => Money::ofCurrency(10_00, 'USD')->add(Money::ofCurrency(5_00, 'EUR')))
        ->toThrow(CurrencyMismatch::class);
});

```

Pest datasets keep these exhaustive without repetition.

---

Where the Framework Re-Enters: Integration Tests
------------------------------------------------

Infrastructure concerns — Eloquent repositories, queued jobs, HTTP endpoints — belong in `tests/Feature/`. Use `RefreshDatabase` there and only there. The boundary is intentional:

| Layer | Test type | Uses framework? | |---|---|---| | Value objects, DTOs | Unit | No | | Domain actions | Unit | No | | Eloquent repositories | Integration | Yes | | HTTP controllers | Feature | Yes |

---

Takeaways
---------

- **Interfaces at domain boundaries** are the prerequisite — without them, mocking is painful and coupling is hidden.
- **`FrozenClock` and factory helpers** replace time-dependent and state-dependent setup without the database.
- **Pest datasets** make value object edge cases exhaustive and readable.
- **Separate `tests/Unit/Domain/` from `tests/Feature/`** and enforce the split in CI with `--testsuite=unit` for the fast gate.
- A domain test suite that boots zero framework code is a design signal, not just a speed win.

 Found this useful?

          [  ](https://twitter.com/intent/tweet?url=https%3A%2F%2Fwww.msaied.com%2Farticles%2Fclean-architecture-and-pest-testing-actions-dtos-and-domain-services-without-the-framework&text=Clean+Architecture+and+Pest%3A+Testing+Actions%2C+DTOs%2C+and+Domain+Services+Without+the+Framework) [  ](https://www.linkedin.com/sharing/share-offsite/?url=https%3A%2F%2Fwww.msaied.com%2Farticles%2Fclean-architecture-and-pest-testing-actions-dtos-and-domain-services-without-the-framework) 

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

  3 questions  

     Q01  Do I need a special Pest plugin to test domain code without Laravel?        No. Plain Pest with Mockery is sufficient. Avoid `uses(RefreshDatabase::class)` or `uses(CreatesApplication::class)` in unit test files and the framework never boots. 

      Q02  How do I handle actions that need config values without injecting the Config facade?        Wrap the config value in a typed value object or a dedicated settings DTO and inject that instead. The action stays framework-agnostic and the DTO is trivially constructable in tests. 

      Q03  Should Eloquent models ever appear inside domain actions?        No. Pass DTOs or value objects into actions and return domain types. Eloquent models belong in the infrastructure layer — repositories translate between them and domain objects. 

  Continue reading

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

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

 [ ![Filament v5 Preview: Schema Unification, Performance Shifts, and How to Prepare](https://cdn.msaied.com/340/1a05ca68637b898b676efb66f22e627f.png) filament laravel php 

### Filament v5 Preview: Schema Unification, Performance Shifts, and How to Prepare

Filament v5 is reshaping how panels, forms, and tables are composed. This deep-dive covers the confirmed archi...

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

 1 Jul 2026     4 min read  

  Read    

 ](https://www.msaied.com/articles/filament-v5-preview-schema-unification-performance-shifts-and-how-to-prepare) [ ![Laravel 13: New Features, Helpers, and Practical Upgrade Notes](https://cdn.msaied.com/339/58c4fa6fe9b6d25a2dac17c621b6f4c6.png) laravel laravel-13 upgrade 

### Laravel 13: New Features, Helpers, and Practical Upgrade Notes

Laravel 13 ships with async-first defaults, a leaner bootstrapping layer, and several quality-of-life helpers....

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

 1 Jul 2026     3 min read  

  Read    

 ](https://www.msaied.com/articles/laravel-13-new-features-helpers-and-practical-upgrade-notes) [ ![Laravel 12: Structured Route Files, Slim Skeletons, and the New Application Bootstrapping](https://cdn.msaied.com/337/05b39d16d0f88a5fb94d0cf74049b88b.png) laravel laravel-12 upgrade 

### Laravel 12: Structured Route Files, Slim Skeletons, and the New Application Bootstrapping

Laravel 12 ships with a leaner skeleton, first-class route file organisation, and a revised application bootst...

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

 1 Jul 2026     3 min read  

  Read    

 ](https://www.msaied.com/articles/laravel-12-structured-route-files-slim-skeletons-and-the-new-application-bootstrapping) 

   [  ![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)
