On an order list page where every row renders a "Cancel" button, an end-to-end test's locator matches several elements and ends up cancelling the wrong order. Why do page-wide selectors go wrong on repeated UI, and how do you target the intended instance reliably?
answer
- selector names a kind, not an instance
- indices encode data, not identity
- scope to the entity, then to the control
- anchor on data the test itself created
- ambiguity errors beat silent first match
basics
~20 sA selector describing a kind of control cannot identify which instance you mean when the page renders many of them, and index-based picking depends on ordering and data that shift. Find the container that identifies the entity first, then query for the control within it.
solid answer
~50 sRole and name, text, even a shared test id all describe a *type* of control. On a list they match once per row, so the test either acts on an arbitrary match or fails ambiguously. Picking by index looks like a fix but is not: ordering depends on sort, pagination, and whatever data the run happened to seed, so `nth(2)` quietly repoints at a different order tomorrow — and the failure is a wrong action, not a red test. The reliable pattern is two-step scoping. Locate the container that identifies the entity — the row containing the order number the test itself created, or a row carrying `data-testid="order-row-1042"` — and then query inside it by role and name. Tools express this as `within(row)` or a scoped locator. It is also why locator APIs that throw on multiple matches are doing you a favour: an ambiguity error at the point of the bug beats silently acting on the first match.
code
html · 12 lines<table>
<tbody>
<tr data-testid="order-row-1042">
<td>ORD-1042</td>
<td><button>Cancel</button></td>
</tr>
<tr data-testid="order-row-1043">
<td>ORD-1043</td>
<td><button>Cancel</button></td>
</tr>
</tbody>
</table>go deeper
Know that when a page shows a list, a selector for 'the Cancel button' matches one per row, so the test must first narrow down to the row it means.
Explain the two-step pattern — find the container that identifies the entity, then query by role and name inside it — and why an index is not identity.
Show the production judgment: a silent first-match click produces a green test that acted on the wrong record, so treat ambiguity errors as findings and anchor scoping on data the test itself seeded.
Argue for the shared contract that makes this affordable at scale — entity-scoped test ids rendered by list components, plus container-scoped assertions as the default so a parallel suite cannot see other runs' data.
## The mistake underneath the symptom The test asked the page for "the Cancel button." The page has nine. Every selector strategy discussed for durability — accessible role and name, visible text, a `data-testid` shared by a component — describes **what kind of control** an element is. None of them describes **which one**. Repeated UI turns a perfectly good selector into an under-specified one. What happens next depends on the API. Some return a collection and the test takes the first; some throw an ambiguity error; some legacy APIs quietly act on the first match. The dangerous case is the quiet one, because the test passes while having cancelled someone else's order — a green test that verified the wrong thing. ## Why index-based picking is not the fix The reflex is `nth(2)` or `.eq(1)`. It works on the developer's machine and encodes an assumption nobody wrote down: that the row you care about is in that position. That assumption depends on the sort order, the page size, whether another test's data landed in the same list, when the seeded record was created, and whether the list is virtualised. Change the default sort from newest-first to oldest-first and every index-based test now acts on a different entity — some of them still passing. The deeper problem is that indices come from **data**, and data in an e2e environment is shared and mutable. Any strategy that survives only under one data arrangement is a latent flake at best and a false pass at worst. ## The two-step pattern Split identity into two questions: *which entity?* then *which control within it?* 1. **Scope to a container that identifies the entity.** The best anchor is data the test itself created, so it is unique by construction: the row containing the order number this test seeded. Alternatively the component renders an entity-scoped test id, `data-testid="order-row-1042"`, which is the cleanest contract because it is explicit and unique. 2. **Query inside that container by role and name.** Within one row, "the Cancel button" is unambiguous again, and you keep all the durability benefits of semantic locating. ```js // entity identity first, control identity second const row = page.getByRole('row', { name: /ORD-1042/ }); await row.getByRole('button', { name: 'Cancel' }).click(); ``` The same shape exists in component testing as `within(row).getByRole('button', { name: 'Cancel' })`. The mechanism differs per tool; the principle does not. Filtering a collection by contained text — "the row that has the text ORD-1042" — is the same idea expressed differently and is fine, as long as the text you filter on identifies the entity rather than being generic copy. ## Why strictness helps A locator API that refuses to act when a selector matches several elements is often experienced as an annoyance. It is a correctness feature. Ambiguity is a bug in the test's model of the page, and it should be reported where it occurs rather than resolved by an arbitrary rule. Silent first-match semantics convert that bug into a *wrong action*, which is far more expensive to find — you discover it when a test that has been green for months turns out to have been asserting on a different record than it thought. The practical corollary: when you hit an ambiguity error, do not reach for `.first()` to make it go away. That is the same silence, opted into manually. Ask what makes the intended element different from the others, and encode that. ## Scoping as an assertion tool, not just a click tool The pattern pays off twice. Assertions scoped to a container are far more precise: asserting that *this row* now shows "Cancelled" is a real check, while asserting that the word "Cancelled" appears somewhere on the page passes as soon as any row is cancelled — including rows another parallel test touched. Container-scoped assertions are one of the cheapest defences against cross-test interference in a shared environment. ## What to say in an interview Name the core distinction — kind versus instance — explain why indices encode data assumptions rather than identity, and describe the two-step pattern with a concrete anchor drawn from the test's own data. Then add the judgment point: strict, ambiguity-reporting locators are preferable to first-match convenience, because a loud failure is cheaper than a green test that acted on the wrong entity.
- Why not just make every button's test id unique, like cancel-1, cancel-2?Because those numbers are positional, not entity identity, so they shift with sort order and paging exactly as an index would. If you encode identity in the attribute, encode the entity — `order-row-1042` — and put it on the row so one contract covers every control inside it.
- The row has no unique text and no test id, and you cannot change the component. What now?Anchor on something the test controls. Seed data whose values are unique by construction — a namespaced customer name or reference — so the row containing it is unique, and filter the row collection by that text. Failing everything, a shallow structural scope is acceptable, but flag it as a gap and get the entity test id added.
- How does scoping help with tests running in parallel against shared data?Page-wide assertions can be satisfied by another test's records — 'Cancelled appears on the page' passes if any row is cancelled. Scoping every assertion to the row for this test's own entity makes it blind to other runs' data, which removes a whole class of cross-test interference without any coordination between tests.
saying these in an interview costs you the question
- Reaches for .first() to silence an ambiguity error
- Uses nth-of-type or eq() indices to pick a list row
- Assumes list order is stable across runs and environments
- Asserts on page-wide text rather than within the affected row
- Adds positional test ids like cancel-1 and calls it identity