Testing Doctrine
Reusable regression-test patterns distilled from shipped phases. The two below were extracted from Phase 299.6 (dead-poll deletion + probe-decoupling), where presence-only tests and a body-only decoupling claim both masked a real BLOCKER that codex passed across multiple rounds.
Structural regression LOCK over presence assertions
Section titled “Structural regression LOCK over presence assertions”Assert that forbidden constructs CANNOT return (grep-style absence over the target code block) rather than asserting current behavior. A structural LOCK is durable against the silent reintroduction of deleted dead code — a future refactor that adds the construct back fails the lock immediately, regardless of runtime behavior.
When to use: After deleting dead or dangerous code that a future refactor could plausibly reintroduce.
Anti-pattern callout: Presence-only “is X gone?” tests actively MASK the defect they should catch. A test that pins the existence of code that should not exist is worse than no test — in Phase 299.6 the tests asserting the dead 5-minute background-poll symbols were “present” actively masked the CR-01 BLOCKER.
Concrete instance: dev-tunnel.test.mjs uses 9 toBe(false) assertions that structurally forbid the return of setTimeout / timer.unref / pollProjectName / pollFunnelUrl / “MCP health via Funnel” / “background check” / “not yet reachable after 5m” / mcpProbe from the target code block. The idiom (schematic):
const src = readFileSync(targetModulePath, "utf8");const block = extractFunctionBody(src, "runDevTunnel");
it("cannot reintroduce the deleted background reachability poll", () => { expect(block.includes("setTimeout")).toBe(false); expect(block.includes("timer.unref")).toBe(false); expect(block.includes("pollFunnelUrl")).toBe(false); expect(block.includes("not yet reachable after 5m")).toBe(false); expect(block.includes("mcpProbe")).toBe(false); // …one assertion per forbidden construct});The lock pins absence over the source block, so it stays green only while the dead code stays deleted — and goes red the moment a refactor silently reintroduces it.
Pure-decision module with an arity lock
Section titled “Pure-decision module with an arity lock”A “should we do X?” decision function takes a single object arg with NO forbidden-input parameter, and its independence from that input is pinned by an arity assertion on the function’s signature — expect(fn.length).toBe(1) — not just on its body. A body-only test can pass while a later edit threads the forbidden input back in; the arity lock makes that structurally impossible without failing the test.
When to use: Any time a decision must be provably independent of a forbidden input — make the input structurally impossible to pass and lock the arity in a test.
Concrete instance: packages/cli/src/tailscale-provision.mjs exposes shouldWritePublicBaseUrl({funnelUrl, hostnameCheck}) — a single object arg, no probe/reachability parameter — pinned by expect(shouldWritePublicBaseUrl.length).toBe(1). The probe-decoupling is enforced by the function’s signature, not just its body:
import { shouldWritePublicBaseUrl } from "../tailscale-provision.mjs";
it("decision is structurally decoupled from any probe", () => { // arity lock: exactly one (object) parameter, so a reachability/probe // argument cannot be threaded back in without failing this test expect(shouldWritePublicBaseUrl.length).toBe(1); expect(shouldWritePublicBaseUrl({ funnelUrl: "https://x.ts.net", hostnameCheck: true })).toBe(true);});See also
Section titled “See also”.planning/phases/299.6-tailscale-auto-tunnel-followups/299.6-LEARNINGS.md— the origin record (Patterns + Lessons sections) for both doctrines above.