Case file TS5

Structural Assertions

Hardcoded slugs are brittleness. Discover from the sitemap.

A test that hardcodes /tenet/intention-revealing-names commits a single bit of content as if it were the contract. Assert on the shape the user meets, and discover the URLs from the artefact that already lists them.

ByAdam LewisPublished3 May 2026Revised13 Oct 2026Reading11 minVersionv1.2ConfidenceHigh
§0b

Opinion

I've watched too many test suites die the same death: an editor renames a heading, forty E2E tests go red, the team blames Playwright and the next person to touch a slug is flagged for breaking CI. The bug is in the test. A test that asserts “the H1 says Best Providers” on a page whose content the editor owns is a coupling contract the test never had the right to sign.

The structural half is non-negotiable. Use the role tree the way a screen reader does. Testing Library's priority list ranks getByRole, getByLabelText, getByText in that order for a reason.1 Demoting an h1 to an h2 is a meaningful change. The test should fail. Renaming the page from Best Providers to Top Providers is not. The test should still pass. A role query that asks for the heading level, not its text, draws that line.

Fewer sources publish the discovery half. Pa11y CI, an accessibility test runner, ships it as a flag: pass --sitemap and it tests every URL the sitemap lists.2 A discovery test asks the live build “what URLs do you serve?” and walks every one of them. New page goes live, new page gets tested, no PR opens to add a slug to a fixture. The static-page exception is the carve-out: /about-us, /privacy-policy, /contact exist because the architecture says so, not because some row in a database said so. Their slugs are the contract. Hardcoding them is the right answer. Layer 2 of the case file walks through that exception in detail so reviewers stop asking the question on every PR.

Copy a note and link

Grab this short comment and drop it into a PR comment or an LLM chat to prompt the right change.

Locate by structure, not content. `getByRole` first, `getByText` only when role-based queries can't reach. Discover dynamic pages by fetching the sitemap. Never hardcode a slug that comes from a database. Static-page paths (compliance, marketing, contact) are part of the contract: those keep their hardcoded paths.

/tenet/structural-assertions/TS5
Copy for paste
§0c

AI eyes only

Rule: locate by structure, not content. Discover dynamic pages from the sitemap.

Reject: getByTestId as the first locator. Reject: cy.get('.btn-primary') style class selectors. Reject: hardcoded slugs in test fixtures for content that changes.

Generate: getByRole first; getByText only when role-based queries cannot reach. For dynamic pages, fetch /sitemap.xml at test time and iterate.

Diagnostic: every selector must survive a content rename. Every dynamic-content URL must come from the live sitemap, not a fixture.

§0d

Why?

  • New pages test themselves. Add a tenet, add a versus row, publish a pillar page: the next CI run iterates over the new URL the moment /sitemap.xml serves it. No fixture file to update, no PR to remember.
  • Copy edits stop breaking tests. Editor renames an H1 from Best Providers to Top Providers: the role-based locator still passes. The test suite stays green; QA stops chasing edits they had no part in.
  • Accessibility improves as a side effect. getByRole('heading') traverses the same role tree a screen reader uses. If the test passes, the page is at minimum navigable.
  • Review time falls. The reviewer doesn't have to ask “does this test still match the live page?”: the test asks the live page itself.
  • Coverage scales linearly with pages. One test that walks the sitemap covers every URL it lists, with no edit as the site grows. The same shape works for 10 or 10,000.
  • The static-page exception keeps compliance honest. /privacy-policy, /contact, marketing landing slugs: tests fail loudly if any contract-bearing path disappears. TS4 Real-Dependency E2E is the data-side enabler.
  • Coding agents stop hardcoding selectors. With the rule in CLAUDE.md and a lint guard against getByTestId, the model writes getByRole first because that's the cheapest way to pass review.
The receipts
Origins, quoted passages, evidence, the strongest counter-argument and the reply.
§1

Origins

