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, andaria-*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:
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 and React selectors have their own pages — see Shadow DOM and React. Playwright-specific locators use the pw strategy: { pw: '[data-testid="save"]' }.
Locator types at a glance
Section titled “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 | Your team uses dedicated test attributes |
ARIA locators
Section titled “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.
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
Section titled “CSS selectors”CSS is the fastest locator type and most frontend developers read it fluently.
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.
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:
I.fillField({ css: 'input[type=password]' }, '123456')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
I.click({ xpath: "//tr[td[text()='Acme Corp']]//button[contains(., 'Edit')]" })Long XPath expressions become unreadable fast. The locate() builder produces the same XPath with a fluent syntax — prefer it for anything beyond two conditions.
Semantic locators
Section titled “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.
I.click('Sign In') // matches <a>, <button>, or <input type="submit">I.fillField('Email', 'u@t.com') // matches label, placeholder, name, or aria-labelI.checkOption('I accept the terms')Pair semantic locators with a context
Section titled “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:
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). 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
Section titled “How matching works”For fillField and similar actions, CodeceptJS resolves the locator in this order:
- ARIA role locator (
{ role: 'textbox', name: 'Email' }) — resolved through the accessibility tree. - Strict locator (
{ css: ... },{ xpath: ... },{ id: ... }, …) — run directly. - Plain string treated as semantic, tried in order:
- Field whose
name,id+label[for], orplaceholderequals the string — or a<label>with that exact text wrapping an input. - The same match with contains, extended to
aria-label,aria-labelledby, andtitle. - An input with that
nameattribute. - The string as a CSS selector.
- Field whose
- 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
Section titled “ID locators”Three short forms cover id-based matching:
#useror{ id: 'user' }— element withid="user"{ name: 'email' }— form field withname="email"~login-button— accessibility id (mobile) oraria-label(web)
I.fillField('#email', 'user@test.com')I.seeElement({ id: 'confirmation' })I.tap('~submit') // mobilePicking a specific element
Section titled “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():
import step from 'codeceptjs/steps'
I.click('a', step.opts({ elementIndex: 2 })) // the 2nd linkI.click('a', step.opts({ elementIndex: 'last' })) // the last linkI.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:
I.click('a', step.opts({ exact: true }))// throws MultipleElementsFound if more than one link matchesSee Element Selection for full details on elementIndex, strict mode, and iterating over matches with eachElement.
Combining locators
Section titled “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; inI.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
Section titled “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:
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.
// Open the user dropdown in the top bar, then pick SettingsI.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 screenI.click({ role: 'link', name: 'Settings' }, '.sidebar')The context itself accepts any locator type: a bare string, a strict object, or a locate() chain.
I.click({ role: 'menuitem', name: 'Log out' }, locate('.dropdown-menu').inside('header'))locate() builder: compose CSS and XPath
Section titled “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.
locate('a') .withAttr({ href: '#' }) .inside(locate('label').withText('Hello'))// .//a[@href = '#'][ancestor::label[contains(., 'Hello')]]Give long chains a name for readable logs:
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 rather than wrapping it inlocate().
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:
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
const editAcme = locate('tr') .withDescendant(locate('td').withText('Acme Corp')) .find('button') .withText('Edit') .as('Edit button for Acme')
I.click(editAcme)Builder methods
Section titled “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
Section titled “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:
locate('button') .withClass('red-btn', 'btn-text-and-icon', 'btn-lg', 'btn-selected') .withText('Button selected') .withoutDescendant('svg')
withClassuses word-exact matching (same as CSS.foo), so.withClass('btn')will not accidentally matchclass="btn-lg". UsewithAttrContains('class', …)if you need the old substring behavior.
Custom locators
Section titled “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 maps a prefix to an attribute:
// 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:
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:
I.click({ data: 'user-login' })Further reading: Mozilla’s Writing reliable locators for Selenium and WebDriver tests and the Locator Advicer.