skip to content

Why does Playwright's page.getByTestId offer no exact option when page.getByText and page.getByLabel both do?

level: middleimportance: must knowfreq 68%

answer

  1. Two query families, two matching policies
  2. Identifiers do not need fuzzy matching
  3. Case and whole value both matter here
  4. A pattern is the supported escape hatch
  5. One attribute name, replaced not extended

basics

~10 s

Because a test id match is always exact. page.getByTestId compares the whole attribute value case-sensitively, so there is nothing for an option to relax. It reads data-testid unless the testIdAttribute option names another attribute.

solid answer

~40 s

The text-shaped queries match human copy, which is why they default to a case-insensitive substring and offer `{ exact: true }` to tighten. A test id is not copy — it is an identifier the component author chose — so `page.getByTestId('add-comment')` always compares the **whole** attribute value, **case-sensitively**, and an option to loosen that would only create ambiguity. Consequences: `getByTestId('add')` does not match `data-testid="add-comment"`, and `getByTestId('Add-Comment')` does not match `add-comment`. When you do need a partial match, pass a RegExp — `getByTestId(/^add-/)` is accepted. Which attribute is read is configurable through the `testIdAttribute` option; unset, it is `data-testid`, and setting it to `data-pw` makes every `getByTestId` call read `data-pw` instead.

code

typescript · 10 lines
typescript
import { expect, test } from '@playwright/test';

test('a test id is matched whole and case-sensitively', async ({ page }) => {
  await page.setContent('<button data-testid="add-comment">Comment</button>');

  await expect(page.getByTestId('add-comment')).toBeVisible();
  await expect(page.getByTestId('add')).toHaveCount(0);          // not a substring match
  await expect(page.getByTestId('Add-Comment')).toHaveCount(0);  // not case-insensitive
  await expect(page.getByTestId(/^add-/)).toBeVisible();         // a RegExp is accepted
});

go deeper

for a junior

Remember that a test id must be written exactly as it appears in the markup, including case and hyphens, and that the attribute read is data-testid unless the project configured another one.

for a middle

Explain why the option is absent: identifiers are authored for the test, so fuzzy matching would only create ambiguity between sibling ids. Mention that a RegExp is the supported way to match a family of generated ids.

for a senior

Bring the operational angle: a suite-wide attribute change silently invalidates every test id lookup, so treat it as a migration with the component attributes moved in the same change, not a config tweak.

for a principal

Own the convention across teams, including whether generated ids may encode row data. Ids that embed identifiers push tests toward regular expressions, which quietly trades the exactness the query was chosen for.

## Two families of query, two matching policies Playwright's built-in queries split into two groups, and the split explains the missing option. **Text-shaped queries** — `getByText`, `getByLabel`, `getByPlaceholder`, `getByAltText`, `getByTitle` — match strings a human wrote for other humans. Copy gets recased, gets suffixed, gets re-indented in a template. So these default to a **case-insensitive substring** match and expose `{ exact: true }` when the loose default matches too much. **`getByTestId`** matches an identifier a developer put in the markup for exactly this purpose. There is no editorial drift to absorb, and a loose match on identifiers is actively harmful: `add-comment`, `add-comment-button` and `add-comment-error` are all plausible ids on the same issue form, and a substring query for `add-comment` would hit all three. Playwright therefore matches the **whole attribute value, case-sensitively**, always. An `exact` option would have nothing to switch on. ## What that means in practice | Call | Element | Matches | |---|---|---| | `getByTestId('add-comment')` | `data-testid="add-comment"` | yes | | `getByTestId('add')` | `data-testid="add-comment"` | no, not a substring match | | `getByTestId('Add-Comment')` | `data-testid="add-comment"` | no, case-sensitive | | `getByTestId('add-comment ')` | `data-testid="add-comment"` | no, the value is not trimmed for you | | `getByTestId(/^add-/)` | `data-testid="add-comment"` | yes, a RegExp is allowed | That last row is the escape hatch. `getByTestId` accepts a `string` **or** a `RegExp`, so a deliberately partial match is available when you want one — for ids generated with a row identifier, such as `issue-42-menu`, `getByTestId(/^issue-\d+-menu$/)` is the idiomatic form. Reach for it consciously; a RegExp with no anchors reintroduces exactly the ambiguity the exact default protects you from. ## Which attribute is read By default `getByTestId` reads the **`data-testid`** attribute. The `testIdAttribute` option changes that, and the change is a **replacement, not an addition**: after setting it to `data-pw`, `getByTestId('add-comment')` looks for `data-pw="add-comment"` and stops looking at `data-testid` entirely. Teams migrating between conventions have to move the attributes, not rely on a fallback. Two things follow: 1. The option is a **project-wide** decision, because it changes the meaning of every `getByTestId` call in the suite at once. It is not something to vary per test. 2. If a `getByTestId` call suddenly finds nothing after a config change, check which attribute is configured before you go looking at the component — the locator is doing exactly what it was told. The same attribute name can also be set through the `Selectors.setTestIdAttribute` API when you drive the library directly rather than through the test runner. ## Common failure shapes - A test id built by string interpolation in the component picks up a trailing space, and the whole-string comparison fails while the value *looks* right in DevTools. - A component library emits camelCase ids such as `addComment`, and a test written from the design doc uses `add-comment`; case-sensitivity is doing its job, and the fix is agreeing on one convention. - A framework strips unknown attributes in production builds, so the id exists in dev and not in the build under test. - Someone reaches for `getByTestId('row')` expecting a prefix match across `row-1`, `row-2`, `row-3`; the correct form is a RegExp, or a container-scoped query. ## How to answer this in an interview State the principle first: **copy is fuzzy, identifiers are not**, so the text queries are loose by default with a tightening option, while the test id query is strict with no loosening option. Then show you know the two concrete corollaries — the comparison is whole-string and case-sensitive, and a RegExp is the supported way to match a family of ids. Finish with the attribute detail: `data-testid` by default, overridable through `testIdAttribute`, and the override replaces the default rather than adding to it. That sequence covers the mechanics without wandering into whether the team should use test ids at all, which is a different conversation.

  • How would you locate a family of generated ids such as issue-42-menu and issue-43-menu?
    Pass a RegExp: `page.getByTestId(/^issue-\d+-menu$/)`. `getByTestId` accepts a string or a RegExp, and the pattern is the supported way to match a family. Anchor it with `^` and `$` so it does not accidentally match a longer id, and expect several matches, so scope or index deliberately.
  • After setting testIdAttribute to data-pw, do calls still fall back to data-testid?
    No. The setting replaces the attribute Playwright reads rather than adding to it, so `getByTestId` looks only at `data-pw` from then on. A migration has to move the attributes in the components; there is no per-element fallback to the old name.

saying these in an interview costs you the question

  • Says getByTestId matches a substring of the id
  • Thinks test id matching ignores case
  • Believes exact: true is available on getByTestId
  • Assumes data-testid still works after testIdAttribute changes
  • Claims getByTestId only accepts a plain string