UNPKG

codeceptjs

Version:

Supercharged End 2 End Testing Framework for NodeJS

356 lines (253 loc) 17.7 kB
--- permalink: /locators title: Locators --- # Locators Locators tell CodeceptJS which element on the page a step acts on. Every action that touches the DOM — `click`, `fillField`, `see`, `waitForVisible` — accepts one. CodeceptJS accepts locators in two forms: - **Strict locator** — an object whose single key names the strategy: `{ css: 'button' }`, `{ role: 'button', name: 'Submit' }`, `{ xpath: '//td[1]' }`, `{ id: 'email' }`. The strategy is explicit, so the helper runs exactly one query. - **Semantic locator** — a plain string like `'Sign In'` or `'Email'`. CodeceptJS matches it against labels, button text, placeholders, and `aria-*` attributes the way a user would read the page. Both are idiomatic. The strongest pattern in CodeceptJS — readable, resilient, and unambiguous — is a **semantic locator scoped to a context**: ```js I.click('Save', '.header') I.fillField('Search', 'Item 1', '.topbar') I.click({ role: 'button', name: 'Submit' }, '#login-form') ``` The context narrows the search to one region of the page, and the semantic string says what the user actually clicks. This is **more precise than ARIA or CSS alone** because it combines structural scope with human-readable intent. Supported strategies: `css`, `xpath`, `id`, `name`, `role`, `frame`, `shadow`, `pw`. Shadow DOM has its own page — see [Shadow DOM](/shadow). Playwright-specific locators use the `pw` strategy: `{ pw: '[data-testid="save"]' }`. To test components by their accessible role, use [ARIA locators](#aria-locators). ## Locator types at a glance | Type | Example | Strengths | Weaknesses | Reach for it when | |------|---------|-----------|------------|-------------------| | **Semantic + context** | `I.click('Save', '.header')` | Reads like prose; survives CSS and ARIA refactors; the context disambiguates duplicates | Needs a stable region to scope into | **Default for stable suites.** Anywhere a label, button text, or placeholder identifies the element | | **ARIA role** | `{ role: 'button', name: 'Save' }` | Survives markup changes; matches how users and screen readers identify elements; exposes accessibility gaps | Needs correct ARIA roles and accessible names; slower than CSS | The app follows accessibility guidelines and you want tests that mirror user intent | | **Semantic (no context)** | `'Sign In'`, `'Email'` | No locator to maintain; reads like prose | Ambiguous when the same label appears more than once on the page | A label is unique on the page, or you are prototyping | | **CSS** | `{ css: '.btn-save' }` or `.btn-save` | Fast; familiar to every web developer; composes with class, attribute, and pseudo-selectors | Couples tests to styling; breaks on CSS refactors; cannot match by visible text | A stable class, id, or data-attribute exists on the target | | **XPath** | `{ xpath: '//table//tr[2]/td[last()]' }` | Walks the tree in any direction (`ancestor`, `following-sibling`); matches visible text | Verbose; slow; harder to read than CSS | You need text matching or axis navigation that CSS cannot express | | **ID / name** | `#email`, `{ name: 'user[email]' }` | Shortest possible locator; unambiguous | Requires an `id` or `name` attribute to exist | Forms and elements with stable ids | | **Accessibility id** | `~login-button` | Works in both web (`aria-label`) and mobile | Mobile apps need to expose the id | Cross-platform web and mobile tests | | **Custom (`$foo`)** | `$register_button` | Encodes team convention (`data-qa`, `data-test`) in two characters | Needs the [customLocator plugin](/plugins#customlocator) | Your team uses dedicated test attributes | ## ARIA locators ARIA role locators are the modern default. They identify elements the way assistive technology does — by role and accessible name — and they survive layout and class refactors that break CSS. ```js I.click({ role: 'button', name: 'Login' }) I.fillField({ role: 'textbox', name: 'Email Address' }, 'user@test.com') I.seeElement({ role: 'heading', name: 'Dashboard' }) I.selectOption({ role: 'combobox', name: 'Country' }, 'Ukraine') ``` The `name` matches the element's accessible name — its visible text, `aria-label`, or the text referenced by `aria-labelledby`. Common roles: `button`, `link`, `textbox`, `checkbox`, `radio`, `combobox`, `listbox`, `menuitem`, `tab`, `dialog`, `alert`, `heading`, `navigation`, `banner`, `main`. **Prefer ARIA when:** - The element has a visible label or accessible text. - You want the test to double as an accessibility smoke check. - The UI is rewritten often and class names drift. **Reach for something else when:** - The element has no accessible name (purely decorative icons, unlabeled inputs). - The page predates ARIA annotation and you cannot change it. - A hot loop runs thousands of locator calls and needs the speed of a direct CSS query. > ARIA locators rely on the accessibility tree of the underlying helper. Playwright and modern WebDriver support them natively. ## CSS selectors CSS is the fastest locator type and most frontend developers read it fluently. ```js I.seeElement('.user-profile .avatar') I.click('#checkout-btn') I.fillField('input[name="email"]', 'user@test.com') ``` Pair CSS with stable test attributes — `data-testid`, `data-qa` — rather than style classes. Style classes drift with every design update; test attributes exist to be locators. ```js I.click('[data-testid="submit-order"]') ``` Tie locators to structure, not to presentation: `.btn-primary` survives a redesign; `.bg-green-500` does not. Force CSS when a bare string would trigger fuzzy matching: ```js I.fillField({ css: 'input[type=password]' }, '123456') ``` ## XPath XPath reaches where CSS cannot. Use it for: - Text matching: `//button[contains(., 'Save changes')]` - Axis navigation: `ancestor`, `following-sibling`, `preceding-sibling` - Positional selection deep in a table or list ```js I.click({ xpath: "//tr[td[text()='Acme Corp']]//button[contains(., 'Edit')]" }) ``` Long XPath expressions become unreadable fast. The [`locate()` builder](#combining-locators) produces the same XPath with a fluent syntax — prefer it for anything beyond two conditions. ## Semantic locators A plain string is a semantic locator. CodeceptJS reads it the way a user would: as a button label, a link, a field name, a placeholder, or an `aria-label`. ```js I.click('Sign In') // matches <a>, <button>, or <input type="submit"> I.fillField('Email', 'u@t.com') // matches label, placeholder, name, or aria-label I.checkOption('I accept the terms') ``` ### Pair semantic locators with a context The same label often appears in more than one place — a "Save" button in the toolbar, the modal, and the inline editor. **Pass a context as the last argument** and the lookup is unambiguous, fast, and still readable: ```js I.click('Save', '.toolbar') I.fillField('Search', 'Item 1', '.topbar') I.click('Edit', { css: 'tr.acme' }) I.see('Welcome', '.header') ``` The context can be any locator (CSS, XPath, ARIA, [`locate()` chain](#locate-builder-compose-css-and-xpath)). The action runs only inside it, so duplicate labels elsewhere on the page no longer cause flaky matches. This is the recommended default for stable scenarios — production-grade, not a prototyping shortcut. ### How matching works For `fillField` and similar actions, CodeceptJS resolves the locator in this order: 1. ARIA role locator (`{ role: 'textbox', name: 'Email' }`) — resolved through the accessibility tree. 2. Strict locator (`{ css: ... }`, `{ xpath: ... }`, `{ id: ... }`, …) — run directly. 3. Plain string treated as semantic, tried in order: 1. Field whose `name`, `id`+`label[for]`, or `placeholder` **equals** the string — or a `<label>` with that exact text wrapping an input. 2. The same match with **contains**, extended to `aria-label`, `aria-labelledby`, and `title`. 3. An input with that `name` attribute. 4. The string as a CSS selector. 4. Nothing matched? Throw `ElementNotFound`. A semantic lookup runs several queries, but each query is cheap and the second argument (context) prunes the search space dramatically. ## ID locators Three short forms cover id-based matching: - `#user` or `{ id: 'user' }` — element with `id="user"` - `{ name: 'email' }` — form field with `name="email"` - `~login-button` — accessibility id (mobile) or `aria-label` (web) ```js I.fillField('#email', 'user@test.com') I.seeElement({ id: 'confirmation' }) I.tap('~submit') // mobile ``` ## Picking a specific element When a locator matches several elements on the page, CodeceptJS acts on the first one by default. To target a different match, pass `elementIndex` via `step.opts()`: ```js import step from 'codeceptjs/steps' I.click('a', step.opts({ elementIndex: 2 })) // the 2nd link I.click('a', step.opts({ elementIndex: 'last' })) // the last link I.fillField('.email-input', 'u@t.com', step.opts({ elementIndex: -1 })) ``` `elementIndex` accepts positive numbers (1-based), negative numbers (`-1` is last), or the aliases `'first'` and `'last'`. It works with `click`, `fillField`, `selectOption`, `checkOption`, and other single-element actions. To catch ambiguous locators during development rather than silently using the first match, enable `strict: true` in the helper config, or pass `step.opts({ exact: true })` on a single step: ```js I.click('a', step.opts({ exact: true })) // throws MultipleElementsFound if more than one link matches ``` See [Element Selection](/element-selection) for full details on `elementIndex`, strict mode, and iterating over matches with `eachElement`. ## Combining locators Two mechanisms narrow a locator to a region of the page: - **Context** — the last argument of most actions. Works with every locator type. In `I.click('Save', '.toolbar')` it is the second argument; in `I.fillField('Email', 'u@t.com', '#login-form')` it is the third. - **`locate()` builder** — a fluent API that composes CSS and XPath into a single XPath expression. Does **not** accept ARIA role locators. ### Context: scope any locator to a region Every action that targets an element accepts a context locator as its last argument. The action searches only inside the context. **Use it by default** — even a one-line scenario reads better and survives more refactors when the lookup is scoped: ```js I.click('Login', '#login-form') I.fillField('Email', 'u@t.com', '.modal') I.seeElement({ role: 'button', name: 'Delete' }, '.toolbar') ``` Why scope every action: - Duplicate labels stop being a problem ("Save" in the toolbar vs. the modal). - The semantic locator stays semantic — no need to rewrite as `[data-testid="save-toolbar"]` to disambiguate. - The lookup is faster: each strategy queries only inside the context, not the whole DOM. - Tests read like a sentence about the page: "click Save in the header". The two sides can be any combination — semantic+CSS, ARIA+CSS, semantic+`locate()`. Mix freely. **Example: a dropdown inside a top bar** A complex app often has several menus on screen at once: the top navigation bar, a left sidebar, a right-click context menu. Each may contain a "Settings" item. Without scoping, `I.click('Settings')` is a coin toss. ```js // Open the user dropdown in the top bar, then pick Settings I.click({ role: 'button', name: 'User menu' }, '.top-bar') I.click({ role: 'menuitem', name: 'Settings' }, '.top-bar') // The same label in the sidebar goes to a different screen I.click({ role: 'link', name: 'Settings' }, '.sidebar') ``` The context itself accepts any locator type: a bare string, a strict object, or a [`locate()`](#locate-builder-compose-css-and-xpath) chain. ```js I.click({ role: 'menuitem', name: 'Log out' }, locate('.dropdown-menu').inside('header')) ``` ### `locate()` builder: compose CSS and XPath `locate()` chains CSS and XPath conditions into a single XPath expression. Each method returns the builder so you keep composing. ```js locate('a') .withAttr({ href: '#' }) .inside(locate('label').withText('Hello')) // .//a[@href = '#'][ancestor::label[contains(., 'Hello')]] ``` Give long chains a name for readable logs: ```js locate('//table').find('a').withText('Edit').as('row edit button') ``` > **`locate()` does not wrap ARIA role locators.** The builder produces XPath; ARIA role matching relies on the accessibility tree provided by the helper. To scope an ARIA locator to a region, pass the region as a [**context** argument](#context-scope-any-locator-to-a-region) rather than wrapping it in `locate()`. **Example: the dropdown from the top bar, expressed with `locate()`** When menu items expose no useful ARIA role (custom components built from `<div>` elements and click handlers), fall back to CSS and XPath inside a `locate()` chain: ```js const userMenu = locate('.dropdown-menu').inside('.top-bar').as('user menu') I.click('.user-avatar', '.top-bar') I.click(locate('a').withText('Settings').inside(userMenu)) ``` **Example: the Edit button in a specific table row** ```js const editAcme = locate('tr') .withDescendant(locate('td').withText('Acme Corp')) .find('button') .withText('Edit') .as('Edit button for Acme') I.click(editAcme) ``` #### Builder methods The `with*` family filters elements positively; `without*` excludes; `and` / `andNot` / `or` compose raw predicates or union locators. | Method | Purpose | Example | |--------|---------|---------| | `find(loc)` | Descendant lookup | `locate('table').find('td')` | | `withAttr(obj)` | Match attributes | `locate('input').withAttr({ placeholder: 'Name' })` | | `withAttrContains(attr, str)` | Attr value contains substring | `locate('a').withAttrContains('href', 'google')` | | `withAttrStartsWith(attr, str)` | Attr value starts with | `locate('a').withAttrStartsWith('href', 'https://')` | | `withAttrEndsWith(attr, str)` | Attr value ends with | `locate('a').withAttrEndsWith('href', '.pdf')` | | `withClass(...classes)` | Has all classes (word-exact) | `locate('button').withClass('btn-primary', 'btn-lg')` | | `withClassAttr(str)` | Class attribute contains substring (legacy — prefer `withClass`) | `locate('div').withClassAttr('form')` | | `withText(str)` | Visible text contains | `locate('span').withText('Warning')` | | `withTextEquals(str)` | Visible text matches exactly | `locate('button').withTextEquals('Add')` | | `withChild(loc)` | Has a direct child | `locate('form').withChild('select')` | | `withDescendant(loc)` | Has a descendant anywhere below | `locate('tr').withDescendant('img.avatar')` | | `withoutClass(...classes)` | None of these classes | `locate('tr').withoutClass('deleted')` | | `withoutText(str)` | Visible text does not contain | `locate('li').withoutText('Archived')` | | `withoutAttr(obj)` | None of these attr/value pairs | `locate('button').withoutAttr({ disabled: '' })` | | `withoutChild(loc)` | No direct child matching | `locate('form').withoutChild('input[type=submit]')` | | `withoutDescendant(loc)` | No descendant matching | `locate('button').withoutDescendant('svg')` | | `inside(loc)` | Sits inside an ancestor | `locate('select').inside('form#user')` | | `before(loc)` | Appears before another element | `locate('button').before('.btn-cancel')` | | `after(loc)` | Appears after another element | `locate('button').after('.btn-cancel')` | | `or(loc)` | Union of two locators | `locate('button.submit').or('input[type=submit]')` | | `and(expr)` | Append raw XPath predicate | `locate('input').and('@type="text" or @type="email"')` | | `andNot(expr)` | Append negated raw XPath predicate | `locate('button').andNot('.//svg')` | | `first()` / `last()` | Bound position | `locate('#table td').first()` | | `at(n)` | Pick nth element (negative counts from end) | `locate('#table td').at(-2)` | | `as(name)` | Rename in logs | `locate('//table').as('orders table')` | #### Translating complex XPath Long XPath expressions become readable with the DSL. For example: ``` //*[self::button and contains(@class,"red-btn") and contains(@class,"btn-text-and-icon") and contains(@class,"btn-lg") and contains(@class,"btn-selected") and normalize-space(.)="Button selected" and not(.//svg)] ``` becomes: ```js locate('button') .withClass('red-btn', 'btn-text-and-icon', 'btn-lg', 'btn-selected') .withText('Button selected') .withoutDescendant('svg') ``` > `withClass` uses word-exact matching (same as CSS `.foo`), so `.withClass('btn')` will not accidentally match `class="btn-lg"`. Use `withAttrContains('class', …)` if you need the old substring behavior. ## Custom locators Teams that tag elements with `data-qa`, `data-test`, or similar attributes can register a short-form syntax instead of typing `{ css: '[data-qa-id=register_button]' }` every time. The [`customLocator` plugin](/plugins#customlocator) maps a prefix to an attribute: ```js // with plugin enabled: $name → [data-qa=name] I.click('$register_button') I.fillField('$email', 'user@test.com') ``` For more control, register a filter from a bootstrap script or plugin: ```js codeceptjs.locator.addFilter((providedLocator, locatorObj) => { if (providedLocator.data) { locatorObj.type = 'css' locatorObj.value = `[data-element=${providedLocator.data}]` } }) ``` After registration, `{ data: 'user-login' }` is a valid strict locator: ```js I.click({ data: 'user-login' }) ``` Further reading: Mozilla's [Writing reliable locators for Selenium and WebDriver tests](https://blog.mozilla.org/webqa/2013/09/26/writing-reliable-locators-for-selenium-and-webdriver-tests/) and the [Locator Advicer](https://davertmik.github.io/locator/).