Clean Architecture and Pest: Testing Actions, DTOs, and Domain Services Without the Framework
#pest #clean-architecture #testing #laravel #ddd

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

4 min read Mohamed Said Mohamed Said

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:

// 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.

// 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:

// 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?

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