Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

ADR-0012: Strict mode is verified in close(), never in the destructor

Status: Accepted · Reference: PLAN.md §7 decision 42

Context

StrictMode::AllPlayed fails a test whose cassette holds interactions nothing asked for; InOrder fails when replay departed from the recorded sequence. Both are verdicts about a finished run, so they can only be checked at the end.

“The end” is ambiguous in PHP. There is the moment the test method returns, and there is the moment the garbage collector reclaims the client — which may be during another test, during shutdown, or during the handling of an unrelated exception. Throwing from __destruct() at those moments produces failures attributed to the wrong test, or fatal errors that swallow the real one.

Decision

close() releases the lock and checks strict mode. __destruct() releases the lock and reports — the secret scan’s findings, a forced recording a lock made a no-op, the interactions nothing replayed — but asserts nothing. InOrder is implemented as monotonicity of recorded positions, evaluated at close.

That split is what lets the same finding exist in both forms: interactions nothing asked for are reported by default from the reporting half, and become a failure under AllPlayed from the asserting half. A report can be emitted at a moment nobody chose, because it names its own cassette and joins the run’s block of warnings rather than being attributed to whichever test was running.

The PHPUnit bridge closes from an #[After] method in the trait; the Test\Finished subscriber is only a backstop for clients the trait never saw.

Consequences

Good. An assertion fires at a moment the test chose, and is attributed to the test that caused it. The lock is always given back regardless — including when the strict-mode assertion itself fails, which has its own test. Nothing depends on collection timing.

Bad. A test that never calls close() and does not use the bridge gets no strict-mode check at all — it still gets the report, which is the half that survives the destructor. Silently skipping a requested assertion is unpleasant; the alternative was raising it from a destructor, which is worse in every way that matters.