Skip to content

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:

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"]' }.

TypeExampleStrengthsWeaknessesReach for it when
Semantic + contextI.click('Save', '.header')Reads like prose; survives CSS and ARIA refactors; the context disambiguates duplicatesNeeds a stable region to scope intoDefault 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 gapsNeeds correct ARIA roles and accessible names; slower than CSSThe app follows accessibility guidelines and you want tests that mirror user intent
Semantic (no context)'Sign In', 'Email'No locator to maintain; reads like proseAmbiguous when the same label appears more than once on the pageA label is unique on the page, or you are prototyping
CSS{ css: '.btn-save' } or .btn-saveFast; familiar to every web developer; composes with class, attribute, and pseudo-selectorsCouples tests to styling; breaks on CSS refactors; cannot match by visible textA 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 textVerbose; slow; harder to read than CSSYou need text matching or axis navigation that CSS cannot express
ID / name#email, { name: 'user[email]' }Shortest possible locator; unambiguousRequires an id or name attribute to existForms and elements with stable ids
Accessibility id~login-buttonWorks in both web (aria-label) and mobileMobile apps need to expose the idCross-platform web and mobile tests
Custom ($foo)$register_buttonEncodes team convention (data-qa, data-test) in two charactersNeeds the customLocator pluginYour 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' }, '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 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.

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-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:

  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.

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', 'user@test.com')
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', '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 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:

  • 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.

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 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() 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 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:

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.

MethodPurposeExample
find(loc)Descendant lookuplocate('table').find('td')
withAttr(obj)Match attributeslocate('input').withAttr({ placeholder: 'Name' })
withAttrContains(attr, str)Attr value contains substringlocate('a').withAttrContains('href', 'google')
withAttrStartsWith(attr, str)Attr value starts withlocate('a').withAttrStartsWith('href', 'https://')
withAttrEndsWith(attr, str)Attr value ends withlocate('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 containslocate('span').withText('Warning')
withTextEquals(str)Visible text matches exactlylocate('button').withTextEquals('Add')
withChild(loc)Has a direct childlocate('form').withChild('select')
withDescendant(loc)Has a descendant anywhere belowlocate('tr').withDescendant('img.avatar')
withoutClass(...classes)None of these classeslocate('tr').withoutClass('deleted')
withoutText(str)Visible text does not containlocate('li').withoutText('Archived')
withoutAttr(obj)None of these attr/value pairslocate('button').withoutAttr({ disabled: '' })
withoutChild(loc)No direct child matchinglocate('form').withoutChild('input[type=submit]')
withoutDescendant(loc)No descendant matchinglocate('button').withoutDescendant('svg')
inside(loc)Sits inside an ancestorlocate('select').inside('form#user')
before(loc)Appears before another elementlocate('button').before('.btn-cancel')
after(loc)Appears after another elementlocate('button').after('.btn-cancel')
or(loc)Union of two locatorslocate('button.submit').or('input[type=submit]')
and(expr)Append raw XPath predicatelocate('input').and('@type="text" or @type="email"')
andNot(expr)Append negated raw XPath predicatelocate('button').andNot('.//svg')
first() / last()Bound positionlocate('#table td').first()
at(n)Pick nth element (negative counts from end)locate('#table td').at(-2)
as(name)Rename in logslocate('//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')

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.

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.