Appearance
Testing
Minitest with fixtures. No RSpec, no FactoryBot.
bash
mise exec -- bin/rails test # everything
mise exec -- bin/rails test test/models # a directory
mise exec -- bin/ci # style, security, testsLayout
| Directory | Covers |
|---|---|
test/models, test/lib | Domain logic |
test/controllers | Requests, authorisation, rendering |
test/policies | Authorisation predicates |
test/helpers | Presentation mapping |
test/invariants | Product rules that must hold everywhere |
test/design_audit | Real-browser screenshots and Turbo interaction, skipped unless DESIGN_AUDIT=1 |
Do not write down what the code does
Write the assertion you would have written before reading the implementation. A test that records observed output is not a test — it is a lock, and if the code is wrong it locks the wrongness in.
This has happened five times in this codebase:
| Asserted | Should have been |
|---|---|
Both screening gates clear when no screening had run | not_evaluable — nobody looked |
match_count: 0 for a PENDING_REVIEW body | nil — the provider had not answered |
403 for another account's record | 404 — 403-vs-404 enumerates tenants |
medium_max == scale_max is valid | Rejected — it makes the high band unreachable |
A risk_level with no risk_level_updated_at is published | Withheld — the column defaults to low |
They share a mechanism: the case had no obviously right answer, so the value the code produced got written down as the expectation. Code and test then agreed — which is exactly why mutating a single file never caught them. The test was wrong in the same direction.
The rule for this domain: whenever an assertion says a check passed, a count was zero, a score banded or a record was clean, ask what was actually read to justify it. If the answer is "nothing", the expected value is an absence — nil, not_evaluable, "not assessed" — never a reassuring one.
Invariants
test/invariants/ states product rules as executable assertions that walk the real registries — every gate, every public-result adapter, every provenance value — so a component added tomorrow is covered without anyone remembering.
Currently held. never_claim_clear_test.rb carries the core family — the product principle applied layer by layer:
- No gate reports a clean outcome from evidence nobody read, and neither does the person readout
- No provider adapter reports a positive result from a payload that says nothing
- A screening that has not answered has no match count
- A nil score never bands, under any configuration
- No provenance lets a below-neutral score through from an unevidenced source
- No valid tier configuration makes a band unreachable
- A date of birth is never held or sent in the wrong calendar
Each remaining file states one principle:
| Principle | File |
|---|---|
| A date in the wrong calendar is not a date of birth, on any surface reporting one | a_date_in_the_wrong_calendar_is_not_a_date_test.rb |
| A retry replaces what it retried — a fixed failure stops blocking, a new one starts | a_superseded_run_is_not_a_current_one_test.rb |
| A check offered is a check that can run in that country | a_check_offered_is_a_check_that_can_run_test.rb |
| A contract is bound to the document that was shown | a_contract_is_bound_to_the_document_shown_test.rb |
| An unknown price is never zero | an_unknown_price_is_never_zero_test.rb |
| The API never claims a verification that never happened | api_never_claims_clear_test.rb |
| A country scope that is advertised is a country scope that is enforced | country_scope_is_enforced_test.rb |
| A check nobody can configure is a check nobody runs | every_check_reaches_the_country_defaults_test.rb |
| Every permission is filed and named, in every language | permission_catalog_covers_every_permission_test.rb |
| A setting the code supplies is never a setting an operator has to | required_config_is_obtainable_test.rb |
| The role catalogue is a compliance artefact, and no role is both maker and checker | role_catalogue_test.rb |
| Screening never searches nothing and calls it clean | screening_never_searches_nothing_test.rb |
| Signing is never offered where it cannot complete, and unresearched rules are never reported as none | signing_is_never_offered_where_it_cannot_complete_test.rb |
| A value an operator can set is a value the request carries | settings_reach_the_request_test.rb |
See test/invariants/README.md, which is the index these are kept against.
A vacuous invariant is worse than none
Three of these have shipped in a state where they asserted nothing — a loop over a collection that was empty, and a guard that early-returned when it found a file on disk. Each passed while proving nothing.
Rails reports "Test is missing assertions" for the loop case; the early-return case reported nothing at all. When writing one, check that it goes red for the right reason and that it executes assertions at all.
The first run of that suite found a live bug nobody had reported: low_max could be set below the bottom of the scale, leaving the low band unreachable.
When you fix a bug whose test agreed with it, add an invariant rather than only correcting the case.
Mutation-check anything load-bearing
Break the implementation deliberately and confirm the test fails. A green invariant proves nothing until you have seen it go red — one gate invariant here looked correct and was empty, because its fixture tripped an earlier guard and never reached the code under test.
Tests that pass for the wrong reason
Two, both found on the same branch, both invisible on a developer machine.
Never remove a method you did not define
A test stubbed Entity#create_periodic_review! by defining an override, then called remove_method in its ensure to clean up. The production method is defined directly on Entity rather than in a module, so removing the override removed the only definition — for the rest of the process.
Every later test in the same worker that reached SchedulePeriodicReviewsJob then failed with "undefined method create_periodic_review! for an instance of Entity", and which ones failed depended on the random seed. A passing order exists, which is why nobody had seen it.
Stub through a subclass or a prepended module, or restore the original UnboundMethod — never delete a method the test did not create.
A connection built by hand must carry the whole config
A test opened its own Postgres connection with the host, port and user from the database config, and not the password. Local Postgres trusts local users, so it passed on every machine; CI's asks for a password, and it failed there with "no password supplied".
If a test must connect directly, pass every credential field the config carries — or better, go through ActiveRecord and let it assemble the connection.
The shape both share
Each depends on something true of this machine and false of the others: a test order, a trust setting. "Green locally, red in CI" is the symptom, and the cause is almost never the code under test.
Fixtures
Descriptive names, never one / two. Watch for fixtures that encode a bug: four of the five cases above were fixture-level, including one that reused Elm's returned birth date as the subject's stored claim, conflating what we send with what comes back.
Design audit
bash
DESIGN_AUDIT=1 mise exec -- bin/rails test test/design_audit/Boots headless Chromium against a disposable seeded database and writes full-page screenshots at 1280px and 390px into tmp/design_audit/. Useful for reviewing a visual change across every screen at once.
Known flake
This suite fails intermittently — roughly one run in three at times — and the cause is not yet identified. If you see a failure here, re-run before investigating the change you just made.