Build gates instead of unit tests
accepted
Context
This repository has no unit tests. It has four scripts that fail the build.
That is a position I have to be able to defend, because "we don't write tests" is usually a confession. My argument is narrower than it sounds: for the bugs that actually ship in frontend work, a test suite is often testing the wrong layer.
The bugs that reach production in the UI work I do are rarely a function returning the wrong number. They are a physical CSS property that inverts in the other reading direction, a translation key that exists in one locale and not the other, a contrast ratio that drifted below 4.5:1 when someone adjusted a token, a bundle that quietly grew past its budget. None of those are unit-testable in any natural way, and all of them are trivially scriptable.
Options considered
Vitest plus Testing Library. The default. It would cover the colour maths well and the direction bugs not at all.
Playwright end-to-end. Would catch rendering regressions, but slowly, and with a maintenance cost I do not want on a site with four routes.
Assertion scripts wired into one verify command. Each one encodes a rule I would otherwise be
enforcing in code review, from memory.
Decision
One chained command:
verify = build && lint && typecheck
&& check:style && check:direction && check:contrast && check:adr
check-direction.mjsfails on any physical Tailwind utility that has a logical twin, unless explicitly prefixedrtl:/ltr:.check-contrast.mjsruns the real colour engine — the same TypeScript the browser executes, vianode --experimental-strip-types, not a compiled copy that could drift — against every preset seed in both schemes, and asserts every role pair reaches its WCAG target. 144 pairs.check-style.mjsenforces the two formatting rules I actually care about — no semicolons, single-quoted module specifiers.check-adr.mjsenforces locale parity across every decision record, a fixed section order, and that no unfinished prose ships: aTODOin anacceptedrecord is an error, while adraftmay carry them and is excluded from the index and from static generation entirely.
The colour gate does more than check thresholds. It validates the OKLCH conversion against published CSS Color 4 reference values before anything is built on top of it, round-trips ten hues losslessly, and re-measures every emitted token against its reported ratio — so the engine cannot report a number it did not ship.
Tradeoffs accepted
- These gates cannot catch logic bugs. Nothing here would notice if the cart summed incorrectly. That is a real hole and I know exactly where it is.
check-directiongreps source text. A class name assembled at runtime is invisible to it. I keep class strings literal rather than weaken the check.- No regression safety net for refactors. A test suite lets you rip out an internal and know the behaviour held. I do not have that, and on a larger codebase I would want it.
- Every gate is one I wrote, so every gate can be wrong. One of mine was: I asserted that white-on-white was uncorrectable. It is not — the foreground is free to darken to black and reach 21:1. The engine was right and my test was wrong.
Result
Four gates, all green, and each has already caught something real. The direction gate finds four violations in a single line of my own earlier code. The contrast gate found that I had a ground being contrast-corrected as though it were a foreground.
What I would do differently
I would write each gate before the code it governs. I wrote components first and the direction
gate after, then went back and fixed what it found. Writing it first changes how you type — knowing
the check exists means reaching for ms- instead of reaching for ml- and cleaning up later.
I would have added the formatter on day one. I started with no Prettier at all, on the reasoning that a sixty-line script enforcing two rules beats a dependency that makes a hundred decisions for you. That reasoning is fine and the conclusion was still wrong: my editor formats on save with Prettier's defaults, so every file I touched came back with double quotes and semicolons and broke my own gate. Two tools were fighting and the one without a config file was always going to lose.
The resolution is not to pick one. Prettier now formats, with a .prettierrc matching the house
style, and the gate still runs in CI — because a formatter someone forgot to run is not a guarantee,
and a guarantee that reformats nothing is not a formatter. They do different jobs. I was treating
them as competitors because I wanted the story to be tidier than the situation was.
I would not generalise this position. This is the right shape for a small, visual, static site where the failure modes are cross-cutting rather than algorithmic. On a product with real domain logic — pricing, permissions, inventory — I would write unit tests without hesitation and keep the gates as well. They solve different problems, and presenting them as alternatives is a rhetorical convenience, not an engineering truth.