Shared testing conventions for skills that generate or modify tests
php artisan boost:add-skill pekral/arch-app-services
Save this content to: .cursor/rules/code-testing/general.mdc
## Testing Rules
- Follow test conventions from:
- @rules/php/core-standards.mdc
- @rules/laravel/architecture.mdc (for Laravel projects)
- Write all new tests using Pest syntax (`it()` / `test()` functional blocks). Do not introduce PHPUnit-style class-based tests for new test files.
- When modifying existing PHPUnit tests, prefer rewriting them to Pest via `@skills/rewrite-tests-pest/SKILL.md` rather than extending the legacy class.
- Never use `describe()`; use top-level `it()` / `test()` only.
- Test classes should be `final`.
- Prefer local variables; avoid shared mutable state.
- In Laravel Pest projects, define `uses(Tests\TestCase::class)` in `tests/Pest.php` instead of repeating it in every test file.
## Flaky Test Prevention
A flaky test fails inconsistently, is hard to reproduce, and is often "fixed" by simply re-running the pipeline. Flaky tests destroy trust in CI — once a team learns to re-run instead of investigate, it starts ignoring real failures too. Every new or modified test must be deterministic: it must pass repeatedly, in isolation, and in any order. Apply the following:
- **Freeze time; never assert on the wall clock.** Tests that read `now()`, `time()`, `sleep()`, or the ambient timezone fail depending on environment speed and clock drift. Pin time with `Carbon::setTestNow(...)` (or `travelTo()`) and always restore it afterwards — `Carbon::setTestNow()` / `travelBack()` in `afterEach`.
- **Isolate database state.** A test must never rely on rows created by another test. Assertions such as `assertDatabaseCount('posts', 1)` start failing the moment another test seeds the same table. Isolate with `RefreshDatabase` (or `DatabaseTransactions`) and create exactly the rows the test under assertion needs.
- **Make factory data deterministic.** Do not let random factory values decide the outcome. Explicitly set every attribute the assertion depends on (for example the user's role) instead of trusting factory defaults or random states.
- **Test queues and async jobs deterministically.** Never wait for an asynchronous job to finish before asserting. Either assert the dispatch with `Bus::fake()` / `Queue::fake()`, or force `QUEUE_CONNECTION=sync` in the test environment so jobs run inline.
- **Keep shared resources parallel-safe.** Parallel runs do not create flakiness — they expose existing shared state: Redis keys, files, temp directories, config cache, static properties, and singletons. Isolate per test with `Storage::fake()`, unique keys / file names, and by avoiding mutable static or singleton state.
- **Never call external services directly.** Real HTTP requests fail on network issues, rate limits, latency, or sandbox outages — fake every outbound call with `Http::fake()` (and the relevant SDK fakes). See the *External Calls* section below for the full no-network / no-DNS contract.
- **Guarantee order independence.** A test must pass on its own, repeatedly, and in any order. If a test passes only because another test ran before it, fix the hidden dependency — do not rely on suite ordering.
## Data Handling
- For Laravel:
- Use `Model::factory()` for persisted data.
- Do not duplicate database defaults unless explicitly required by the test.
- Avoid mocking data that can be created via factories.
## Mocking
- Prefer storing real data in the database over mocking ModelManager or Repository classes.
- Mock only:
- external services that cannot run in test environment
- exception scenarios that are hard to reach otherwise
- Prefer partial mocks over full mocks.
- Remove unnecessary or redundant mocks.
## Jobs
- Dispatch jobs using `JobClass::dispatch(...)` only.
- Assert a job was dispatched with `Queue::assertPushed(JobClass::class)` (optionally with an integer times count, `Queue::assertPushed(JobClass::class, $times)`) only — never pass a closure/callback argument (issue #756). The assertion's sole purpose is to verify the job was pushed onto the queue; a closure inspecting job properties belongs in a dedicated unit test of the job itself, not in the dispatch assertion. This is a CR finding under **Queue assertion specificity (issue #756)** in `@rules/code-review/general.mdc` Core Analysis Walk-through — raise it there once, never additionally under Strict rule compliance.
## External Calls
- Tests must not call external services.
- Mock all HTTP requests.
- Use local or in-memory database connections for tests.
- Avoid DNS lookups in tests.
## Consistency
- Ensure new or modified tests follow existing project conventions.
## Test Organization
- Place every new test file under a directory path that mirrors the namespace of the production class it covers (e.g. `App\Service\Billing\InvoiceCalculator` → `tests/Service/Billing/InvoiceCalculatorTest.php`, `Pekral\CursorRules\InstallerPath` → `tests/InstallerPathTest.php`). A reviewer must be able to walk from a class to its tests by walking the parallel folder tree without searching.
- One production class maps to one primary test file. The file name is `{ClassName}Test.php` (Pest functional test file using the same base name as the SUT plus the `Test` suffix). Helper / scenario test files extracted from a primary test file keep the same directory and a descriptive `{ClassName}{Scenario}Test.php` name so the relationship stays explicit.
- Cross-cutting tests that do not target a single production class (full-stack feature flows, contract tests for an external API, install / boot scripts) go under an intent-named directory at the top of `tests/` (`tests/Feature/<flow>`, `tests/Contract/<vendor>`, `tests/Integration/<area>`). Do not park them next to an unrelated class just to satisfy a parallel path.
- Group multiple `it()` / `test()` blocks for the same SUT in the same test file when they share setup and subject. Do not split one SUT across many files unless the per-file size genuinely hurts readability (then use the `{ClassName}{Scenario}Test.php` form above).
- Each `it()` / `test()` description states the scenario in plain language and matches what the body actually asserts. Examples of *matching* descriptions: `it('returns zero for an empty cart')`, `test('throws InvalidArgumentException when the discount is negative')`. Examples of *non-matching* descriptions that violate this rule: generic placeholders (`it('it works')`, `test('test1')`, `test('happy path')`), descriptions that name the method instead of the scenario (`test('calculate')`, `it('handles getUser')`), or descriptions that contradict the assertions (`it('adds user')` over a body that asserts removal). When changing what a test asserts, rename the description in the same change.
## Comments in Tests
- **A test carries even less comment budget than production code.** The `it()` / `test()` description already states the scenario, arrange-act-assert already states the shape, and the assertions already state the expectation — so a comment narrating the setup, labelling the three phases (`// arrange`), or paraphrasing the description is pure noise. Do not write it, and delete it when you find it in a test you are already changing, per `@rules/php/core-standards.mdc` *Documentation*.
- **Keep only what the test body cannot say:** why a fixture holds an odd-looking value, the external quirk or upstream bug a regression pins, the issue / spec the case reproduces, or a genuinely non-obvious reason the assertion looks the way it does. Everything else belongs in the test description — when a comment is needed to tell the reader *what* the test does, rename the description instead of writing the comment.
- **A comment that survives is maintained.** When you change what a test asserts, update or delete its comment in the same change — a comment describing an assertion the test no longer makes misleads the next reader exactly as a stale production docblock does.
## Test Organization Review Hook
- In code-review and pre-push contexts, every new or moved test file must satisfy the **Test Organization** rules above. Misplaced files, mismatched file names, and descriptions that do not match the asserted scenario are CR findings — see `@skills/code-review/SKILL.md` "Test organization" Core Analysis bullet for the severity matrix and Suggested Fix template.
## Scope
- Focus tests on behavior, not implementation details.
## Execution
- Run tests in parallel whenever possible (e.g. `php artisan test --parallel` or `pest --parallel`).
## Quality
- Prefer simple, readable tests over complex setups.
- Use data providers (datasets) where they improve readability and reduce duplication across similar test cases.
- Tests must not contain conditions (e.g., `if`, `switch`); split conditional logic into separate test cases or data providers instead.
## Coverage
- Every test change must be verified to be functional — run affected tests after each modification.
- Require 100% code coverage for every changed or added code path — applies equally to code modifications and code review.
- Before running coverage, discover the project's coverage command (prefer Phing target from `build.xml`/`phing.xml`; fall back to a Composer script in `composer.json` such as `test:coverage` or `coverage`). Do not assume a default command.
- **Coverage reporting is short by default (issue #528 follow-up).** Run the coverage check on every change, but report the result on the published CR / tracker comment **only** when there is something the reader must act on:
- **uncovered changed lines** — list every uncovered line as a Critical finding and render the `## Coverage` section with the tool, exact command, and the uncovered-line list;
- **coverage tooling unavailable** — raise the missing-tool case as a Critical finding and render the `## Coverage` section with the reason in place of a result.
When every changed line is at 100% coverage and the tool ran successfully, **omit the `## Coverage` section entirely, omit the `Coverage:` header line, and omit the `coverage …` slot from the final summary line.** The CR is "clean" on the Counts line and the omission is the signal that coverage is satisfied — never emit `100%` / `clean` / `n/a` placeholders for the section, the header line, or the summary slot. The coverage check itself still runs unconditionally on every CR; only the user-visible reporting is short-circuited.
## Code Style and Quality Gates
- Discover available fixers and checkers: prefer Phing targets (`build.xml`/`phing.xml`) over Composer scripts (`composer.json`).
- Run available fixers on changed test files and fix any violations.
- Run available checkers/analyzers on changed test files and resolve all reported errors **by rewriting the flagged code, never by adding a suppression annotation** (`@rules/php/core-standards.mdc` PHP Practices).
- Complete all quality gates before finalizing test changes.
## Test Review
- After completing test changes, run a quick code review focused on test quality against these rules.
Follow test conventions from:
Write all new tests using Pest syntax (it() / test() functional blocks). Do not introduce PHPUnit-style class-based tests for new test files.
When modifying existing PHPUnit tests, prefer rewriting them to Pest via @skills/rewrite-tests-pest/SKILL.md rather than extending the legacy class.
Never use describe(); use top-level it() / test() only.
Test classes should be final.
Prefer local variables; avoid shared mutable state.
In Laravel Pest projects, define uses(Tests\TestCase::class) in tests/Pest.php instead of repeating it in every test file.
A flaky test fails inconsistently, is hard to reproduce, and is often "fixed" by simply re-running the pipeline. Flaky tests destroy trust in CI — once a team learns to re-run instead of investigate, it starts ignoring real failures too. Every new or modified test must be deterministic: it must pass repeatedly, in isolation, and in any order. Apply the following:
now(), time(), sleep(), or the ambient timezone fail depending on environment speed and clock drift. Pin time with Carbon::setTestNow(...) (or travelTo()) and always restore it afterwards — Carbon::setTestNow() / travelBack() in afterEach.assertDatabaseCount('posts', 1) start failing the moment another test seeds the same table. Isolate with RefreshDatabase (or DatabaseTransactions) and create exactly the rows the test under assertion needs.Bus::fake() / Queue::fake(), or force QUEUE_CONNECTION=sync in the test environment so jobs run inline.Storage::fake(), unique keys / file names, and by avoiding mutable static or singleton state.Http::fake() (and the relevant SDK fakes). See the External Calls section below for the full no-network / no-DNS contract.Model::factory() for persisted data.JobClass::dispatch(...) only.Queue::assertPushed(JobClass::class) (optionally with an integer times count, Queue::assertPushed(JobClass::class, $times)) only — never pass a closure/callback argument (issue #756). The assertion's sole purpose is to verify the job was pushed onto the queue; a closure inspecting job properties belongs in a dedicated unit test of the job itself, not in the dispatch assertion. This is a CR finding under Queue assertion specificity (issue #756) in @rules/code-review/general.mdc Core Analysis Walk-through — raise it there once, never additionally under Strict rule compliance.App\Service\Billing\InvoiceCalculator → tests/Service/Billing/InvoiceCalculatorTest.php, Pekral\CursorRules\InstallerPath → tests/InstallerPathTest.php). A reviewer must be able to walk from a class to its tests by walking the parallel folder tree without searching.{ClassName}Test.php (Pest functional test file using the same base name as the SUT plus the Test suffix). Helper / scenario test files extracted from a primary test file keep the same directory and a descriptive {ClassName}{Scenario}Test.php name so the relationship stays explicit.tests/ (tests/Feature/<flow>, tests/Contract/<vendor>, tests/Integration/<area>). Do not park them next to an unrelated class just to satisfy a parallel path.it() / test() blocks for the same SUT in the same test file when they share setup and subject. Do not split one SUT across many files unless the per-file size genuinely hurts readability (then use the {ClassName}{Scenario}Test.php form above).it() / test() description states the scenario in plain language and matches what the body actually asserts. Examples of matching descriptions: it('returns zero for an empty cart'), test('throws InvalidArgumentException when the discount is negative'). Examples of non-matching descriptions that violate this rule: generic placeholders (it('it works'), test('test1'), test('happy path')), descriptions that name the method instead of the scenario (test('calculate'), it('handles getUser')), or descriptions that contradict the assertions (it('adds user') over a body that asserts removal). When changing what a test asserts, rename the description in the same change.it() / test() description already states the scenario, arrange-act-assert already states the shape, and the assertions already state the expectation — so a comment narrating the setup, labelling the three phases (// arrange), or paraphrasing the description is pure noise. Do not write it, and delete it when you find it in a test you are already changing, per @rules/php/core-standards.mdc Documentation.@skills/code-review/SKILL.md "Test organization" Core Analysis bullet for the severity matrix and Suggested Fix template.php artisan test --parallel or pest --parallel).if, switch); split conditional logic into separate test cases or data providers instead.build.xml/phing.xml; fall back to a Composer script in composer.json such as test:coverage or coverage). Do not assume a default command.## Coverage section with the tool, exact command, and the uncovered-line list;## Coverage section with the reason in place of a result.
When every changed line is at 100% coverage and the tool ran successfully, omit the ## Coverage section entirely, omit the Coverage: header line, and omit the coverage … slot from the final summary line. The CR is "clean" on the Counts line and the omission is the signal that coverage is satisfied — never emit 100% / clean / n/a placeholders for the section, the header line, or the summary slot. The coverage check itself still runs unconditionally on every CR; only the user-visible reporting is short-circuited.build.xml/phing.xml) over Composer scripts (composer.json).@rules/php/core-standards.mdc PHP Practices).How can I help you explore Laravel packages today?