The structural half starts in the Testing Library docs. Their “Which query should I use?” guide ranked the queries from February 2019, with getByTestId last, and moved getByRole to the top in May 2020. The current docs keep that order: use getByRole first, fall back through getByLabelText, getByText, and reach getByTestId only when nothing semantic exists.1 Kent C. Dodds's Common Mistakes with React Testing Library (2020) sends readers to that guide, and it made the locator priority list the de-facto standard for component testing.3 Marcy Sutton's 2019 Frontend Masters workshop made the rule editorially obvious: the role-based locator queries the same tree a screen-reader queries, so writing the test correctly improves accessibility as a side effect.

Microsoft Playwright's locator API ports the same priority order to E2E, explicitly ranking role locators first. The locator is the contract. A content-based locator signs one the test never agreed to.6

The discovery half has shallower published lineage. Pa11y CI has taken a --sitemap argument since 2016: it retrieves the sitemap and tests every URL in it.2 Sitemap.org's protocol, the boring XML format every public site already publishes for SEO, supplies the discovery surface for free.7 The Storybook interaction-tests pattern is the cousin: discover stories at build time from *.stories.tsx globs rather than runtime fetch, but the instinct is the same: don't hardcode the input set when something else already lists it.

The static-page exception comes from the standards literature rather than a single named source. Compliance pages and marketing pages are stable contracts in product-architecture vocabulary: their URLs appear in legal documents, footers, ad copy. The PUP standards/automation-testing.md file owns the cleanest formulation of when hardcoding is correct and the Layer 2 sidebar lifts it into the canon.

§2

Quotes

The more your tests resemble the way your software is used, the more confidence they can give you.

Testing Library · Guiding Principles

Implementation details are things which users of your code will not typically use, see, or even know about.

Kent C. Dodds · Testing Implementation Details (2020)
§3

Evidence

14 external sources, ranked by author authority. The first five are the canon; expand to see the rest, including the qualifiers and the named opposers. Each links out to its primary source.

  1. 01
    Kent C. Dodds · 2020
    The most-cited source for the locator-priority rule. Names getByTestId and container.querySelector as anti-patterns, and argues for querying by the actual text.
  2. 02
    Testing Library maintainers · 2018 onwards
    The official priority list: getByRole, getByLabelText, getByPlaceholderText, getByText, getByDisplayValue, getByAltText, getByTitle, then getByTestId. The rule is documented, not folkloric.
  3. 03
    Microsoft Playwright team · 2021 onwards
    Microsoft Playwright recommends role locators first and user-facing locators over CSS and XPath in E2E. The rule scales beyond unit tests.
  4. 04
    Marcy Sutton · 2019
    The bridge piece that made the rule editorially obvious: querying by role traverses the same tree a screen reader does. Accessibility improves as a side benefit.
  5. 05
    Kent C. Dodds · 2020
    Sets the upstream principle the locator-priority list operationalises: tests resemble use. Implementation-coupled selectors are implementation-coupled tests.
  1. 06
    Pa11y · 2016 onwards
    An accessibility test runner for CI whose --sitemap flag retrieves a sitemap and tests every URL in it. Discovery-mode testing shipped as a flag rather than a fixture file.
  2. 07
    Sitemap.org · 2006
    The boring XML format every public site already publishes for SEO. Free discovery surface for the test runner.
  3. 08
    Testing Library maintainers · 2018 onwards
    “The more your tests resemble the way your software is used, the more confidence they can give you.” The axiom underneath the locator priority list.
  4. 09
    Microsoft Playwright Blog · 2024
    Recommends getByRole and other user-facing locators. Warns against CSS and XPath selectors. The current vendor-canonical position.
  5. 10
    Google Search Central · 2010 onwards
    The reason the discovery surface exists. Every public site that wants traffic publishes a sitemap. The test runner gets URL discovery for free.
  6. 11
    Mike Wacker, Google Testing Blog · 2015
    End-to-end tests are slow, flaky and poor at isolating failures, so Google suggests a 70/20/10 split of unit, integration and end-to-end tests as a first guess. The budget case against broad E2E.
  7. 12
    Storybook · 2022
    Build-time discovery from *.stories.tsx globs rather than runtime fetch. Same instinct (don’t hardcode the input set), different surface.
  8. 13
    MDN Web Docs · 2010 onwards
    The role taxonomy getByRole traverses. Documents which roles exist, which roles have implicit HTML mappings, and which require explicit aria-* attributes.
  9. 14
    Cypress (community) · 2018
    The data-attribute school’s case for explicit test IDs. Worth reading for the steelman of the structural-vs-explicit debate.

