Skip to content

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. See tests/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. A tests/Architecture/ExpectStyleTest.php guard fails CI if a test body uses $this->assertSame|assertEquals|assertTrue|assertFalse|assertNull|assertNotNull|assertCount|assertInstanceOf|assertContains|assertEmpty — each has a direct expect() equivalent. Laravel feature/HTTP/DB/auth asserts and browser page-object asserts (no expect() equivalent) and static Assert:: in tests/Fakes/tests/Fixtures are 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-12 due 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.neon bans now() / today() / Carbon::now() / Carbon::today() in production code (existing call sites are grandfathered in phpstan-clock-baseline.neon; new ones fail PHPStan). Pass the reference time in — isOverdue($on), an $on parameter — so callers and tests control time. This is the prevention layer.
    • The suite runs once ~3 years in the future (the time-travel faketime job in tests.yml). A test that exercises clock-reading code without freezing its own clock flips red there; an explicit setTestNow is 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()). Run qa-tests before opening a PR.
  • covers() scopes mutation attribution, not just coverage. A test that exercises a class but doesn't name it in covers() 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 to covers(...) 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 (the browser group 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), use FakeTodoistClient::failGetTaskAfter(n). The full layer split lives in docs/agents/qa-ui-contract.md.
  • Run the browser group in ONE process — never fan it across parallel php artisan test tests/Browser invocations. 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. Use COMPOSER_ALLOW_SUPERUSER=1 composer test:browser (one process) and narrow with --filter inside it to go faster. See docs/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=3G for 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."