← All posts
2 min read

692 tests passed, then two words in the live form saved as []

A numeric csv-list swallowed `pinned, comment` and persisted an empty array. The suite never saw it because the fixture supplied data in the shape the code expected.

The suite was green. 49 files, 692 tests, zero new type errors. Then I typed two words into the deployed form and they saved as an empty array.

The field was keywords. It was declared as a numeric csv-list — a comma-separated list that only accepts numbers. So pinned, comment parsed to [], and an empty array is a perfectly valid value. Nothing threw. Nothing logged. The field just quietly forgot what I typed.

Why the tests were fine with it

Every layer below the input did exactly what it was told:

  • The schema said "numbers only", and the values weren't numbers, so the parser returned nothing.
  • "Nothing" is a legal value, so the save path had nothing to reject.
  • The golden fixture supplied keywords in the shape the code expected, so the roundtrip matched.

That last point is the trap. A fixture is written by the same person who wrote the parser, from the same mental model. It confirms the code agrees with itself. It cannot confirm the code agrees with a human typing into a text box.

This is not a coverage problem. 692 tests is plenty. It is a shape-of-input problem: the only place where real input shape appears is the browser.

The fix

The field became a string csv-list. Strings pass through untouched, which is what a keyword list actually is:

// shape of the change, not the literal diff
// before: numbers only — "pinned, comment" parses to []
{ type: "csv-list", itemType: "number" }

// after: strings survive, gated by a parity assertion
{ type: "csv-list", itemType: "string" }

Fixed in b77ec406, and it now has a parity assertion so the field cannot silently drift back to numeric. Deployment was confirmed by checking the shipped chunks for strings that only exist in the new build — Emphasised Words, within reading limits, never, ship.

That last step matters more than it sounds. A green pipeline and a deployed build are different facts, and I have been wrong about which one I was looking at before.

What I changed about my process

Unit tests and type checks answer "did I break the code I can see". They cannot answer "does this field accept the thing a person will type into it". Only the real UI can.

So the last gate before calling something done is driving the deployed build with a browser and touching the exact input a user would touch. Not a component test, not a fixture — the deployed build, through Playwright, as an anonymous user.

The thing that makes this bug class expensive isn't the fix. It's that the fix took ten minutes while finding it took a live session, because everything the repo could tell me said the field was working.


This is part of a build-in-public log: every post starts from a commit, a failure, or a measurement — not from a topic I felt like writing about.