Tests break when they target markup that changes. unotest steers every locator toward meaning over structure, so tests survive refactors.
The priority
getByTestId(id)— an explicitdata-testid. Most stable. Prefer it.getByRole(role, { name })— semantic role + accessible name. Stable when the name is unique.getByLabel(text)— form controls by their label.getByText(text)— unique visible text.locator(css)— raw CSS. Last resort.
// good — resilientclick(getByRole("button", { name: "Save changes" }));fill(getByLabel("Email"), TEST_USER_EMAIL);
// avoid — brittleclick(locator(".btn.btn-primary.css-1a2b3c"));Refine, don’t index blindly
Narrow with filter() before reaching for position:
click(getByRole("row").filter({ hasText: "Uma Quinn" }).first());nth() / index-only refinement is fragile — the linter flags it.
The linter enforces it
The linter warns on deep CSS, XPath, hashed class names
(Tailwind JIT, CSS Modules), and unexplained pause(). Run it anytime:
npx @unotest/web lint