Skip to content

Test hygiene — derive test literals from source-of-truth constants

Filed under issue #1089. Two main-red incidents in short order came from the same class of bug: a source-of-truth constant (vocab, enum, seed CSV row set) grew legitimately in one PR, and a test assertion in a different file encoded the old shape as a bare integer literal. This doc codifies the pattern that keeps the assertion in lock-step with the vocab.

The rule

When a test asserts a count that reflects a domain vocabulary, enum, or seed-data set, import the source-of-truth constant and derive the count.

js
// Before — brittle. Vocab grows in PR A, test breaks on main after merge.
import { describe, it, expect } from 'vitest';

it('returns 13 tags', () => {
  expect(items).toHaveLength(13);
});

// After — assertion tracks the vocab. Growing PROFILE_VOCAB simultaneously
// bumps items.length AND the expectation. CI stays green.
import { KNOWN_TAGS } from '../../srv/lib/homepage/persona-tag-validator.js';

it('returns one row per KNOWN_TAG', () => {
  expect(items).toHaveLength(KNOWN_TAGS.length);
  expect(new Set(items.map((r) => r.tag))).toEqual(new Set(KNOWN_TAGS));
});

Source-of-truth constants (July 2026)

These are the modules to import from — each holds the canonical shape and should be reused rather than duplicated in tests, docs, or fixture files.

DomainConstantModule
Profile vocab (role, cloud, deployment, region)PROFILE_VOCAB, PROFILE_FIELDSsrv/lib/branch/profile-fields.js
Persona-tag namespace (derived from PROFILE_VOCAB)KNOWN_TAGSsrv/lib/homepage/persona-tag-validator.js
Homepage verb registryVERB_DEFAULTS, VERB_KEYS_SORTEDsrv/lib/homepage/verb-shelf-defaults.js
Homepage shelf registrySHELF_DEFAULTS, SHELF_KEYS_SORTEDsrv/lib/homepage/verb-shelf-defaults.js
Persona → verb-order mapBASE_ORDERsrv/lib/homepage/persona-map.js
Cross-corpus render cap in KG neighborhood mergeMAX_OTHER_RESOURCESsrv/lib/kg-neighborhood-merge.js
KG corpus type/priority tableRESOURCE_TYPE_CONFIGsrv/lib/kg-resource-type-config.js
Live event-type registryEVENT_TYPESsrv/lib/events/index.js
AuthorService analytics entity picker (curated set)AUTHOR_EXPOSED_ENTITIESsrv/lib/author-exposed-entities.js

If your domain isn't listed and you're about to hardcode a count of N in a test where N reflects the shape of a module-level array/enum, promote the constant first, then derive.

What NOT to convert

Not every integer literal in a test is a candidate. Leave these alone:

  • Fixture counts — the test itself seeds 3 rows, the assertion checks 3. The count is local to the file and doesn't move.
  • Function-signature contractsmw.length === 3 (Express middleware arity), func.length === 2.
  • Column-width / truncation capsslug.length === 200, message.length === 2000. These encode DB-level pins; the test is the drift guard against schema drift.
  • Explicit drift guards — where the test's purpose is to freeze the cardinality (e.g. "predicate → count-field mapping has exactly 9 keys", "@assert.unique enum has exactly these three region values"). The hardcoded literal is the drift signal.
  • Historical row counts — "3 LegacyRedirects seeded in the migration from IMS". Anchored to a one-time backfill; hardcoding is intentional.

When in doubt, add a comment (// FROZEN — this cardinality is the contract) so the next audit doesn't sweep it up.

Optional linter guard

An ESLint / Vitest-beforeAll rule could flag files that:

  1. Import a symbol matching /^KNOWN_/, /_VOCAB$/, /_DEFAULTS$/, /_FIELDS$/, /_TAGS$/, or /_KEYS(_SORTED)?$/,
  2. AND contain toHaveLength(<int>) or .length).toBe(<int>) where <int> > 0.

Weak signal — many co-imports are unrelated to the assertion — but cheap to add and catches the exact PR shape that motivated issue #1089.

Audit log

The full test/ + srv/__tests__/ sweep for issue #1089 ran 2026-07-11 (~440 toHaveLength(<N>) + ~124 .length).toBe(<N>) occurrences scanned). Nearly all are safe (fixture counts, toHaveLength(0), HTTP status arrays, column-width caps, embedding-vector widths, pagination/batch sizes). The converted candidates:

FileWasNow derives from
test/integration/homepage/persona-tag-choices.test.jstoHaveLength(13)KNOWN_TAGS.length (PR #1088)
test/unit/srv/admin-service-explainer-autoinit.test.jstoBe(7) / toBe(4)VERB_DEFAULTS.length / SHELF_DEFAULTS.length (prior #1089 work)
test/hybrid/{verb,shelf}-definitions-crud.test.jstoBe(7) / toBe(4)VERB_DEFAULTS.length / SHELF_DEFAULTS.length (prior #1089 work)
test/unit/srv/admin-service-explainer-actions.test.jstoBe(7), toBe(5)VERB_DEFAULTS.length, VERB_DEFAULTS.length - 2
test/unit/author-service-analytics.test.jstoHaveLength(9)AUTHOR_EXPOSED_ENTITIES.length
test/hybrid/617-author-tutorials.test.jstoHaveLength(9)AUTHOR_EXPOSED_ENTITIES.length

Deliberately left as frozen drift guards (converting would defeat their purpose — see "What NOT to convert"):

  • test/unit/kg-graph-rebuild-predicate-counts.test.jstoBe(9) on the PREDICATE_TO_COUNT_FIELD map size is itself the canary that fails CI when a projection predicate is added without a count column.
  • Issue #1089 — audit + convert
  • PR #1088 — the fix that motivated this pattern
  • Issue #1043 — CSV-column parallel gotcha (different mechanism, same class)
  • cap-cds-gotchas.md — general CAP/CDS test-hygiene guidance