Best Practices

Testing

Why this exists

Tests in a DDD codebase do double duty: they verify behaviour and they pin the public boundaries of each domain. A feature test that hits the API and asserts against data.* proves the envelope is correct. A unit test that calls an Action directly proves the Action works without the HTTP layer in the way. A test that mocks a Contract via the container proves the boundary really is a seam.

This chapter is the project's testing patterns adapted for the DDD layout. The framework is Pest 4; the conventions below differ from a stock Pest setup in a handful of important places.

Where tests live

tests/
├── Browser/        # Pest 4 browser tests
├── Feature/        # HTTP-level integration tests
├── Unit/           # isolated unit tests (Actions, DTOs, helpers)
├── Pest.php        # global Pest configuration
└── TestCase.php

Mirror the directory structure of src/ under tests/Unit/ and tests/Feature/ where practical. A test for Domain\Order\Actions\CreateOrder lives at tests/Unit/Order/CreateOrderTest.php.

Creating a test

php artisan make:test --pest CreateOrderTest          # creates tests/Feature/CreateOrderTest.php
php artisan make:test --pest --unit CreateOrderTest   # creates tests/Unit/CreateOrderTest.php

The {name} argument should not include the test suite directory. make:test --pest Feature/SomeFeatureTest produces tests/Feature/Feature/SomeFeatureTest.php — wrong.

Use test() or it() consistently with what neighbouring tests use. Both are valid; sibling files set the convention for the directory.

Unit test for an Action

Actions are the project's smallest reusable operation, and they're the easiest thing to test. Call Class::handle($data) directly and assert against the result.

use Domain\Order\Actions\CreateOrder;
use Domain\Order\DataTransferObjects\CreateOrderData;
use Domain\Order\Models\Entities\Order;

uses(RefreshDatabase::class);

it('creates an order with the given provider', function () {
    $provider = Provider::factory()->create();
    $customer = User::factory()->create();

    $order = CreateOrder::handle(new CreateOrderData(
        provider_id: $provider->id,
        customer_id: $customer->id,
        total_amount: 350.00,
    ));

    expect($order->provider_id)->toBe($provider->id)
        ->and($order->total_amount)->toEqual(350.00);
});

it('rolls back when a downstream Action throws', function () {
    $provider = Provider::factory()->create();

    expect(fn () => CreateOrder::handle(new CreateOrderData(
        provider_id: $provider->id,
        customer_id: 999_999,                 // does not exist
        total_amount: 350.00,
    )))->toThrow(QueryException::class);

    expect(Order::count())->toBe(0);          // transaction rolled back
});

Two patterns worth keeping in mind:

  • RefreshDatabase resets the schema between tests so each one starts from a known state.
  • Factories are the canonical way to create models in tests. Don't hand-build them; use the factory's default state and override what you need.

Feature test for a controller endpoint

Feature tests hit the HTTP layer. Use the project's response envelope when asserting — path assertions go through data.*.

use function Pest\Laravel\actingAs;
use function Pest\Laravel\postJson;

uses(RefreshDatabase::class);

it('creates an order via the API', function () {
    $customer = User::factory()->create();
    $provider = Provider::factory()->create();

    actingAs($customer, 'sanctum')
        ->postJson('/api/v1/orders', [
            'provider_id' => $provider->id,
            'amount' => 350.00,
        ])
        ->assertStatus(201)
        ->assertJsonPath('status.code', 201)
        ->assertJsonPath('status.success', true)
        ->assertJsonPath('status.error_key', 'ok')
        ->assertJsonPath('data.amount', '350.00');
});

it('rejects an order with no provider', function () {
    $customer = User::factory()->create();

    actingAs($customer, 'sanctum')
        ->postJson('/api/v1/orders', ['amount' => 350])
        ->assertStatus(422)
        ->assertJsonPath('status.error_key', 'validation_failed')
        ->assertJsonPath('errors.provider_id.0', 'The provider id field is required.');
});

Don't assert against status.message unless you've pinned the locale for the test — message text is localised. See API responses for the full envelope.

Mocking a Contract via the container

The boundary between domains is the Contract. A unit test in one domain that needs the upstream's behaviour should bind a fake into the container rather than constructing the upstream's real Service.

use Domain\Provider\Contracts\ProviderContract;
use function Pest\Laravel\mock;

it('uses the provider name from the contract', function () {
    mock(ProviderContract::class)
        ->shouldReceive('getProvider')
        ->once()
        ->andReturn(Provider::factory()->make(['name' => 'Acme']));

    $result = AssignOrderToProvider::handle(orderId: 42, providerId: 7);

    expect($result->provider_name)->toBe('Acme');
});

Pest\Laravel\mock binds the mock into the container so the app(ProviderContract::class) call inside the Action returns the fake. The Action under test stays untouched.

Specific assertion helpers

Use specific assertions over assertStatus():

UseInstead of
assertSuccessful()assertStatus(200)
assertNotFound()assertStatus(404)
assertForbidden()assertStatus(403)
assertUnauthorized()assertStatus(401)

The specific helpers read better and produce friendlier failure messages.

Datasets

For repetitive tests — validation rules, status-code matrices, edge-case enums — use Pest datasets.

it('rejects invalid amounts', function (mixed $amount) {
    actingAs(User::factory()->create(), 'sanctum')
        ->postJson('/api/v1/orders', ['provider_id' => 1, 'amount' => $amount])
        ->assertStatus(422)
        ->assertJsonPath('status.error_key', 'validation_failed');
})->with([
    'negative' => -1,
    'zero'     => 0,
    'string'   => 'three hundred',
    'null'     => null,
]);

Browser tests

Browser tests live in tests/Browser/ and run in a real browser via Pest 4's browser-testing support. Use them for full integration flows that exercise JavaScript or visual rendering — login flows, Livewire components, PDF previews.

For smoke-testing several pages quickly to catch JavaScript errors or 500s, Pest 4 supports a smoke-test idiom:

$pages = ['/cp/login', '/cp/dashboard', '/cp/orders'];

it('smoke tests admin pages', function (string $url) {
    visit($url)->assertNoJavaScriptErrors();
})->with($pages);

Architecture tests

Pest 4 ships an arch() API for enforcing code conventions. We use it sparingly to lock in the project's DDD rules — for example, asserting no domain imports another domain's Models.

arch('domains do not import other domains\' models')
    ->expect('Domain\Order')
    ->not->toUse('Domain\Provider\Models\Entities')
    ->not->toUse('Domain\Customer\Models\Entities');

When an architecture test starts failing, the fix is almost always in the code, not in the test.

Running tests

./vendor/bin/pest                          # all tests
./vendor/bin/pest tests/Unit               # only unit
./vendor/bin/pest tests/Feature            # only feature
php artisan test --compact                 # alternate runner with terse output
php artisan test --compact --filter=createOrder