Testing & the Gates
What this covers / who it's for. The map of how this app is verified: the architecture-as-tests strategy, every structural guard and what it enforces, the RLS test lifecycle, how to run the suite (parallel vs serial vs filtered), and the four gates every change must pass. The how-to detail (writing tests, Pest idioms, factories) lives in the
pest-testingskill and the stables-app-development testing reference — this page is the map, not the manual.
The strategy: architecture as tests
The repo's non-negotiables (see How a change ships) are not review
conventions — they are executable guards in tests/Architecture/ that fail the suite the
moment code violates them. Two design choices make the guards durable:
- Discovery from disk, not hand-lists. Helpers in
tests/Pest.php(module_namespaces(),module_layer_namespaces()) enumerate every module, model, and presentation layer from the filesystem — a new module is guarded automatically, without editing any test. The one deliberate exception isModuleBoundaryTest, whose dependency rules are hand-maintained: when you add a module, you must slot it into the graph. - A meta-guard against vacuous guards.
GuardCoverageTestproves the discovery itself works — every module, model, and layer is actually enumerated, and every model file resolves to a loaded class — so a guard can never silently pass by scanning nothing.
Exemptions are always per-class, reviewed allow-lists inside the guard (never a dropped namespace), and the guards keep their own allow-lists honest (stale entries fail).
The guard table
| Guard | Location | Enforces |
|---|---|---|
StrictTypesTest | tests/Architecture/ | declare(strict_types=1) in every file under app/ and every module's src/ |
ReturnTypeTest | tests/Architecture/ | Native return types on every method, no mixed fallback; FQN exception list only for deliberate generic escape hatches (e.g. the bus's dispatch()/ask()) |
CqrsTest | tests/Architecture/ | Every Command/Query implements its contract, is immutable, and routes to a real handler; never both Idempotent and WithoutTransaction |
CqrsBoundaryTest | tests/Architecture/ | The read/write boundary (D22/D24): presentation layers (module Http + Livewire) never touch Eloquent, the query builder, or the DB facade — only handlers do; raw DB is confined to the bus + sanctioned internals |
ModuleBoundaryTest | tests/Architecture/ | The acyclic module dependency graph, pointing down to the Foundation shared kernel — the one hand-maintained guard: new modules must be slotted into its rules |
AuthorizationCoverageTest | tests/Architecture/ | Every CQRS message is gated by a declared permission (or a conscious allow-list entry); every guarded API route declares ->requires(); the allow-lists themselves stay honest |
ModelConventionsTest | tests/Architecture/ | Every Eloquent model carries HasPublicUuid + SoftDeletes + Blamable unless class-exempted (D25/D26) |
TenantIsolationTest | tests/Architecture/ | The conventions that make RLS correct: tenant-scoped models use FillsCurrentTenant, central models (e.g. the backoffice User) never carry the tenant trait |
EventConventionsTest | tests/Architecture/ | Domain events are final, implement the contract, carry no Eloquent models, have unique wire names; queued jobs/listeners carry only serialization-safe primitives |
IntegrationContractsTest | tests/Architecture/ | src/Integration/Contracts publishes only interfaces, and no contract method returns an Eloquent model or builder (D52) |
RationaleCoverageTest | tests/Architecture/ | Compliance-significant commands (freeze/unfreeze/closure, risk-rating change, case disposition) carry a mandatory audited rationale (D46) |
GuardCoverageTest | tests/Architecture/ | The meta-guard: module/model/layer discovery is non-vacuous, no model file is silently dropped |
SchemaConventionTest | tests/Feature/ | The realized database schema (not migration source): every table has bigint id + public uuid (uuidv7 default), deleted_at, and the blamable columns — per-convention exemption lists |
RlsCoverageTest | app-modules/tenancy/tests/Feature/ | The tenants:verify-isolation guard itself: every tenant-scoped table has an enforced RLS policy; a new tenant table without one fails the suite |
The structural guards do not replace the per-model runtime isolation test: every new tenant-scoped table also ships a feature test proving create as tenant A, read as tenant B → empty (see Tenancy & RLS).
The RLS test lifecycle
Tenant/RLS feature tests never use RefreshDatabase — RLS DDL (the NOBYPASSRLS role, grants,
FORCE-RLS policies) is schema-level and would be torn down by it (D14). Instead, tests/TestCase.php
provides:
$this->refreshTenantDatabase()insetUp()— the schema, role, grants, and policies are provisioned once per process (TenantDatabaseState::provisionOnce()), and each test starts from a fastTRUNCATE ... RESTART IDENTITY CASCADEthat leaves schema and policies intact. This replaced the historical per-testmigrate:fresh+tenants:rls(~39 min → ~1.6 min suite).$this->actingAsClient($user, $tenant)— authenticates on theclientguard and presents the tenant's public uuid in theX-Tenantheader, resolving the request into that tenant's RLS scope. The standard setup for tenant-API endpoint tests.$this->runInTenant($tenant, $callback)— runs a callback inside a tenant's RLS scope (thin wrapper overtenancy()->run()) for arranging/asserting tenant-scoped data.
Running the suite
| Command | What it is | When |
|---|---|---|
make test-parallel | Full suite in parallel (~2 min); each worker gets a private stables_testing_test_{token} database + its own Redis logical DB index, provisioned by App\Providers\ParallelTestingServiceProvider | The default local verification |
make test | Full suite, serial (~12 min) | When parallel contention is suspected |
make test-filter FILTER=Name | One test/class, serial, no per-worker overhead | The inner iteration loop |
make test-coverage | Suite with Xdebug coverage; gate with ARGS="--min=90" | Before merge; ratchet the floor toward 100 |
Parallel notes: keep worker count ≤ 16 (Valkey has 16 logical DBs; pass ARGS="--processes=12"
on bigger hosts), and remember that parallel sessions/worktrees share the stables_testing
database — concurrent full-suite runs contend and produce failures that look like isolation
bugs but aren't. Stagger them (see Local development).
The four gates
Every change must pass all applicable gates, run inside Docker:
| # | Gate | Command | Always? |
|---|---|---|---|
| 1 | Test suite | make test-parallel | Always |
| 2 | Code style (Pint) | make lint | Always (fixes in place; CI uses pint --test) |
| 3 | Static analysis (PHPStan/Larastan, level 6) | make stan | Always |
| 4 | RLS regeneration + isolation proof | php artisan tenants:rls + php artisan tenants:verify-isolation | After any migration touching a tenant-scoped table, or any change to the RLS machinery (the tenancy module, config/tenancy.php) |
CI vs you. CI (.github/workflows/ci.yml) runs php artisan test --parallel, PHPStan, and
pint --test — but it does not run tenants:verify-isolation yet (tracked in
docs/tracking/devops-hardening.md). Until that
lands, you are gate 4: run it locally whenever it applies.
Coverage
The goal is 100% coverage of app/ + app-modules/*/src (the <source> set in
phpunit.xml): fully cover what you touch, never let coverage regress. Measure with
make test-coverage (sets XDEBUG_MODE=coverage); enforce a floor with ARGS="--min=90" and
ratchet it up — don't put --min=100 on the default run until coverage actually is 100%.
Details and the fast unit-vs-feature split: the
testing reference.