DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Five URLs our test suite demands we never publish, and the two ways a published page can vanish with the build still green

Munchable publishes 461 question pages: one per ingredient and condition we have an answer for, each at the site root, each statically generated at build time from one array. The index groups them by condition.

They are generated, which is the nice part, and they are generated, which is the problem. Nothing in that pipeline can fail when a page stops existing.

Why a missing page is silent

The route is a dynamic segment with dynamicParams = false and dynamic = 'error', so the only URLs that exist are the ones generateStaticParams returns. If an entry drops out of the source array, here is the complete list of things that happen:

  • generateStaticParams returns one fewer param
  • the sitemap contains one fewer URL
  • the index page renders one fewer link, because it is built from the same array
  • the build succeeds

No type error. No failed render. Not even a broken internal link, because every link to the page came from the list that lost it. The only evidence is a URL that answered 200 last month and answers 404 today, and you learn about that from Search Console, weeks later, after the ranking is gone.

For the coeliac pages this is a live risk rather than a theoretical one, because that set is built differently from the others. The other pages are keyed off the rule maps directly. These start from a list of label words, each of which has to resolve through our ingredient data to reach an entry. There are two separate joints in that chain, and both can come apart without a single error:

  1. The word stops being a real id. A typo, or an id renamed in the data, and it resolves to nothing.
  2. The word stops reaching an entry. The id is fine, the data has been re-keyed, and nothing joins them any more.

Either way, the page silently does not get generated.

Asserting that URLs exist

So the list of words is treated as a promise, and the test file reads the promise back in layers. Each layer fails with the word that broke, because a failure that only says the count went down by one tells you nothing:

it('every promised label word is a real taxonomy id', () => {
  const failures = COELIAC_LABEL_WORDS
    .map((word) => canonicalizeTag(`en:${word}`))
    .filter((id) => !isKnownTag(id));
  assert.deepEqual(failures, []);
});
Enter fullscreen mode Exit fullscreen mode

Then: every promised word reaches the data. Then: every promised word has a page, and the page actually answers something rather than coming out as unknown. A page that renders with no answer on it is worse than a 404, because it gets indexed.

assert.deepEqual(failures, []) rather than assert.equal(failures.length, 0) is a small thing that pays for itself every time it fires: the diff is the list of words that are broken.

Asserting that URLs do not exist

This is the half I had not written before and would now write again.

The page generator is mechanical: it takes an ingredient and a condition and builds a question. The word list, however, contains words that are in there because labels print them, not because anybody would ask about them. Run the generator over the whole list and you publish:

  • /is-gluten-gluten-free
  • /is-cereals-containing-gluten-gluten-free
  • /is-gluten-free-oats-gluten-free
  • /is-gluten-free-wheat-fiber-gluten-free
  • /is-backoblate-gluten-free

Four of those are their own answer. The fifth is a label word almost nobody outside a German bakery aisle has read. None of them is a question, and all five would be real pages, in the sitemap, asking Google to rank us for a tautology.

The exclusion is an editorial decision in a mechanical pipeline, which is exactly the kind of decision that gets refactored away by somebody tidying up a filter six months later. So the test pins it:

it('the words that are their own answer get no page at all', () => {
  const slugs = new Set(ANSWER_PAGES.map((p) => p.question));
  for (const slug of [
    'is-gluten-gluten-free',
    'is-cereals-containing-gluten-gluten-free',
    'is-gluten-free-oats-gluten-free',
    'is-gluten-free-wheat-fiber-gluten-free',
    'is-backoblate-gluten-free',
  ]) {
    assert.equal(slugs.has(slug), false, `${slug} should not be published`);
  }
});
Enter fullscreen mode Exit fullscreen mode

You can check both halves from outside. /is-semolina-gluten-free and /is-tamari-gluten-free are 200. /is-gluten-gluten-free is a 404 and is meant to be.

The slug is a URL, the name is copy

One more assertion in that file, three lines long, guarding something I would otherwise have broken within a month:

const entry = INGREDIENTS.find((i) => i.tag === 'en:brewer-s-yeast');
assert.equal(entry?.name, "Brewer's yeast");
// The slug is a published URL and must not follow the name.
assert.equal(entry?.slug, 'brewer-s-yeast');
Enter fullscreen mode Exit fullscreen mode

The display name carries a real apostrophe, because that is how a label prints it and the page should read like English. The slug is ASCII with a hyphen where the apostrophe was, and it is published: /is-brewer-s-yeast-gluten-free.

Those two fields want to be one field. Every instinct says derive the slug from the name. Do that and the day somebody improves a display name is the day a ranked URL moves and the old one 404s. Asserting both values, with the comment explaining why they differ, is cheaper than remembering.

Asserting on generated copy

The last few tests in the file check the words of the output, which normally I would not do:

assert.match(facts('semolina'), /reads semolina as wheat/);
assert.match(body('oat'), /grown, milled and carried alongside wheat/);
// Inherited words take the reason of the entry that settled them.
assert.match(body('oat-bran'), /grown, milled and carried alongside wheat/);
Enter fullscreen mode Exit fullscreen mode

The defence is that this is not prose, it is output. The sentence on each page is generated, and the thing being asserted is a property of the generator: that a word which got its answer from a more general entry says which entry settled it, and that each word with a genuinely uncertain answer carries its own reason rather than one generic line repeated down the list. Both of those are what the page is for. If they regress, every page regresses at once, and nothing else in the build would notice.

That is the theme of the whole file, really. A generated URL set needs tests about its shape, because the framework will cheerfully build you a smaller site than it built yesterday and tell you it went fine. The 461 answers are here, and the reference list that links into them is where a missing page would be most obvious, since every word on it is a link.

Top comments (0)