--- title: Extending Browser Locators | Recipes --- # Domain Locators Built-in [locators](/api/browser/locators) like `getByRole` and `getByText` cover queries that map onto accessibility attributes. They run out when an app has shapes that don't fit ARIA, like a "comment with N replies" or a row in a custom table component. The fallback is to use `querySelector`. That works, but the result is a plain query rather than a locator, so you lose auto-retry and strict-mode protection. [`locators.extend`](/api/browser/locators#custom-locators) 3.2.0 adds a domain-specific locator without giving up the locator API. The value the method returns is still a locator, so auto-retry, strict-mode protection, and chaining all carry through to your custom methods. The names you give those methods become part of the team's test vocabulary: `page.getByCard({ id: 'product-1' })` reads like the product instead of the DOM, and the same name shows up consistently across the suite. ## Returning a Playwright string The simplest form returns a [Playwright locator string](https://playwright.dev/docs/other-locators). Vitest treats the returned string as a child query of whatever locator the method was called on: when called on `page`, the string runs against the entire page; when called on a parent locator, it runs scoped to that parent's subtree. Reach for this form when the new query has no good expression in built-in locators, like a CSS-with-text selector for a widget that doesn't map onto a built-in role, or an XPath for a legacy component you don't control. ```ts import { locators } from 'vitest/browser' locators.extend({ getByCommentsCount(count: number) { return `.comments :text("${count} comments")` }, }) ``` ```ts import { expect, test } from 'vitest' import { page } from 'vitest/browser' test('article shows comment count', async () => { await expect.element(page.getByCommentsCount(1)).toBeVisible() await expect.element( page.getByRole('article', { name: 'Hello World' }) .getByCommentsCount(1) ).toBeVisible() }) ``` ## Composing existing locators When you return a locator instead of a string, Vitest uses that locator directly. Inside the extension, `this` is bound to the locator the method was called on (or to `page` for top-level calls), so you can chain existing locators or apply `filter` to express relationships between elements that no single built-in option captures. The example below uses `filter({ has })` to narrow a row locator to those that contain a button with a given name, encoding a common per-row-actions pattern as a single named lookup: ```ts import { locators } from 'vitest/browser' import type { Locator } from 'vitest/browser' locators.extend({ getRowWithAction(this: Locator, action: string) { return this.getByRole('row').filter({ has: this.getByRole('button', { name: action }), }) }, }) ``` ```ts await page.getRowWithAction('Delete').first().click() ``` Prefer this over the raw-string form when both options can express the query. Built-in locators encode accessibility-aware lookups, and chaining or filtering them preserves those guarantees. Reach for the raw-string form only when no chain of built-ins covers the query, since the string runs whatever selector you wrote and bypasses the locator mechanism you're trying to keep. ## Custom interactions Methods that perform an interaction instead of returning a locator also work. This is the same mechanism used for shaping your own DSL of user actions, defined alongside your queries so the test vocabulary stays consistent. `locators.extend` types `this` as `BrowserPage | Locator`, since custom methods are reachable from both. For query helpers that's fine, since `getByRole` and other query methods exist on both. For interaction helpers it isn't: `page` has no `click` or `fill`, so calling `page.clickAndFill('x')` would fail at runtime. Guard against that by comparing `this` against the `page` singleton, which lets TypeScript narrow `this` to `Locator` after the throw: ```ts import { locators, page } from 'vitest/browser' import type { BrowserPage, Locator } from 'vitest/browser' locators.extend({ async clickAndFill(this: BrowserPage | Locator, text: string) { if (this === page) { throw new TypeError( 'clickAndFill must be called on a locator, like page.getByRole(\'textbox\').clickAndFill(...)', ) } await this.click() await this.fill(text) }, }) await page.getByRole('textbox').clickAndFill('Hello World') ``` Interaction methods don't compose into selectors. `page.getByRole('textbox').clickAndFill('Hello')` works because `getByRole` returns a locator; `page.clickAndFill('Hello')` would hit the guard. Reach for this form for action helpers, not for query helpers. ## Augmenting locator types `locators.extend` is a runtime registration. TypeScript doesn't know about the new methods until you augment the [`LocatorSelectors`](/api/browser/locators) interface, usually in a shared `.d.ts` file: ```ts import 'vitest/browser' declare module 'vitest/browser' { interface LocatorSelectors { getByCommentsCount: (count: number) => Locator getRowWithAction: (action: string) => Locator clickAndFill: (text: string) => Promise } } ``` `LocatorSelectors` is the interface that both `Locator` and `BrowserPage` extend, so any method declared on it shows up on both. That matches what `locators.extend` does at runtime, and it's why interaction helpers like `clickAndFill` need the guard above: TypeScript will let `page.clickAndFill('x')` type-check, but the guard catches the misuse before it hits a missing method. ## See also - [Custom Locators API](/api/browser/locators#custom-locators) - [Built-in Locators](/api/browser/locators) - [Playwright "other locators"](https://playwright.dev/docs/other-locators)