docs/locators.md
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:
{ css: 'button' }, { role: 'button', name: 'Submit' }, { xpath: '//td[1]' }, { id: 'email' }. The strategy is explicit, so the helper runs exactly one query.'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:
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. Playwright-specific locators use the pw strategy: { pw: '[data-testid="save"]' }. To test components by their accessible role, use ARIA locators.
| 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 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' }, '[email protected]')
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:
Reach for something else when:
ARIA locators rely on the accessibility tree of the underlying helper. Playwright and modern WebDriver support them natively.
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"]', '[email protected]')
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:
//button[contains(., 'Save changes')]ancestor, following-sibling, preceding-siblingI.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.
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', '[email protected]') // matches label, placeholder, name, or aria-label
I.checkOption('I accept the terms')
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.
For fillField and similar actions, CodeceptJS resolves the locator in this order:
{ role: 'textbox', name: 'Email' }) — resolved through the accessibility tree.{ css: ... }, { xpath: ... }, { id: ... }, …) — run directly.name, id+label[for], or placeholder equals the string — or a <label> with that exact text wrapping an input.aria-label, aria-labelledby, and title.name attribute.ElementNotFound.A semantic lookup runs several queries, but each query is cheap and the second argument (context) prunes the search space dramatically.
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)I.fillField('#email', '[email protected]')
I.seeElement({ id: 'confirmation' })
I.tap('~submit') // mobile
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 link
I.click('a', step.opts({ elementIndex: 'last' })) // the last link
I.fillField('.email-input', '[email protected]', 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 matches
See Element Selection for full details on elementIndex, strict mode, and iterating over matches with eachElement.
Two mechanisms narrow a locator to a region of the page:
I.click('Save', '.toolbar') it is the second argument; in I.fillField('Email', '[email protected]', '#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.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', '[email protected]', '.modal')
I.seeElement({ role: 'button', name: 'Delete' }, '.toolbar')
Why scope every action:
[data-testid="save-toolbar"] to disambiguate.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 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() chain.
I.click({ role: 'menuitem', name: 'Log out' }, locate('.dropdown-menu').inside('header'))
locate() builder: compose CSS and XPathlocate() 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)
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') |
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.
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', '[email protected]')
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.