Fourteen sources, two halves. The locator-priority literature (Kent C. Dodds, Testing Library, Playwright, Marcy Sutton) is canonical: this is the convention the testing libraries follow around. The sitemap-discovery literature is thinner and lives further down. The dissent is Cypress's case for dedicated data-cy attributes and Google's case for fewer end-to-end tests. The reply addresses both.

§4

Examples

Viewing: TypeScript.
Avoid

Before hardcoded slug. The next rename breaks a test that asserts no behaviour.

Filetenet-pages.spec.ts
test("single-responsibility-principle/F1 renders", async ({ page }) => {
  await page.goto("/tenet/single-responsibility-principle/F1");
  await expect(page).toHaveURL("/tenet/single-responsibility-principle/F1");
  await expect(page.getByText("Single Responsibility Principle")).toBeVisible();
});
Prefer

After discover the URL set from the sitemap; assert structural shape.

Filetenet-pages.spec.ts
test("every tenet page exposes an h1 and a /tenet/{slug}/{id} URL", async ({ page, request }) => {
  const urls = await tenetUrlsFromSitemap(request);
  expect(urls.length).toBeGreaterThan(0);
  for (const url of urls) {
    await page.goto(url);
    expect(page.url()).toMatch(/\/tenet\/[\w-]+\/F\d+$/);
    await expect(page.getByRole("heading", { level: 1 })).toBeVisible();
  }
});
§4b

Enforcement

Viewing: TypeScript.

Apply these rules in eslint.config.mjs. The full enforcement across every tenet lives on the implementation page.

RuleToolCatches
testing-library/prefer-screen-querieseslint-plugin-testing-librarydestructured query helpers like getByRole: forces tests through screen, which keeps the locator priority list traceable.
testing-library/no-containereslint-plugin-testing-librarycontainer.querySelector usage: the canonical content-coupling escape hatch the priority list rules out.
testing-library/no-node-accesseslint-plugin-testing-libraryDOM walking via .firstChild, .parentNode, .children: implementation-detail traversal posing as a query.
testing-library/prefer-query-by-disappearanceeslint-plugin-testing-librarygetBy*/findBy* queries passed to waitForElementToBeRemoved, where a queryBy* query gives the clearer failure.
jest-dom/prefer-in-documenteslint-plugin-jest-domexpect(element).toHaveLength(1) and similar: pushes assertions toward the structural toBeInTheDocument matcher.
playwright/no-element-handleeslint-plugin-playwrightpage.$('css-selector'): the discouraged, content-coupled E2E entry point. Forces locator-based discovery.
playwright/prefer-web-first-assertionseslint-plugin-playwrightexpect(await locator.isVisible()): bypasses the polling assertion that makes structural locators reliable.
eslint.config.mjsconfiguration snippet
import { defineConfig } from 'eslint/config';
import tseslint from 'typescript-eslint';
import testingLibrary from 'eslint-plugin-testing-library';
import jestDom from 'eslint-plugin-jest-dom';
import playwright from 'eslint-plugin-playwright';

export default defineConfig(
  tseslint.configs.base,
  {
    files: ['**/*.spec.{ts,tsx}', '**/*.test.{ts,tsx}'],
    plugins: { 'testing-library': testingLibrary, 'jest-dom': jestDom },
    rules: {
      'testing-library/prefer-query-by-disappearance': 'error',
      'testing-library/prefer-screen-queries': 'error',
      'testing-library/no-container': 'error',
      'testing-library/no-node-access': 'error',
      'testing-library/no-dom-import': ['error', 'react'],
      'testing-library/prefer-user-event': 'error',
      'jest-dom/prefer-in-document': 'error',
      'jest-dom/prefer-to-have-text-content': 'error',
    },
  },
  {
    files: ['e2e/**/*.{ts,tsx}', 'tests/**/*.{ts,tsx}'],
    plugins: { playwright },
    rules: {
      'playwright/no-element-handle': 'error',
      'playwright/no-eval': 'error',
      'playwright/prefer-web-first-assertions': 'error',
      'playwright/no-useless-not': 'error',
    },
  },
);
§4c

