| name | test-writing |
| description | Test-writing conventions for clear, targeted tests. Activates when: writing a new test, creating a test file, adding test coverage, naming tests, or when user mentions: test, testing, test case, coverage, assertion, unit test, feature test. |
| metadata | {"schema-required":"^1"} |
Test Writing Guidelines
Conventions for writing tests that are specific, descriptive, and well-structured. Treat every test as an executable specification: its name reads as a sentence stating the scenario and expected outcome, so the suite documents intended behaviour while it verifies it. The principles are language-agnostic; the examples use PHP/Pest, but the same rules apply to any test framework.
Which framework to write for
Match the framework to the code under test:
- Backend / PHP code → the test runner your project uses — detect it from composer.json scripts or whether vendor/bin/pest vs vendor/bin/phpunit is present, else follow sibling test files.
- Frontend / JS / TS code → the project's JavaScript test runner. Detect it from
package.json — a test / test:js script, or a vitest / jest / mocha dev-dependency plus its config — else follow sibling test files. Put specs where the existing ones live (e.g. a tests/Js/ directory, or an __tests__/ folder next to the source).
A change to frontend logic needs a JS test the same way a change to backend logic needs a backend test — don't leave JS/TS behaviour uncovered just because the default examples below are PHP. Never write a test in any framework on the project's forbidden list (none — no restriction), or in any framework a listed category alias expands to; if asked to, redirect to the appropriate framework above.
Category alias expansions (a forbidden alias forbids every framework in its set):
| Alias | Expands to |
|---|
js-test-frameworks | vitest, jest, mocha, cypress, playwright |
browser-test-frameworks | cypress, playwright |
php-browser-tests | dusk, panther |
So forbid: ['js-test-frameworks'] refuses a Cypress test even though cypress isn't listed by name — it's a member of the alias set. (This expansion is the schema's canonical alias map; adding an alias is a minor schema bump, removing/redefining one is major.)
Test the Specific Scenario
Don't test general functionality - test the exact scenario that matters:
it('rejects checkout when the cart is empty')
it('handles checkout')
Name Tests Descriptively
Gate: read the name aloud. Does it state the scenario and the expected outcome as one executable-specification sentence? If not, rename before you write the assertions. Test names should describe the scenario and the expected outcome:
it('blocks login after five failed attempts')
it('retries the webhook on a temporary failure')
it('rounds the invoice total to two decimal places')
it('login works')
it('webhook')
it('invoice')
Test Structure
Follow the Arrange-Act-Assert pattern:
it('grants access to a paid account', function () {
$account = Account::factory()->create(['plan' => 'pro']);
$response = $this->actingAs($account->owner)
->get(route('reports.index'));
$response->assertOk();
$response->assertSee('Monthly report');
});
Test one behavior per test. The example above asserts twice (assertOk and assertSee) — that is fine, because both assertions verify the same behaviour (a paid account can reach the report). What to avoid is assertion roulette: piling assertions about unrelated behaviours into one test, so a failure doesn't reveal which behaviour broke. Split those into separate tests. ("One behaviour per test" is the rule — not the common misreading "one assertion per test".)
When Tests Aren't Possible
For behaviour that can't be reproduced with an automated test:
- Document why - explain why automated testing isn't feasible
- Provide manual steps - detailed reproduction steps for QA
- Add defensive code - consider adding validation or error handling
- Log for monitoring - add logging to catch future occurrences