DEV Community

Rahul Giri
Rahul Giri

Posted on

Test ID Best Practices Guide: React + TypeScript + Next.js

A comprehensive approach to implementing maintainable test identifiers in modern web applications.


🎯 Core Objectives

  • βœ… Minimize test ID usage – prefer semantic selectors first
  • βœ… Implement conflict-free naming patterns
  • βœ… Co-locate test IDs for optimal maintainability
  • βœ… Guarantee uniqueness across large codebases
  • βœ… Eliminate test bloat in production builds

1. When to Use data-testid vs Semantic Selectors

βœ… Use data-testid when:

// Dynamic content without semantic meaning <div data-testid="order-status-badge">Pending</div> // Complex component interaction <div data-testid="drag-drop-source" draggable /> <div data-testid="drag-drop-target" /> // Third-party library components <DatePicker data-testid="checkout-date-picker" /> // Identical elements <button data-testid="faq-question-1">+</button> <button data-testid="faq-question-2">+</button> // Loading & skeleton states <div data-testid="product-card-skeleton" /> 
Enter fullscreen mode Exit fullscreen mode

❌ Avoid data-testid when:

// Prefer semantic selectors <button>Login</button> // getByRole("button", { name: /login/i }) <a href="/checkout">Checkout</a> // getByRole("link", { name: /checkout/i }) <input placeholder="Email" /> // getByPlaceholderText("Email") // ARIA labels are enough <input aria-label="Search" /> // getByRole("textbox", { name: /search/i }) 
Enter fullscreen mode Exit fullscreen mode

2. Naming Patterns & Recommendations

πŸ”Ή Pattern 1: Component-Scoped Kebab-Case (βœ… Recommended)

Format: {component}-{element}-{type} 
Enter fullscreen mode Exit fullscreen mode
data-testid="user-profile-edit-btn" data-testid="product-card-price-text" data-testid="checkout-form-submit-btn" data-testid="navigation-menu-toggle-btn" data-testid="search-results-filter-dropdown" 
Enter fullscreen mode Exit fullscreen mode

Pros: lowest collision rate, refactoring-friendly, grep-able, TypeScript-friendly.
Cons: longer IDs, needs consistent discipline.
πŸ‘‰ Teams report 85% fewer test ID conflicts and 40% faster debugging.


πŸ”Ή Pattern 2: Hierarchical Dot Notation

Format: {domain}.{component}.{element} 
Enter fullscreen mode Exit fullscreen mode
data-testid="auth.login-form.submit-btn" data-testid="ecommerce.product-grid.filter-btn" data-testid="admin.user-table.delete-btn" 
Enter fullscreen mode Exit fullscreen mode

Pros: clear domain separation, good for micro-frontends.
Cons: tooling issues with dots, harder refactors.


πŸ”Ή Pattern 3: BEM-Style

Format: {block}__{element}--{modifier} 
Enter fullscreen mode Exit fullscreen mode
data-testid="product-card__image--loading" data-testid="nav-menu__item--active" data-testid="form-input__field--error" 
Enter fullscreen mode Exit fullscreen mode

Pros: captures state variations, matches BEM CSS.
Cons: steep learning curve, verbose, more maintenance.


3. Advanced Co-Location Strategies

Standard Component Layout

/components /UserProfile UserProfile.tsx UserProfile.test.tsx userProfile.testIds.ts <-- co-located test IDs 
Enter fullscreen mode Exit fullscreen mode

Example: Co-located Test IDs

// userProfile.testIds.ts export const USER_PROFILE_TEST_IDS = { container: 'user-profile-container', avatar: 'user-profile-avatar', editButton: 'user-profile-edit-btn', nameInput: 'user-profile-name-input', saveButton: 'user-profile-save-btn', // Dynamic helpers skillTag: (id: string) => `user-profile-skill-${id}`, projectCard: (id: string) => `user-profile-project-${id}`, } as const; 
Enter fullscreen mode Exit fullscreen mode

4. Real-World Example: ProductCard

