Skip to content
← All decisions

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.mjs fails on any physical Tailwind utility that has a logical twin, unless explicitly prefixed rtl:/ltr:.
  • check-contrast.mjs runs the real colour engine — the same TypeScript the browser executes, via node --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.mjs enforces the two formatting rules I actually care about — no semicolons, single-quoted module specifiers.
  • check-adr.mjs enforces locale parity across every decision record, a fixed section order, and that no unfinished prose ships: a TODO in an accepted record is an error, while a draft may 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-direction greps 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.