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:
RefreshDatabaseresets 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():
| Use | Instead 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
What to read next
- API responses — the envelope your feature tests assert against.
- Actions — the unit-test target.
- Services and contracts — what gets mocked when crossing a boundary.
Security
The handful of security patterns that cause most Laravel bugs in this codebase — Form Requests, authorization gates, `$fillable`, escaping, and what not to return in API responses.
Claude Code
How the team uses Claude Code with the `winch` skill — the rule files Claude follows, the named agents we run, common workflows, and how to add a new rule.