Structural Assertions
Hardcoded slugs are brittleness. Discover from the sitemap.
A test that hardcodes /tenet/intention-revealing-names ships a single bit of content as if it were the contract. Assert on the shape the user actually meets, and discover the URLs from the artefact that already lists them.
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“About Queries” — the documented locator priority: getByRole, getByLabelText, getByPlaceholderText, getByText, getByDisplayValue, getByAltText, getByTitle, then getByTestId as the last-resort escape hatch. 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. The locator priority list draws that line.
The discovery half is the part nobody else publishes. Adobe runs E2E across a thousand pages by reading the sitemap at test time;2“How We Test 1,000+ Pages with Playwright”. Adobe’s docs E2E pipeline reads the live sitemap and iterates every URL on every CI run. The reference scale-up for sitemap-driven test discovery. the Prickles suite does the same for tenets, versus pages and pillar pages. The test asks the live build “what URLs do you serve?” and walks every one of them. New tenet ships, new tenet 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
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.
Why?
- New pages test themselves. Add a tenet, add a versus row, ship a pillar page — the next CI run iterates over the new URL the moment
/sitemap.xmlserves 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 lands 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. Adobe shipped one test file across 1,000 pages with sitemap discovery; 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 writesgetByRolefirst because that's the cheapest way to pass review.
Origins
The structural half traces straight back to Kent C. Dodds's Common Mistakes with React Testing Library (2020), which made the locator priority list the de-facto standard for component testing.3“Common Mistakes with React Testing Library” (2020). The canonical statement of the priority rule and the case for treating getByTestId as a last resort. The original Testing Library docs published the same priority list verbatim, with explicit prose: use getByRole first, fall back through getByLabelText, getByText, and reach getByTestId only when nothing semantic exists.1“About Queries” — the documented locator priority: getByRole, getByLabelText, getByPlaceholderText, getByText, getByDisplayValue, getByAltText, getByTitle, then getByTestId as the last-resort escape hatch. Marcy Sutton's 2019 Testing Library + accessibility piece is the bridge that 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 citing Testing Library as the inspiration.6“Locators”. Playwright’s API ports Testing Library’s priority list to E2E and explicitly credits it as the inspiration. Antoine Caron's “Locators are the Public API of Your UI” gave the principle its load-bearing framing: the locator is a contract, and content-based locators sign a contract the test never agreed to.7“Locators are the Public API of Your UI” (2022). The piece that named the framing: a locator is a contract, and a content-based locator signs one the test had no right to sign.
The discovery half has shallower published lineage. Andrew Bredow's 2021 piece “Don't hardcode URLs in your E2E tests” named the principle directly;2“How We Test 1,000+ Pages with Playwright”. Adobe’s docs E2E pipeline reads the live sitemap and iterates every URL on every CI run. The reference scale-up for sitemap-driven test discovery. Adobe's “How we test 1,000+ pages” case study from 2020 demonstrated it at scale, with Playwright reading the sitemap and iterating every URL on every CI run. Sitemap.org's protocol — the boring XML format every public site already publishes for SEO — supplies the discovery surface for free.8“Sitemap Protocol 0.9”. The XML schema every public site already publishes for SEO. Discovery surface for the test runner, free of charge. 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, not 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.
Quotes
The more your tests resemble the way your software is used, the more confidence they can give you.
Implementation details are things which users of your code will not typically use, see, or know about.
The locator is the contract. Pick locators the way you would pick API signatures.
Every hardcoded URL in your test suite is a fixture-file PR you haven't opened yet.
Evidence
Twenty 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.
- 01The most-cited source for the locator-priority rule. Names content-coupled queries as the canonical anti-pattern.
- 02The official priority list: getByRole, getByLabelText, getByPlaceholderText, getByText, getByTestId — in that order. The rule is documented, not folkloric.
- 03“Locators”SupportsMicrosoft Playwright ports Testing Library’s priority list to E2E and explicitly cites it as the inspiration. The rule scales beyond unit tests.
- 04The bridge piece that made the rule editorially obvious: querying by role traverses the same tree a screen reader does. Accessibility lands as a side benefit.
- 05“Testing Implementation Details”SupportsSets the upstream principle the locator-priority list operationalises: tests resemble use; implementation-coupled selectors are implementation-coupled tests.
Eighteen sources, two halves. The locator-priority literature (Kent C. Dodds, Testing Library, Playwright, Marcy Sutton) is canonical: this is the convention the testing libraries ship around. The sitemap-discovery literature is thinner and lives further down. The qualifying voices argue for data-testid in narrow cases; the reply addresses them.
Examples
// Before: hardcoded slug. The next rename breaks a test that asserts no behaviour.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();});
// After: discover the URL set from the sitemap; assert structural shape.test("every tenet page exposes an h1 and a /tenet/{slug}/{id} URL", async ({ page, request }) => { const urls = await discoverTenetUrls(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(); }});// discoverTenetUrls fetches /sitemap.xml and returns every /tenet/{slug}/{id}.
Enforcement
Apply these rules in eslint.config.mjs. The full enforcement across every tenet lives on the implementation page.
| Rule | Tool | Catches |
|---|---|---|
| testing-library/prefer-screen-queries | eslint-plugin-testing-library | destructured query helpers like getByRole — forces tests through screen, which keeps the locator priority list traceable. |
| testing-library/no-container | eslint-plugin-testing-library | container.querySelector usage — the canonical content-coupling escape hatch the priority list rules out. |
| testing-library/no-node-access | eslint-plugin-testing-library | DOM walking via .firstChild, .parentNode, .children — implementation-detail traversal masquerading as a query. |
| testing-library/prefer-query-by-disappearance | eslint-plugin-testing-library | waitFor + queryBy patterns where waitForElementToBeRemoved would survive flake. |
| jest-dom/prefer-in-document | eslint-plugin-jest-dom | expect(element).toHaveLength(1) and similar — pushes assertions toward the structural toBeInTheDocument matcher. |
| playwright/no-element-handle | eslint-plugin-playwright | page.$('css-selector') — the deprecated, content-coupled E2E entry point. Forces locator-based discovery. |
| playwright/prefer-web-first-assertions | eslint-plugin-playwright | expect(await locator.isVisible()) — bypasses the polling assertion that makes structural locators reliable. |
eslint.config.mjsconfiguration snippet
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 tseslint.config(
{
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': 'warn',
'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',
},
},
);AI rules
.cursor/rules/ts5-structural-assertions.mdc---
description: Prickles TS5 — Structural Assertions
globs: "**/*.{spec,test,e2e}.{ts,tsx,js,jsx}"
alwaysApply: false
---
## Prickles TS5 — Structural Assertions
Locate by structure, not content. Use `getByRole` first, then `getByLabelText`, 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.
Counter-argument
The strongest pushback is the data-testid school. Engineers who ship 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 is the test contract; it does not lie when ARIA does.4“Selecting Elements” — the data-cy / data-testid school’s strongest published statement. Argues explicit test attributes are the only stable contract; structural locators couple to ARIA and ARIA changes for legitimate reasons. The Cypress school presses the point further by capping E2E at smoke checks, arguing structural-discovery encourages broad E2E that the budget cannot afford.5“Real World App testing strategy”. The “E2E is expensive” budget claim — broad E2E is impractical, so cap structural-discovery at the most-trafficked flows. Counter-argument the reply addresses.
Counter-argument retort
The data-testid argument inverts the priority on purpose. Testing Library's docs put structural locators first because ARIA changes should break tests when they mean something and shouldn't when they don't.1“About Queries” — the documented locator priority: getByRole, getByLabelText, getByPlaceholderText, getByText, getByDisplayValue, getByAltText, getByTitle, then getByTestId as the last-resort escape hatch. 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 Cypress “E2E is expensive” objection is a budget claim, not a fidelity one.5“Real World App testing strategy”. The “E2E is expensive” budget claim — broad E2E is impractical, so cap structural-discovery at the most-trafficked flows. Counter-argument the reply addresses. 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, not 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 is where the rule earns its place. Adobe's 1,000-pages Playwright pipeline is the public scale-up that proves it works at industrial volume;2“How We Test 1,000+ Pages with Playwright”. Adobe’s docs E2E pipeline reads the live sitemap and iterates every URL on every CI run. The reference scale-up for sitemap-driven test discovery. Bredow's essay is the popular formulation. Pair them 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 ships.
Notes
- [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]Adobe Engineering — “How We Test 1,000+ Pages with Playwright”. Adobe’s docs E2E pipeline reads the live sitemap and iterates every URL on every CI run. The reference scale-up for sitemap-driven test discovery.
- [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.