Skip to main content

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-testing skill 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:

  1. 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 is ModuleBoundaryTest, whose dependency rules are hand-maintained: when you add a module, you must slot it into the graph.
  2. A meta-guard against vacuous guards. GuardCoverageTest proves 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

GuardLocationEnforces
StrictTypesTesttests/Architecture/declare(strict_types=1) in every file under app/ and every module's src/
ReturnTypeTesttests/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())
CqrsTesttests/Architecture/Every Command/Query implements its contract, is immutable, and routes to a real handler; never both Idempotent and WithoutTransaction
CqrsBoundaryTesttests/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
ModuleBoundaryTesttests/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
AuthorizationCoverageTesttests/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
ModelConventionsTesttests/Architecture/Every Eloquent model carries HasPublicUuid + SoftDeletes + Blamable unless class-exempted (D25/D26)
TenantIsolationTesttests/Architecture/The conventions that make RLS correct: tenant-scoped models use FillsCurrentTenant, central models (e.g. the backoffice User) never carry the tenant trait
EventConventionsTesttests/Architecture/Domain events are final, implement the contract, carry no Eloquent models, have unique wire names; queued jobs/listeners carry only serialization-safe primitives
IntegrationContractsTesttests/Architecture/src/Integration/Contracts publishes only interfaces, and no contract method returns an Eloquent model or builder (D52)
RationaleCoverageTesttests/Architecture/Compliance-significant commands (freeze/unfreeze/closure, risk-rating change, case disposition) carry a mandatory audited rationale (D46)
GuardCoverageTesttests/Architecture/The meta-guard: module/model/layer discovery is non-vacuous, no model file is silently dropped
SchemaConventionTesttests/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
RlsCoverageTestapp-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() in setUp() — the schema, role, grants, and policies are provisioned once per process (TenantDatabaseState::provisionOnce()), and each test starts from a fast TRUNCATE ... RESTART IDENTITY CASCADE that leaves schema and policies intact. This replaced the historical per-test migrate:fresh + tenants:rls (~39 min → ~1.6 min suite).
  • $this->actingAsClient($user, $tenant) — authenticates on the client guard and presents the tenant's public uuid in the X-Tenant header, 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 over tenancy()->run()) for arranging/asserting tenant-scoped data.

Running the suite

CommandWhat it isWhen
make test-parallelFull 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\ParallelTestingServiceProviderThe default local verification
make testFull suite, serial (~12 min)When parallel contention is suspected
make test-filter FILTER=NameOne test/class, serial, no per-worker overheadThe inner iteration loop
make test-coverageSuite 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:

#GateCommandAlways?
1Test suitemake test-parallelAlways
2Code style (Pint)make lintAlways (fixes in place; CI uses pint --test)
3Static analysis (PHPStan/Larastan, level 6)make stanAlways
4RLS regeneration + isolation proofphp artisan tenants:rls + php artisan tenants:verify-isolationAfter 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.


← Engineering wiki index