AI rules

File.cursor/rules/ts5-structural-assertions.mdc
---
alwaysApply: true
---

## Prickles TS5: Structural Assertions

Locate by structure, not content. Reach for `getByRole` first, fall back to `getByLabelText`, and only then `getByText`. `getByTestId` is the last-resort escape hatch, not the default.

Discover dynamic pages from the sitemap. Tests for tenet pages, versus pages, and any other data-driven URL fetch the live `/sitemap.xml` and iterate every entry. The fixture file containing slugs is a smell.

Hardcode static-page paths. Compliance, marketing, contact, and feature-deep-link URLs are part of the product contract. Their slugs appear in external collateral. The test should fail if the route disappears.

Decide once: a slug is hardcoded only if marketing, legal, or external links reference it directly. Otherwise discover it.

Repo layout, CI, and ESLint wiring for these paths live on /implementation, not repeated on every tenet.

§5

Counter-argument

Counter

The strongest pushback is the data-testid school. Engineers who release UI to real-world a11y constraints know that ARIA roles change for legitimate reasons (demoting an h1 to an h2 when a page becomes a section, switching nav to menu when the role taxonomy updates) and they read structural locators as a coupling to a moving target. The argument runs: an explicit data-testid attribute holds the test contract. It does not lie when ARIA does.4 A second objection is about budget. Google's testing blog suggests a 70/20/10 split of unit, integration and end-to-end tests, and a test for every sitemap URL is broad E2E by construction.5

§6

Counter-argument retort

Reply

The data-testid argument inverts the priority on purpose. Structural locators break a test when an ARIA change means something and not when it doesn't. Testing Library's docs rank them first for a plainer reason: “your test should resemble how users interact with your code”.1 Demoting an h1 to an h2 changes the document outline: that's a content edit and a screen-reader-noticeable one. The test caught it for the right reason. Adding an unrelated aria-label doesn't change the role tree; the role-based locator still passes. data-testid is the right escape hatch when there's no semantic role. Treating it as the default loses the alignment between “what users perceive” and “what tests assert.”

The objection that E2E is expensive is a budget claim rather than a fidelity one.5 Structural-discovery makes E2E cheaper, not more expensive: every new page tests itself on the next CI run with no fixture to update. The test doesn't check content per page. It checks the page renders, has an h1, and survives a navigation. That's seconds per page, not minutes. Cap the deeply-asserted E2E flows at the buy-flow and the auth-flow. Let everything else ride on structural smoke.

The static-page exception is what stops the rule degenerating into “don't hardcode anything.” Compliance pages, marketing pages, deep links to documented features: these exist because product architecture says so and their URLs are part of the contract. /privacy-policy changing to /legal/privacy is a redirect-at-launch event rather than a content edit. Hardcoding them means the test catches the day someone deletes the route. The Layer 2 sidebar in the case file lays out the four signs a slug is part of the contract: it's referenced in marketing collateral, it's linked from external sites, it's named in the privacy policy or T&Cs, or it predates any data table that could publish it. If any one is true, hardcode it.

The discovery half earns the rule its place. Pa11y CI's --sitemap flag shows a test runner can take its whole input set from the sitemap.2 Pair it with TS4 Real-Dependency E2E, you can only discover from the sitemap if the deployed build serves a real one and the rule is no longer tactical. It's a coupling discipline that scales linearly with the number of pages a product publishes.

§7

Notes

  1. [1]Testing Library maintainers: “About Queries”, the documented locator priority: getByRole, getByLabelText, getByPlaceholderText, getByText, getByDisplayValue, getByAltText, getByTitle, then getByTestId as the last-resort escape hatch.
  2. [2]Pa11y: “Pa11y CI” README. Pass --sitemap and the runner retrieves the sitemap and tests each URL in it; --sitemap-exclude drops URLs that match a pattern. A published test runner that takes its input set from the sitemap.
  3. [3]Kent C. Dodds: “Common Mistakes with React Testing Library” (2020). The canonical statement of the priority rule and the case for treating getByTestId as a last resort.
Disagree? Found a hole in the argument? Take issue with this tenet →
Last revised: 2026-10-13