< Back

“data-what-now?” Taming the Wild Front-End with a Solid data-* Test ID Strategy

Author

Thomas Decock

Date

22/07/2025

Share this article

“data-what-now?” Taming the Wild Front-End with a Solid data-* Test ID Strategy

You arrive at a new client as a test automation consultant. The brief? “Help us scale quality fast.” The reality? The front-end DOM is an untamed jungle. There’s no test ID strategy, and you’re stuck writing selectors that rely on brittle CSS classes, clunky DOM paths, and XPath spelunking expeditions. Your test code ends up more creative than the UI itself.

Let’s fix that with a test ID strategy that’s scalable, semantic, and developer-approved.

The Problem: No Strategy Means Wasted Hours and Fragile Tests

Without standardized test IDs:

  • You spend more time inspecting the DOM than writing tests.

  • Your selectors break anytime the UI shifts or a designer gets creative.

  • Your test suite becomes hard to read, slow to update, and unreliable to trust.

Step 1: Propose a Smart, Scalable Test ID Convention

You don’t need to reinvent the wheel, you just need to roll with a good one.

Pick Your Attribute

Choose a single attribute likedata-cy or data-testid Both are test-safe, stylingagnostic, and semantically scoped. I recommend data-cyfor E2E testing (e.g., Cypress), but consistency is more important than the label.

Adopt a Naming Pattern

Use an easy-to-read, composable format:

[element]--[intent]--[optional-scope]

Examples:

btn--save--form

input--search--navbar

link--dashboard--footer

Alternative (for page-structured apps):

[scope]--[element]--[intent]

Just pick one and document it clearly. Your future self will thank you.

Step 2: Show the Win With a Before & After

Instead of:

cy.get('.btn.primary:nth-child(3)').click()

Use:

cy.get('[data-cy="btn--save--form"]').click()

Result: No styling dependency, less breakage, more readable intent.

Step 3: Tables — The Right Way

Tables are where generic selectors thrive and test dreams go to die. But with a proper strategy, you can anchor your tests to headers and target rows precisely.

Example HTML Setup

<table data-cy="tbl--user-list">
  <thead data-cy="tbl--user-list--head">
    <tr data-cy="row--header">
      <th data-cy="col--name">Name</th>
      <th data-cy="col--email">Email</th>
      <th data-cy="col--role">Role</th>
    </tr>
  </thead>
  <tbody data-cy="tbl--user-list--body">
    <tr data-cy="row--user" data-user="alice">
      <td data-cy="cell--name">Alice</td>
      <td data-cy="cell--email">alice@example.com</td>
      <td data-cy="cell--role">
        Admin
        <button data-cy="btn--promote">Promote</button>
      </td>
    </tr>
    <tr data-cy="row--user" data-user="bob">
      <td data-cy="cell--name">Bob</td>
      <td data-cy="cell--email">bob@example.com</td>
      <td data-cy="cell--role">
        Editor
        <button data-cy="btn--promote">Promote</button>
      </td>
    </tr>
  </tbody>
</table>

Get the Email in Alice’s Row

cy.get('[data-cy="tbl--user-list"]').within(() => {
  cy.get('[data-user="alice"]').within(() => {
    cy.get('[data-cy="cell--email"]').should('contain', 'alice@example.com')
  })
})

Click “Promote” Button for Bob

cy.get('[data-cy="tbl--user-list"]').within(() => {
  cy.get('[data-user="bob"]').within(() => {
    cy.get('[data-cy="btn--promote"]').click()
  })
})

Loop-Based Header Assertion

const expectedHeaders = ['col--name', 'col--email', 'col--role']

cy.get([data-cy="tbl--user-list--head"]').within(() => {
  expectedHeaders.forEach(headerId => {
    cy.get(`[data-cy="${headerId}"]`).should('exist')
  })
})

Step 4: Bring the Team Along

Roll It Out With:

  • Updates to shared component libraries ( data-cy props baked in)

  • A team test token style guide

  • Code review checklists (“Add a test ID here?”)

Optional: ESLint rules blocking selectors like .class, div, or [class^=""]

Maintain a Registry

export const tokens = {
  saveForm: 'btn--save--form',
  emailCell: 'cell--email',
  promoteBtn: 'btn--promote',
  userRow: 'row--user',
}

