A data-testid naming convention that scales beyond 10 devs

Without a convention, your data-testid attributes turn into a museum of styles after six months. Here's the kebab-case scope-element-role pattern that survives team growth, with concrete examples and how to enforce it without becoming the DOM police.

A data-testid naming convention that scales beyond 10 devs

You know how this story ends. Sprint 1, your team agrees that data-testid is the way. Sprint 4, someone writes data-testid="submitBtn". Sprint 8, someone else writes data-testid="signup_form_submit". Sprint 12, someone writes data-testid="btn-1". Sprint 26, you have a museum.

Your test suite still works (kind of). Refactors are painful (you can never grep for "all submit buttons"). Onboarding is harder (new QAs ask "what's the convention?" and you say "uh"). And worst of all: the team is now reluctant to add new data-testid because nobody knows what they should look like.

This is preventable. The fix isn't a 50-page wiki page. It's a single rule with three parts.

The rule

Every data-testid value follows this pattern:

data-testid="<scope>-<element>-<role>"

All lowercase, kebab-case (hyphens between parts). Examples:

Page / feature data-testid
Signup form, email input data-testid="signup-email-input"
Signup form, submit button data-testid="signup-submit-button"
Settings page, delete account link data-testid="settings-delete-account-link"
User row in admin table, edit button data-testid="admin-user-edit-button"
Pricing modal, close icon data-testid="pricing-close-button"
Footer, privacy policy link data-testid="footer-privacy-link"

That's it. Three parts. Lowercase. Kebab-case.

Why three parts (not two, not four)

I've experimented with two-part conventions (<element>-<role>) and four-part conventions (<page>-<section>-<element>-<role>). Both fail.

Two parts fail because of collisions. Two pages have a "submit button". Without a scope, the selectors collide and the QA can't tell which one the test means without reading the test file. Worse, when a test breaks, the failure message is "submit-button not found" and you have to find which submit-button it was.

Four parts fail because devs stop adopting them. "Page X section Y element Z role W" is too much typing and too many decisions for one PR. After three weeks the team starts cutting corners (<page>-<element>-<role>, <element>-<role>), and your "convention" becomes an aspiration.

Three parts is the sweet spot. Enough specificity to disambiguate. Short enough that no dev will hate writing it.

What goes in <scope>

Scope is whatever makes the element unambiguous in your app. The fastest rule of thumb: it's usually the page or the feature.

For deeply nested or reusable components, scope can also be a parent component:

The principle: the scope answers "where in the app is this element so I can find it without scrolling through DevTools".

What goes in <element>

The element is what the user sees / interacts with. It's the noun.

Avoid duplicating the role here. Don't write submit-button-button. The element is the noun, the role is the type.

What goes in <role>

The role is the HTML element type, generalized. It's what the user clicks / types into.

Why include the role at all? Two reasons. First: searchability. You can grep for -button to find all buttons. Second: future-you debugging a test will appreciate that signup-submit-button is obviously a button without needing to open DevTools.

When you can't fit it in three parts

Sometimes three parts isn't enough. Lists with multiple items, for instance. Two patterns work here:

Pattern A: dynamic suffix (when the list is data-driven)

<button data-testid="user-row-edit-button" data-id="42">Edit</button>

The data-testid stays generic (matches all rows), and the test disambiguates via a separate attribute or via parent scope. This is the cleanest approach when you have many similar items.

Test side:

await page.locator('[data-testid="user-row-edit-button"][data-id="42"]').click()
// or
await page.locator('[data-id="42"]').getByTestId('user-row-edit-button').click()

Pattern B: indexed (when the list is fixed)

<button data-testid="onboarding-step-1-next-button">Next</button>
<button data-testid="onboarding-step-2-next-button">Next</button>
<button data-testid="onboarding-step-3-next-button">Next</button>

Use only when the list is genuinely fixed (a 3-step onboarding, a 4-tab settings page). Don't use it for data-driven lists, because the indices become meaningless when the data changes.

Enforcing it without becoming the DOM police

The convention dies if its only champion is one person yelling "kebab-case!" in PR reviews. Three lightweight enforcement mechanisms work better.

1. A linter / CI check

Add a regex check to your CI that rejects PRs introducing data-testid values that don't match the pattern. ESLint plugins exist (eslint-plugin-testing-library, custom rules). For frameworks without good plugin support, a simple grep step works:

# Find data-testid values that aren't lowercase kebab-case alphanumeric
grep -rE 'data-testid="[^"]*[A-Z_][^"]*"' src/ && exit 1 || exit 0

The CI failing is a much more effective teacher than a senior QA writing a comment in review.

2. A 1-page convention doc

Not a 50-page wiki. One page. Title: "How to name data-testid". Content: the rule from this article, the table of examples, the two list patterns. Pin it in the repo (docs/testids.md) and link to it from the PR template.

3. Examples in the codebase

When a new dev starts, they grep the codebase for data-testid=. If 90% of what they see follows the convention, they follow it. If 50% follows it and 50% is chaos, they pick whichever style is closest to what they're working on, and entropy wins.

This means the first big effort is to clean up the existing inconsistencies in one focused sprint. After that, the convention enforces itself by gravity: new code looks like the surrounding code.

A tool that nudges in the right direction

Here's where I plug the product. TestID Hunter records a QA session and, for each element missing a data-testid, suggests one that already follows this convention. The suggestion uses the page URL or the nearest ancestor with a recognizable name as the scope, the closest visible label as the element, and the HTML tag as the role.

So a dev who receives a TestID Hunter ticket sees Add data-testid="signup-email-input" to <input>. They don't need to know the convention. They just paste. After three months of this, the codebase is conventional by accident.

The takeaway

Conventions don't die because they're bad. They die because they're not enforced. Pick a simple one, automate the check, document it on one page. The kebab-case scope-element-role pattern works for teams of 5 and teams of 50. It's boring. That's exactly the point.