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.
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
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, publish 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 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 writesgetByRolefirst because that's the cheapest way to pass review.
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.
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 even know about.
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.
- 01The most-cited source for the locator-priority rule. Names getByTestId and container.querySelector as anti-patterns, and argues for querying by the actual text.
- 02The official priority list: getByRole, getByLabelText, getByPlaceholderText, getByText, getByDisplayValue, getByAltText, getByTitle, then getByTestId. The rule is documented, not folkloric.
- 03“Locators”SupportsMicrosoft Playwright recommends role locators first and user-facing locators over CSS and XPath in E2E. 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 improves 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.
- 06“Pa11y CI”SupportsAn 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.
- 07“Sitemap Protocol 0.9”SupportsThe boring XML format every public site already publishes for SEO. Free discovery surface for the test runner.
- 08“Guiding Principles”Supports“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.
- 09“Best Practices for Playwright”SupportsRecommends getByRole and other user-facing locators. Warns against CSS and XPath selectors. The current vendor-canonical position.
- 10“Sitemap Best Practices”SupportsThe reason the discovery surface exists. Every public site that wants traffic publishes a sitemap. The test runner gets URL discovery for free.
- 11End-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.
- 12“Interaction Tests”QualifiesBuild-time discovery from *.stories.tsx globs rather than runtime fetch. Same instinct (don’t hardcode the input set), different surface.
- 13“ARIA Roles”SupportsThe role taxonomy getByRole traverses. Documents which roles exist, which roles have implicit HTML mappings, and which require explicit aria-* attributes.
- 14The 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.
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 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(); } });
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 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(); } });
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 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(); } });
Before hardcoded slug. The next rename breaks a test that asserts no behaviour.
def test_single_responsibility_principle_f1_renders(page: Page) -> None: page.goto("/tenet/single-responsibility-principle/F1") expect(page).to_have_url("/tenet/single-responsibility-principle/F1") expect(page.get_by_text("Single Responsibility Principle")).to_be_visible()
After discover the URL set from the sitemap; assert structural shape.
def test_every_tenet_page_exposes_h1_and_slug_id_url(page: Page, request: APIRequestContext) -> None: urls = tenet_urls_from_sitemap(request) assert len(urls) > 0 for url in urls: page.goto(url) assert re.search(r"/tenet/[\w-]+/F\d+$", page.url) expect(page.get_by_role("heading", level=1)).to_be_visible()
Before hardcoded slug. The next rename breaks a test that asserts no behaviour.
@Test void singleResponsibilityPrincipleF1Renders() { driver.get(baseUrl + "/tenet/single-responsibility-principle/F1"); assertEquals(baseUrl + "/tenet/single-responsibility-principle/F1", driver.getCurrentUrl()); assertNotNull(driver.findElement(By.xpath("//*[contains(text(),'Single Responsibility Principle')]"))); }
After discover the URL set from the sitemap; assert structural shape.
@Test void everyTenetPageExposesH1AndSlugIdUrl() { List<String> urls = tenetUrlsFromSitemap(baseUrl); assertFalse(urls.isEmpty()); for (String url : urls) { driver.get(url); assertTrue(driver.getCurrentUrl().matches(".*" + "/tenet/[\\w-]+/F\\d+$")); assertNotNull(wait.until(ExpectedConditions.visibilityOfElementLocated(By.tagName("h1")))); } }
Before hardcoded slug. The next rename breaks a test that asserts no behaviour.
public function singleResponsibilityPrincipleF1Renders(AcceptanceTester $I): void { $I->amOnPage("/tenet/single-responsibility-principle/F1"); $I->seeCurrentUrlEquals("/tenet/single-responsibility-principle/F1"); $I->see("Single Responsibility Principle"); }
After discover the URL set from the sitemap; assert structural shape.
public function everyTenetPageExposesH1AndSlugIdUrl(AcceptanceTester $I): void { $urls = tenetUrlsFromSitemap(); $this->assertNotEmpty($urls); foreach ($urls as $url) { $I->amOnUrl($url); $I->seeCurrentUrlMatches('~^/tenet/[\w-]+/F\d+$~'); $I->seeElement("h1"); } }
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 posing as a query. |
| testing-library/prefer-query-by-disappearance | eslint-plugin-testing-library | getBy*/findBy* queries passed to waitForElementToBeRemoved, where a queryBy* query gives the clearer failure. |
| 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 discouraged, 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 { 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',
},
},
);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 posing as a query. |
| testing-library/prefer-query-by-disappearance | eslint-plugin-testing-library | getBy*/findBy* queries passed to waitForElementToBeRemoved, where a queryBy* query gives the clearer failure. |
| 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 discouraged, 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 { 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',
},
},
);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; routes everything through screen. |
| testing-library/no-container | eslint-plugin-testing-library | container.querySelector: the structural-locator bypass. |
| testing-library/no-node-access | eslint-plugin-testing-library | DOM tree walking inside a test. |
| jest-dom/prefer-in-document | eslint-plugin-jest-dom | length-based existence checks where toBeInTheDocument fits. |
eslint.config.mjsconfiguration snippet
import testingLibrary from 'eslint-plugin-testing-library';
import jestDom from 'eslint-plugin-jest-dom';
export default [
{
files: ['**/*.spec.{js,jsx}', '**/*.test.{js,jsx}'],
plugins: { 'testing-library': testingLibrary, 'jest-dom': jestDom },
rules: {
'testing-library/prefer-screen-queries': 'error',
'testing-library/no-container': 'error',
'testing-library/no-node-access': 'error',
'jest-dom/prefer-in-document': 'error',
},
},
];Apply these rules in pyproject.toml. The full enforcement across every tenet lives on the implementation page.
| Rule | Tool | Catches |
|---|---|---|
| playwright-python: page.query_selector forbidden | Semgrep | page.query_selector(...): the discouraged CSS-coupled API. Forces tests through page.get_by_role / get_by_label. |
| pytest --strict-markers: unregistered marker names | pytest | a misspelled or unregistered mark such as @pytest.mark.structual: --strict-markers makes any unknown mark an error, so the structural and discovery markers keep the names the config registers. |
| ruff: ARG002 unused-method-argument in fixtures | Ruff | discovery fixtures that accept a sitemap parameter and ignore it: a common drift from sitemap-driven to hardcoded. |
| semgrep: forbid literal route strings in tests | Semgrep | string literals matching /tenet/foo or /versus/foo inside test files; CI fails until the slug comes from the sitemap fixture. |
pyproject.tomlconfiguration snippet
[tool.pytest.ini_options]
addopts = "--strict-markers --strict-config -ra"
markers = [
"structural: locates by role/label rather than text",
"discovery: iterates a discovered URL set rather than a hardcoded list",
]
[tool.ruff.lint]
extend-select = ["ARG"]
[tool.ruff.lint.per-file-ignores]
"tests/**/*.py" = ["S101"]Apply these rules in config/pmd-tests.xml. The full enforcement across every tenet lives on the implementation page.
| Rule | Tool | Catches |
|---|---|---|
| RegexpSingleline (By.cssSelector / By.xpath in tests) | Checkstyle | Selenium By.cssSelector and By.xpath inside src/test: pushes tests toward role-based locators. |
| custom XPath rule: AvoidHardcodedRouteString | PMD | string literals matching the application route shape inside test sources: flags hardcoded slug fixtures. |
| UnitTestContainsTooManyAsserts | PMD | tests that pile structural and content assertions into one method: a common form when the test wasn’t sure which signal it cared about. |
| S2699 (no assertions in tests) | SonarQube Java | discovery tests that iterate URLs but never call an assertion: empty CI green is worse than red. |
| S5961 (too many assertions in one test) | SonarQube Java | assertions whose content has drifted from the structure under test: catches the drift between locator and assertion. |
config/pmd-tests.xmlconfiguration snippet
<?xml version="1.0"?>
<ruleset name="ts5-structural-assertions"
xmlns="http://pmd.sourceforge.net/ruleset/2.0.0">
<description>Discourage content-coupled selectors and hardcoded URL fixtures.</description>
<rule ref="category/java/bestpractices.xml/UnitTestContainsTooManyAsserts"/>
<rule name="AvoidHardcodedRouteString"
language="java"
message="Avoid hardcoding application route slugs in tests; load from sitemap or routing table."
class="net.sourceforge.pmd.lang.rule.xpath.XPathRule">
<properties>
<property name="xpath">
<value><![CDATA[
//Literal[@StringLiteral='true' and matches(@Image,'\\"/[a-z][a-z0-9-]+(/[a-z0-9-]+)*\\"')]
]]></value>
</property>
</properties>
</rule>
</ruleset>Apply these rules in phpmd.tests.xml. The full enforcement across every tenet lives on the implementation page.
| Rule | Tool | Catches |
|---|---|---|
| custom rule: AvoidHardcodedSlugInTest | PHPMD | string literals matching /tenet/foo or /versus/foo inside tests/, a common cause of fixture drift. PHPMD ships no such rule, so the ruleset loads a class you write from rules/. |
| Generic.Files.LineLength | PHP_CodeSniffer | monster assertions that mix structural locator and full-string content match: the long line is the smell. |
| Squiz.PHP.NonExecutableCode | PHP_CodeSniffer | code after an unconditional return, continue or exit: a leftover early exit at the top of a discovery loop that leaves every page unchecked. |
| S2187 (test class with no tests) | SonarQube PHP | discovery test classes that declare no test methods. |
phpmd.tests.xmlconfiguration snippet
<?xml version="1.0"?>
<ruleset name="TS5"
xmlns="http://pmd.sourceforge.net/ruleset/2.0.0">
<rule ref="rulesets/codesize.xml/ExcessiveMethodLength"/>
<rule name="AvoidHardcodedSlugInTest"
message="Discover dynamic URLs from the sitemap rather than hardcoding."
class="Tests\Rules\AvoidHardcodedSlugInTest"
file="rules/AvoidHardcodedSlugInTest.php">
<properties>
<property name="pattern" value="#^/(tenet|versus|pillar)/[a-z0-9-]+$#"/>
</properties>
</rule>
</ruleset>AI rules
.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.
Counter-argument
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
Counter-argument retort
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.
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]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]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.
- [4]Cypress (community best practices): “Selecting Elements”: the data-cy / data-testid school’s strongest published statement. Recommends dedicated data-cy attributes over tag, class, id and text selectors, and endorses Testing Library’s role queries where a missing role should fail the test.
- [5]Mike Wacker, Google Testing Blog: “Just Say No to More End-to-End Tests” (2015). End-to-end tests are slow, flaky and poor at isolating failures; as a first guess, Google suggests a 70/20/10 split of unit, integration and end-to-end tests. The budget objection the reply addresses.
- [6]Microsoft Playwright team: “Locators”. Playwright’s locator guide recommends role locators first and user-facing locators over CSS and XPath.
- [7]Sitemap.org: “Sitemap Protocol 0.9”. The XML schema every public site already publishes for SEO. Discovery surface for the test runner, free of charge.