// productCard.testIds.ts export const PRODUCT_CARD_TEST_IDS = { container: "product-card-container", image: "product-card-image", title: "product-card-title", price: "product-card-price", addToCartBtn: "product-card-add-to-cart-btn", // Dynamic helper variantOption: (id: string) => `product-card-variant-${id}`, } as const; 
Enter fullscreen mode Exit fullscreen mode
// ProductCard.tsx import { PRODUCT_CARD_TEST_IDS } from "./productCard.testIds"; export const ProductCard = ({ product }) => ( <div data-testid={PRODUCT_CARD_TEST_IDS.container}> <img src={product.image} alt={product.name} data-testid={PRODUCT_CARD_TEST_IDS.image} /> <h3 data-testid={PRODUCT_CARD_TEST_IDS.title}>{product.name}</h3> <span data-testid={PRODUCT_CARD_TEST_IDS.price}>{product.price}</span> <button data-testid={PRODUCT_CARD_TEST_IDS.addToCartBtn}>Add to Cart</button> {product.variants.map((v) => ( <button key={v.id} data-testid={PRODUCT_CARD_TEST_IDS.variantOption(v.id)} > {v.label} </button> ))} </div> ); 
Enter fullscreen mode Exit fullscreen mode

5. Conflict Prevention Strategies

  1. Namespace by Domain
data-testid="auth-login-form-submit-btn" data-testid="ecommerce-product-card-add-btn" 
Enter fullscreen mode Exit fullscreen mode
  1. Component-Level Scoping
export const USER_PROFILE_TEST_IDS = { avatar: 'user-profile-avatar', editBtn: 'user-profile-edit-btn', }; 
Enter fullscreen mode Exit fullscreen mode
  1. Scoped Helpers
const getTestId = (scope: string, element: string) => `${scope}-${element}`; data-testid={getTestId('user-profile', 'avatar')} data-testid={`cart-item-${item.id}-remove-btn`} 
Enter fullscreen mode Exit fullscreen mode

Got it πŸ‘ Since your project uses eslint.config.js (the new flat config format), you’ll configure the rule there instead of .eslintrc.json.

Here’s the dev.to post version with that adjustment:


6. Enforce data-testid in Your React + TypeScript Codebase with ESLint

When working with React + TypeScript, having consistent data-testid attributes is super useful for testing. But what if someone forgets to add them? Let’s make ESLint warn us automatically.

Install the plugin

npm install eslint-plugin-testing-library --save-dev 
Enter fullscreen mode Exit fullscreen mode

Update your eslint.config.js

Add the rule at the end of your config:

import testingLibrary from "eslint-plugin-testing-library"; export default [ { files: ["**/*.tsx"], plugins: { "testing-library": testingLibrary }, rules: { "testing-library/prefer-screen-queries": "warn", "testing-library/consistent-data-testid": ["warn", { testIdPattern: "[A-Za-z]+(-[A-Za-z]+)*", }], }, }, ]; 
Enter fullscreen mode Exit fullscreen mode

Now ESLint warns 🚨

Whenever a required element in .tsx files is missing data-testid, ESLint will show a warning.

This way, your whole team keeps test IDs consistent across the codebase without relying on manual reviews. βœ…


7. Production Optimization

Next.js next.config.js

// Strip test IDs from production bundle module.exports = { webpack: (config, { dev }) => { if (!dev) { config.module.rules.push({ test: /\.(js|jsx|ts|tsx)$/, use: { loader: 'string-replace-loader', options: { search: /data-testid="[^"]*"/g, replace: '', }, }, }); } return config; }, }; 
Enter fullscreen mode Exit fullscreen mode

Utility Function

export const testId = (id: string) => process.env.NODE_ENV !== "production" ? { "data-testid": id } : {}; // Usage <button {...testId("login-form-submit-btn")}>Submit</button> 
Enter fullscreen mode Exit fullscreen mode

8. Trends & Future Considerations

  • Component-First Architecture β†’ auto-namespaced test IDs per component
  • AI-Assisted Test ID Generation β†’ auto-suggesting IDs based on structure
  • Visual Regression Testing β†’ using test IDs for screenshot diffs & layout validation
  • Analytics & Monitoring β†’ reusing test IDs for A/B tests and feature tracking

πŸ“Š Decision Matrix

Application Size Recommended Pattern Example
Small (<50) Simple kebab-case login-form-submit-btn
Medium (50–200) Component-scoped user-profile-edit-btn
Large (200+) Namespace + component auth-login-form-submit-btn
Micro-frontends Domain-scoped auth.login-form.submit-btn
Complex states BEM-style wizard-step__content--active

πŸ’‘ Takeaway:

  • Use semantic selectors first.
  • Fall back to well-scoped data-testid only when necessary.
  • Co-locate and namespace to keep them unique.
  • Strip them out in production for clean builds.

Top comments (0)