Testing Actions &amp; DTOs with Pest in Clean Architecture | 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 and Pest: Testing Actions, DTOs, and Domain Services Without the Framework

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

 Learn how to structure Pest test suites around domain actions, DTOs, and value objects so your core business logic is verified fast, in isolation, and without booting Laravel.

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

ShareCopy linkCopied

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

  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)

 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.

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

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

  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.

   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.

   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.

   ![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 articleDDD in Laravel: Actions, DTOs, and Value Objects Without the Bloat](https://www.msaied.com/public/articles/ddd-in-laravel-actions-dtos-and-value-objects-without-the-bloat) [Next articleBuilding a Laravel Package: Service Providers, Auto-Discovery, and Config Merging](https://www.msaied.com/public/articles/building-a-laravel-package-service-providers-auto-discovery-and-config-merging)  

   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)

 ###  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)
