Testing this layout
Read when: writing or fixing a Pest test for a domain, an endpoint or a page in this layout, or adding a rule that should fail the build when the layout is broken. Owns: the test database, where tests live, what to test at which layer, envelope assertions, page tests, seeding rows and factories for domain models, fakes, and the architecture tests that enforce the layers.
Written for Pest 4 on Laravel 12 (checked 2026-11-03).
TL;DR
- Tests run on their own database, forced in
phpunit.xml. Never point a run at a working database. - Test the Action for logic, the Contract for what another domain relies on, the route for HTTP. Do not test a Service's one-line delegation.
- Mirror the domain in the path:
tests/Feature/{Domain}/,tests/Unit/{Domain}/. - JSON assertions go through the envelope:
data.*andstatus.*, never top-level keys; validation errors are instatus.errors_list. - A page test asserts the component, the props the page reads, deferred props, the redirect after each write, and the 403.
- No test reaches a real external service.
arch()tests hold the layer rules; add one when a review catches the same mistake twice.- Cover a failure path, not only the happy one. Never skip or delete a failing test to get green.
The test database
<!-- phpunit.xml -->
<env name="APP_ENV" value="testing"/>
<env name="DB_DATABASE" value="myapp_test" force="true"/>
<env name="QUEUE_CONNECTION" value="sync"/>
<env name="MAIL_MAILER" value="array"/>
<env name="CACHE_STORE" value="array"/>
<env name="SESSION_DRIVER" value="array"/>
<env name="BCRYPT_ROUNDS" value="4"/>
force="true"so a local.envcannot win. Why:RefreshDatabaseagainst the working database wipes it.- Prepare it with
php artisan migrate --env=testing, or letRefreshDatabasemigrate. Nevermigrate:freshby hand. - If the schema uses engine-specific features (JSON functions, generated columns, full-text), run the suite on that engine, not SQLite: a SQLite run fails in ways that look like test bugs.
- One test database shared by several checkouts means one suite at a time.
- Run the narrowest set while working:
php artisan test --compact --filter=ActivateEmployeeTest.
Where tests live
tests/Feature/Employee/ActivateEmployeeTest.php an Action or a Contract, with the database
tests/Feature/Employee/Api/EmployeeEndpointsTest.php routes of one JSON panel
tests/Feature/Pages/EmployeeShowPageTest.php one Inertia page, named after its props class
tests/Feature/Pages/EmployeeFormPageTest.php Create and Edit, as EmployeeFormProps
tests/Unit/Employee/EmployeeStatusTest.php an enum, a value object, a pure helper
tests/Arch/LayersTest.php the layer rules
Most tests are feature tests. A unit test fits a pure function, an enum, a key builder, or a trait with no database.
// tests/Pest.php
pest()->extend(Tests\TestCase::class)->use(RefreshDatabase::class)->in('Feature');
What to test where
| Subject | Test | Why |
|---|---|---|
| A write, a calculation, a branch | the Action directly | it holds the logic; the cheapest place to cover branches |
| Behaviour another domain relies on | app(EmployeeContract::class) |
that is the real boundary |
| A Service | usually not directly | it only delegates; you would test the Action twice |
| An endpoint | a feature test on the route | covers routing, the Form Request, authorization, the envelope |
| A page | a feature test with assertInertia |
covers the props class and the controller together |
| A status transition | the transition | assert the refusal reason, not only the boolean |
| An ability | the ability method | a plain bool, cheap to cover every state |
| A filter | the Get{Entity}s Action with a DTO |
rows in and rows out, including the scoping key |
it('activates a pending employee', function () {
$employee = Employee::factory()->pending()->create();
$activated = ActivateEmployee::handle($employee);
expect($activated->status)->toBe(EmployeeStatus::ACTIVE)
->and($activated->employee_number)->not->toBeNull();
});
it('refuses to activate an archived employee', function () {
$employee = Employee::factory()->archived()->create();
expect(fn () => ActivateEmployee::handle($employee))->toThrow(EmployeeAlreadyArchived::class);
});
JSON endpoints and the envelope
The shape is in json-api.md.
it('creates an employee', function () {
$user = User::factory()->create();
actingAs(User::factory()->create(), 'sanctum')
->postJson('/api/v1/employees', ['user_id' => $user->id, 'job_title' => 'Analyst', 'contacts' => []])
->assertCreated()
->assertJsonPath('status.success', true)
->assertJsonPath('data.user.id', (string) $user->id);
});
it('rejects a missing user', function () {
actingAs(User::factory()->create(), 'sanctum')
->postJson('/api/v1/employees', [])
->assertUnprocessable()
->assertJsonPath('status.error_key', 'validation_error')
->assertJsonPath('status.errors_list.*.field', ['user_id', 'job_title']);
});
it('hides another tenant\'s employee', function () {
$foreign = Employee::factory()->create();
actingAs(User::factory()->create(), 'sanctum')
->getJson("/api/v1/employees/{$foreign->id}")
->assertNotFound()
->assertJsonPath('status.error_key', 'not_found');
});
- Assert
status.codeandstatus.error_key, notstatus.message, which changes with the locale. assertJsonValidationErrors()reads Laravel's defaulterrorskey, which this envelope does not send. Assertstatus.errors_list.*.fieldinstead.assertJsonPath('id', …)orassertJsonStructure(['id'])at the top level is always wrong here.- A paginated list:
assertJsonCount(20, 'data')andassertJsonPath('pagination.total', 137). - For each guarded route, test the refused case too: 401 signed out, 403 without the permission, 404 for another tenant's row.
- A JSON endpoint that feeds a page has no envelope on success
(
assertJsonCount(2, 'activities')) but keeps it on failure.
Page tests
beforeEach(function () {
$this->withoutVite();
$this->admin = User::factory()->withPermissions(['admin.employees.show', 'admin.employees.update'])->create();
});
it('renders the show page with its invitations deferred', function () {
$employee = Employee::factory()->active()->has(Invitation::factory()->count(2))->create();
actingAs($this->admin)
->get(route('admin.employees.show', $employee->id))
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('employees/Show')
->where('employee.id', (string) $employee->id)
->where('employee.status.value', EmployeeStatus::ACTIVE->value)
->missing('invitations')
->loadDeferredProps(fn (Assert $reload) => $reload->has('invitations', 2)));
});
it('goes back to the record after a save', function () {
$employee = Employee::factory()->pending()->create();
actingAs($this->admin)
->put(route('admin.employees.update', $employee->id), ['notes' => 'Starts Monday'])
->assertRedirect(route('admin.employees.show', $employee->id));
expect($employee->fresh()->notes)->toBe('Starts Monday');
});
it('refuses a user without the permission', function () {
actingAs(User::factory()->create())
->get(route('admin.employees.show', Employee::factory()->create()->id))
->assertForbidden();
});
Each page's tests cover at least:
- the component and the values the page shows (
where()on each, not the whole array); - its deferred props load (
loadDeferredProps); - on an index, a filter narrows the rows, and an invalid filter value redirects to the plain list;
- each write changes the row and redirects where http.md says;
- a user without the permission gets 403.
withoutVite() so a test does not need a front-end build. Disable throttle middleware in the file
(withoutMiddleware(ThrottleRequests::class)) when the page is hit many times.
Seeding rows and factories
// ✅ factory with states
$employee = Employee::factory()->active()->for($user)->create();
// ❌ magic values the casts would have caught
$employee = new Employee(['status' => 2, 'user_id' => $user->id]);
$employee->save();
- Check for an existing state before adding one.
- Domain entities live outside
App\Models, so either each model definesprotected static function newFactory(): EmployeeFactory, or a provider maps names once withFactory::guessFactoryNamesUsing(). Factories live indatabase/factories/{Domain}/. - A codebase without factories seeds what each test needs:
Model::create([...])when the model's casts and events matter,DB::table(...)->insertGetId([...])for plain rows. Adding factories is its own task, not a side effect of one test. - Explore behaviour by writing a test, not by creating rows in tinker.
Fakes
Http::preventStrayRequests()in the base test case (or abeforeEachintests/Pest.php), so an unfaked call fails the test.Http::fake(),Mail::fake(),Notification::fake(),Event::fake(),Storage::fake().Bus::fake()/Queue::fake()to assert dispatch; withQUEUE_CONNECTION=syncjobs otherwise run inline.- Assert a notification with
Notification::assertSentTo(), not by counting rows in a table. - Swap a Contract with
$this->app->instance(MailProviderContract::class, $fake). This works because domain classes resolve withapp(). - An external integration ships a fake implementation of its Contract; tests bind it.
Security-sensitive code earns adversarial tests
- SQL building: store an apostrophe (
O'Neil), a backslash, an injection string and non-ASCII text; assert each comes back verbatim, with an untouched neighbour row proving theWHEREstill scopes. - Authorization: every role and the signed-out case, for every new route.
- Tenant scoping: a second tenant's rows exist and never appear, in lists and in every by-id method.
- Filters:
1e3,05,-3and a 256-item list are refused. - Rate limits and throttle keys: a blank or unknown part is rejected.
- Once-only operations: the second call is a no-op.
Architecture tests
arch() checks what a file imports, not every call, so it is cheap and coarse. Keep one file:
// tests/Arch/LayersTest.php
$root = dirname(__DIR__, 2);
$domains = array_values(array_diff(array_map('basename', glob("{$root}/src/Domain/*", GLOB_ONLYDIR)), ['Core']));
$panels = array_map('basename', glob("{$root}/src/Presentation/*", GLOB_ONLYDIR));
arch('no debug calls')->expect(['dd', 'dump', 'ray', 'var_dump'])->not->toBeUsed();
arch('the domain knows no presentation')->expect('Domain')->not->toUse(['Presentation', 'Inertia']);
arch('validation stays in Form Requests')->expect('Domain')->not->toUse('Illuminate\Support\Facades\Validator');
foreach ($domains as $domain) {
arch("{$domain}: actions are final and static")
->expect("Domain\\{$domain}\\Actions")->classes()
->toBeFinal()->not->toHaveConstructor()->toHaveMethod('handle');
arch("{$domain}: services hold no queries")
->expect("Domain\\{$domain}\\Services")->not->toUse('Illuminate\Support\Facades\DB');
arch("{$domain}: DTOs end in Data")
->expect("Domain\\{$domain}\\DataTransferObjects")->classes()->toHaveSuffix('Data');
arch("{$domain}: contracts are interfaces")
->expect("Domain\\{$domain}\\Contracts")->toBeInterfaces();
$others = array_diff($domains, [$domain]);
arch("{$domain}: other domains only through their Contracts")
->expect("Domain\\{$domain}")
->not->toUse(array_merge(...array_map(fn (string $other) => ["Domain\\{$other}\\Actions", "Domain\\{$other}\\Services"], $others)));
}
foreach ($panels as $panel) {
arch("{$panel}: controllers stay thin")
->expect("Presentation\\{$panel}\\Controllers")
->not->toUse(['Illuminate\Support\Facades\DB', ...array_map(fn (string $domain) => "Domain\\{$domain}\\Actions", $domains)]);
arch("{$panel}: requests are Form Requests")
->expect("Presentation\\{$panel}\\Requests")->classes()
->toExtend('Illuminate\Foundation\Http\FormRequest')->toHaveSuffix('Request');
}
glob(), not a facade: the file runs before the application boots.- When two domains were split on purpose, pin it with a named rule
(
expect('Domain\Department')->not->toUse('Domain\Employee')) and a one-line why above it. Why: the coupling grows back one convenient import at a time. - Start a legacy codebase with the rules that already pass, and add the rest as each layer is cleaned;
->ignoring('Domain\Legacy\…')names the known exceptions instead of dropping the rule. - What
arch()cannot see (aModel::query()call inside a Service,$request->all()passed to a DTO) needs a custom PHPStan rule, or a test that scans the source for the pattern and lists every hit.
Don't
- Don't delete, skip or weaken a failing test to get green; find the cause.
- Don't write a one-off verification script when a test would prove the same thing.
- Don't let a test depend on another test's data or on run order.
- Don't call real AI, mail or messaging providers from tests, even once.
Verify
# Tests asserting top-level payload keys on an enveloped API
grep -rnE "assertJsonPath\('(id|name|employee_number)'" tests/
# Validation asserted against Laravel's default errors key
grep -rn "assertJsonValidationErrors" tests/Feature/
# The test database is forced
grep -n "DB_DATABASE" phpunit.xml
# Destructive commands in test setup
grep -rn "migrate:fresh\|migrate:refresh\|db:wipe" tests/
# Skipped tests
grep -rnE -- "->skip\(|markTestSkipped|->todo\(" tests/