Appearance
Testing (project conventions)
Pest only, written alongside the code.
- Bias to feature tests through HTTP — hit a route, assert the response, assert the resulting state (DB rows, sent mail, dispatched jobs). Reserve unit tests for genuinely isolated logic (value objects, calculators).
- Name tests as readable sentences (
it('rejects an expired coupon')). One behaviour per test; arrange / act / assert separated by blank lines. - Use factories + dedicated states for meaningful variants; set only the attributes the test cares about.
- Fixture/scenario builders mirror the domain's shape. Make the structural grouping the top-level API (a closure per group —
->withQuickWins(fn ($section) => $section->addTask(...))), and keep a record's attributes (overdue = a due date, quick = a duration) orthogonal to its placement — pass them per-task (addTask('…', dueDaysAgo: 1)), never weld them into the method name. A builder method that bakes an attribute into a section name (withOverdue= section + due) or hides which group it targets (withTask) conflates the two axes; an overdue task must be expressible inside a normal section. Seetests/Fixtures/TodoistScenario.php. - Assert real outcomes:
assertDatabaseHas,assertRedirect,assertForbidden,assertSessionHasErrors,Mail::assertSent,Queue::assertPushed. Add a query-count / N+1 assertion where performance matters. - Assert with
expect(), not PHPUnit value-asserts. Atests/Architecture/ExpectStyleTest.phpguard fails CI if a test body uses$this->assertSame|assertEquals|assertTrue|assertFalse|assertNull|assertNotNull|assertCount|assertInstanceOf|assertContains|assertEmpty— each has a directexpect()equivalent. Laravel feature/HTTP/DB/auth asserts and browser page-object asserts (noexpect()equivalent) and staticAssert::intests/Fakes/tests/Fixturesare allowed. - Assert cardinality, not just existence. When you assert a thing is present — a DB row, a rendered element, an emitted event — assert the exact count too (
assertDatabaseCount,->assertCount('[data-test="…"]', n),->has('tasks', 1),expect($wrapper->emitted('linked'))->toHaveCount(1)). "Contains the one I made" silently passes when there are also three I didn't. - Distinct IDs per object in a test. When a test sets up more than one object — a task and a person and a comment — give each a different id (don't let them all default to
1). Shared ids let a lookup or assertion match on the id alone and grab the wrong object type, hiding real bugs; distinct ids make a wrong-object match fail loudly. (One object referenced in two places — a plan row and its matching fake — should share its id.) - Cover the happy path and every guard path (unauthorised → 403, invalid input, not-found, conflicting state). Freeze the clock per-test in arrange (
Carbon::setTestNow), never globally. - Time determinism is enforced, not just asked for (it bit us — bug #488, a hardcoded
2026-06-12due date with no freeze that detonated at the date boundary). Two gates back the per-test-freeze rule, each by a different mechanism, because a hidden global "now" would be the cure becoming the disease:app/may not read the clock ambiently.phpstan-disallowed-clock.neonbansnow()/today()/Carbon::now()/Carbon::today()in production code (existing call sites are grandfathered inphpstan-clock-baseline.neon; new ones fail PHPStan). Pass the reference time in —isOverdue($on), an$onparameter — so callers and tests control time. This is the prevention layer.- The suite runs once ~3 years in the future (the
time-travelfaketime job intests.yml). A test that exercises clock-reading code without freezing its own clock flips red there; an explicitsetTestNowis immune. This is the detection net for the symptom in tests, and it catches reads through baselined production code that the lint can't. Currently non-blocking — it reports latent bombs until the suite is clean under it, then it becomes a gate.
- Design against ZOMBIES + CORRECT; every external API call is mocked (
Http::fake(),mock()). Runqa-testsbefore opening a PR. covers()scopes mutation attribution, not just coverage. A test that exercises a class but doesn't name it incovers()is not credited with killing that class's mutants — they survive as "untested" and drag the score down even though the behaviour is asserted. When a test drives its scenario through a collaborator (a shared builder, a DTO, an Action), add that collaborator tocovers(...)too, or its mutants survive uncounted. (Bit us twice — Sprint A coverage and Sprint B mutation 87.75→90.24;docs/learnings/2026-06-14-covers-scopes-mutation-attribution.md.)- Browser tests don't gate in
ci:check(thebrowsergroup is excluded; they run in the frontend CI lane) — so any guard path a browser test proves needs a feature-test twin or the gate never sees it. For partial-outage arranges (page load works, the action fails), useFakeTodoistClient::failGetTaskAfter(n). The full layer split lives indocs/agents/qa-ui-contract.md. - Run the browser group in ONE process — never fan it across parallel
php artisan test tests/Browserinvocations. Each pest-browser process stands up its own in-process amphp server + Playwright/Chromium and they contend (same bindings / test-DB), so two at once hang with no output. UseCOMPOSER_ALLOW_SUPERUSER=1 composer test:browser(one process) and narrow with--filterinside it to go faster. Seedocs/learnings/2026-06-20-pest-browser-parallel-processes-hang.md. - Quality gate (
composer ci:check): PHPStan L7, coverage ≥ 80%, mutation ≥ 90%. Never lower a threshold — fix surviving mutants by adding tests. (Windows:php -d memory_limit=3Gfor phpstan/coverage; mutation only reads reliably from a full run, not--dirty.) - Definition of done = a green
composer ci:check. Never open or merge a PR on red; never lower a threshold or skip / comment-out a failing test to go green (fix it or revert). "Done with known issues" is not mergeable — file out-of-scope defects as issues and link them, don't ship caveats. Flaky / infra-only failures (not caused by the diff): re-run; if it persists, file an issue and pause — don't merge. - Security is a gate too. The deterministic security check (known-CVE / dependency audit, secret scan, static security lint) blocks like PHPStan; a critical/major LLM security finding is human-gated locally before "done", not a mechanical CI blocker. Scope + severity from
docs/security/threat-model.md. No "done with known security issues."