Why This Matters: Consistency Unlocks Reusability

Choosing a structured selector strategy isn’t just about writing cleaner tests today, it’s about building a smarter testing framework tomorrow.

When your selectors follow a predictable and semantic pattern, you open the door to generating them programmatically. That means:

  • Less duplication across your test files

  • Reduced risk of typos or drift in selector strings

  • Faster onboarding for new contributors

  • A declarative test writing style that reads like intent, not DOM traversal

Example: A Selector Utility Function

Once your data tokens follow a consistent pattern like [data-cy="btn--action--scope"] , you can wrap that in a simple helper:

// selectorUtils.ts
export const sel = {
  btn: (action: string, scope?: string) =>
    `[data-cy="btn--${action}${scope ? `--${scope}` : ''}"]`,

  cell: (type: string) =>
    `[data-cy="cell--${type}"]`,

  col: (type: string) =>
    `[data-cy="col--${type}"]`,

  row: (type: string) =>
    `[data-cy="row--${type}"]`,
}

Now your tests become delightfully expressive:

cy.get(sel.btn('save', 'form')).click()
cy.get(sel.cell('email')).should('contain', 'alice@example.com')
cy.get(sel.row('user')).should('have.length', 5)

You can even generate scoped versions:

export const withinRow = (userId: string, callback: () => void) => {
  cy.get([data-user="${userId}"]`).within(callback)
}

Then use it like this:

withinRow('bob', () => {
  cy.get(sel.btn('promote')).click()
})

Bonus: ID Registry + Autocomplete

If you use TypeScript, you’ll even get autocomplete and type safety for your selectors. Wrap action, scope, and cellType as enums or union types to protect your suite from typos and keep everything discoverable.

When your selector strategy becomes a vocabulary, not just a query string, your test suite turns from brittle shell into an internal testing DSL. You’re no longer “selecting”, you’re declaring test intent.

Pattern-Based Selector Builders (Starts With, Ends With, Contains)

Let’s take checkboxes as an example. If you follow a consistent naming convention like:

<input type="checkbox" data-cy="chk--accept-terms--signup">
<input type="checkbox" data-cy="chk--accept-terms--checkout">
<input type="checkbox" data-cy="chk--receive-newsletter--profile">

You can now generate selectors that search by partial match on specific parts of the token.

Using Attribute Selectors

These allow for partial matching:

Selector Syntax

Matches When data-cy...
[data-cy^="chk--accept-terms"]
starts with "chk--accept-terms"
[data-cy$="--signup"]
ends with "--signup"
[data-cy*="newsletter"]
contains "newsletter" anywhere in the token

Dynamic Test Selector Utility (with Patterns)

export const sel = {
  // Exact match
  chk: (label: string, scope?: string) =>
    `[data-cy="chk--${label}${scope ? `--${scope}` : ''}"]`,

  // Starts with pattern
  chkStartsWith: (prefix: string) =>
    `[data-cy^="chk--${prefix}"]`,

  // Ends with pattern
  chkEndsWith: (suffix: string) =>
    `[data-cy$="--${suffix}"]`,

  // Contains pattern
  chkContains: (fragment: string) =>
    `[data-cy*="${fragment}"]`,
}

Test Examples

Click all checkboxes with labels starting with accept-terms:

cy.get(sel.chkStartsWith('accept-terms')).each(($el) => {
  cy.wrap($el).check()
})

Verify only the newsletter checkbox exists:

cy.get(sel.chkContains('newsletter')).should('exist')

Assert that the signup-specific checkbox is visible:

cy.get(sel.chkEndsWith('signup')).should('be.visible')

Benefits of This Approach

  • Insanely flexible for dynamic UIs or repeated components

  • Reduces hardcoded duplication across tests

  • Scales beautifully in large apps

  • Empowers advanced use cases like filtering elements, scoping within modals, or validating accessibility toggles

Final Thought: Creativity Is Great — In the UI, Not the Tests

Your selectors shouldn’t be clever. They should be clear, boring, and built to last.

With semantic data-cy test tokens, scope .within() selectors, and a consistent naming scheme, your automation becomes:

  • Easier to write

  • Less prone to breakage

  • Clear in its intent