DEV Community

Rahul Giri
Rahul Giri

Posted on

Modern Test ID Conventions for React/TypeScript/Next.js Apps: Industry Best Practices for 2025

Testing is the backbone of reliable software, and having a solid test ID strategy can make or break your testing experience. Whether you're writing unit tests with Jest and React Testing Library, or end-to-end tests with Playwright or Cypress, your test ID conventions will determine how maintainable and readable your test suite becomes.

After analyzing practices from leading tech companies and the latest trends in the testing community, here's a comprehensive guide to modernizing your test ID approach.

The Evolution: From Basic data-testid to Semantic Test IDs

Traditional Approach (What Most Teams Do)

// Basic approach - functional but not scalable
<button data-testid="submit-button">Submit</button>
<input data-testid="email-input" />
<div data-testid="error-message" />
Enter fullscreen mode Exit fullscreen mode

Modern Approach (What Top Companies Use)

// Semantic, hierarchical, and context-aware
<button data-testid="auth.login-form.submit-btn">Login</button>
<input data-testid="auth.login-form.email-input" />
<div data-testid="auth.login-form.error-message" />
Enter fullscreen mode Exit fullscreen mode

Industry-Standard Naming Conventions

1. Hierarchical Dot Notation (Recommended)

This is the most popular approach among major tech companies:

Pattern: domain.component.element-type

// Examples
data-testid="user.profile-card.avatar-image"
data-testid="cart.checkout-form.payment-method-selector"
data-testid="dashboard.analytics-widget.export-btn"
data-testid="navigation.main-menu.user-dropdown"
Enter fullscreen mode Exit fullscreen mode

Benefits:

  • Clear hierarchy and context
  • Easy to grep and search
  • Namespace collision prevention
  • Scales well with large applications

2. BEM-Style Convention (Alternative)

Pattern: component__element--modifier

// Examples
data-testid="login-form__submit-button--disabled"
data-testid="product-card__price--discounted"
data-testid="navigation__menu-item--active"
Enter fullscreen mode Exit fullscreen mode

3. Kebab-Case with Prefixes (Simple Alternative)

Pattern: prefix-component-element-action

// Examples
data-testid="btn-login-submit"
data-testid="input-user-email"
data-testid="modal-confirmation-close"
Enter fullscreen mode Exit fullscreen mode

TypeScript Integration: Type-Safe Test IDs

Create a centralized system for managing test IDs:

// testIds.ts - Centralized test ID management
export const TestIds = {
  auth: {
    loginForm: {
      emailInput: 'auth.login-form.email-input',
      passwordInput: 'auth.login-form.password-input',
      submitBtn: 'auth.login-form.submit-btn',
      forgotPasswordLink: 'auth.login-form.forgot-password-link',
    },
    signupForm: {
      emailInput: 'auth.signup-form.email-input',
      passwordInput: 'auth.signup-form.password-input',
      confirmPasswordInput: 'auth.signup-form.confirm-password-input',
      submitBtn: 'auth.signup-form.submit-btn',
    }
  },
  dashboard: {
    sidebar: {
      profileBtn: 'dashboard.sidebar.profile-btn',
      settingsBtn: 'dashboard.sidebar.settings-btn',
      logoutBtn: 'dashboard.sidebar.logout-btn',
    }
  }
} as const;

// Type for all possible test IDs
type TestId = typeof TestIds[keyof typeof TestIds];
Enter fullscreen mode Exit fullscreen mode

Enhanced Hook for Test ID Generation

// hooks/useTestId.ts
import { TestIds } from '../constants/testIds';

type TestIdPath = string;
type TestIdOptions = {
  suffix?: string;
  index?: number;
  variant?: string;
};

export const useTestId = () => {
  const generateTestId = (
    basePath: TestIdPath, 
    options?: TestIdOptions
  ): string => {
    let testId = basePath;

    if (options?.index !== undefined) {
      testId += `-${options.index}`;
    }

    if (options?.variant) {
      testId += `--${options.variant}`;
    }

    if (options?.suffix) {
      testId += `-${options.suffix}`;
    }

    return testId;
  };

  const getTestId = (path: string) => {
    const keys = path.split('.');
    let current: any = TestIds;

    for (const key of keys) {
      current = current[key];
      if (!current) {
        console.warn(`Test ID path not found: ${path}`);
        return path;
      }
    }

    return current;
  };

  return { generateTestId, getTestId };
};
Enter fullscreen mode Exit fullscreen mode

Usage in Components

// LoginForm.tsx
import React from 'react';
import { useTestId } from '../hooks/useTestId';
import { TestIds } from '../constants/testIds';

interface LoginFormProps {
  onSubmit: (email: string, password: string) => void;
  isLoading?: boolean;
}

export const LoginForm: React.FC<LoginFormProps> = ({ onSubmit, isLoading }) => {
  const { generateTestId } = useTestId();

  return (
    <form data-testid={TestIds.auth.loginForm.container}>
      <input
        type="email"
        data-testid={TestIds.auth.loginForm.emailInput}
        placeholder="Email"
      />
      <input
        type="password"
        data-testid={TestIds.auth.loginForm.passwordInput}
        placeholder="Password"
      />
      <button
        type="submit"
        data-testid={generateTestId(
          TestIds.auth.loginForm.submitBtn,
          { variant: isLoading ? 'loading' : 'active' }
        )}
      >
        {isLoading ? 'Logging in...' : 'Login'}
      </button>
    </form>
  );
};
Enter fullscreen mode Exit fullscreen mode

Advanced Patterns for Complex UIs

1. Dynamic Lists and Collections

// ProductList.tsx
const ProductList: React.FC<{ products: Product[] }> = ({ products }) => {
  return (
    <div data-testid="product.list.container">
      {products.map((product, index) => (
        <div
          key={product.id}
          data-testid={`product.list.item-${index}`}
          data-product-id={product.id} // Additional context
        >
          <h3 data-testid={`product.list.item-${index}.title`}>
            {product.name}
          </h3>
          <button
            data-testid={`product.list.item-${index}.add-to-cart-btn`}
            data-product-action="add-to-cart"
          >
            Add to Cart
          </button>
        </div>
      ))}
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

2. Conditional Rendering States

// LoadingButton.tsx
const LoadingButton: React.FC<ButtonProps> = ({ 
  isLoading, 
  disabled, 
  children,
  testId 
}) => {
  const getStateModifier = () => {
    if (isLoading) return '--loading';
    if (disabled) return '--disabled';
    return '--active';
  };

  return (
    <button
      data-testid={`${testId}${getStateModifier()}`}
      data-loading={isLoading}
      data-disabled={disabled}
      disabled={disabled || isLoading}
    >
      {isLoading ? <Spinner /> : children}
    </button>
  );
};
Enter fullscreen mode Exit fullscreen mode

3. Multi-Step Forms and Wizards

// CheckoutWizard.tsx
const CheckoutWizard: React.FC = () => {
  const [currentStep, setCurrentStep] = useState(0);

  const steps = ['shipping', 'payment', 'review'];

  return (
    <div data-testid="checkout.wizard.container">
      <div data-testid="checkout.wizard.progress">
        {steps.map((step, index) => (
          <div
            key={step}
            data-testid={`checkout.wizard.step-${index}`}
            data-step-name={step}
            data-step-active={index === currentStep}
          >
            {step}
          </div>
        ))}
      </div>

      <div data-testid={`checkout.wizard.content-${steps[currentStep]}`}>
        {/* Step content */}
      </div>
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

Testing Library Integration

React Testing Library Best Practices

// LoginForm.test.tsx
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { LoginForm } from './LoginForm';
import { TestIds } from '../constants/testIds';

describe('LoginForm', () => {
  it('should handle login submission', async () => {
    const mockSubmit = jest.fn();

    render(<LoginForm onSubmit={mockSubmit} />);

    // Using semantic test IDs
    const emailInput = screen.getByTestId(TestIds.auth.loginForm.emailInput);
    const passwordInput = screen.getByTestId(TestIds.auth.loginForm.passwordInput);
    const submitBtn = screen.getByTestId(TestIds.auth.loginForm.submitBtn);

    fireEvent.change(emailInput, { target: { value: 'test@example.com' } });
    fireEvent.change(passwordInput, { target: { value: 'password123' } });
    fireEvent.click(submitBtn);

    await waitFor(() => {
      expect(mockSubmit).toHaveBeenCalledWith('test@example.com', 'password123');
    });
  });

  it('should show loading state', () => {
    render(<LoginForm onSubmit={jest.fn()} isLoading />);

    // Testing state variants
    expect(screen.getByTestId(
      TestIds.auth.loginForm.submitBtn + '--loading'
    )).toBeInTheDocument();
  });
});
Enter fullscreen mode Exit fullscreen mode

Playwright/Cypress Integration

// login.spec.ts (Playwright)
import { test, expect } from '@playwright/test';
import { TestIds } from '../src/constants/testIds';

test('user can login successfully', async ({ page }) => {
  await page.goto('/login');

  // Using the same test IDs across unit and e2e tests
  await page.fill(
    `[data-testid="${TestIds.auth.loginForm.emailInput}"]`,
    'user@example.com'
  );

  await page.fill(
    `[data-testid="${TestIds.auth.loginForm.passwordInput}"]`,
    'password123'
  );

  await page.click(`[data-testid="${TestIds.auth.loginForm.submitBtn}"]`);

  // Assert navigation to dashboard
  await expect(page.locator(
    `[data-testid="${TestIds.dashboard.container}"]`
  )).toBeVisible();
});
Enter fullscreen mode Exit fullscreen mode

ESLint Rules for Test ID Consistency

Create custom ESLint rules to enforce your conventions:

// .eslintrc.json
{
  "rules": {
    "@typescript-eslint/naming-convention": [
      "error",
      {
        "selector": "property",
        "filter": "data-testid",
        "format": ["kebab-case"],
        "custom": {
          "regex": "^[a-z]+\\.[a-z-]+\\.[a-z-]+$",
          "match": true
        }
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

Automated Test ID Generation

Consider using a build-time plugin to automatically generate test IDs:

// babel-plugin-auto-testid.js
module.exports = function({ types: t }) {
  return {
    visitor: {
      JSXElement(path) {
        const openingElement = path.node.openingElement;
        const name = openingElement.name.name;

        // Skip if data-testid already exists
        const hasTestId = openingElement.attributes.some(
          attr => attr.name && attr.name.name === 'data-testid'
        );

        if (!hasTestId && shouldAddTestId(name)) {
          const testId = generateTestId(path);
          openingElement.attributes.push(
            t.jsxAttribute(
              t.jsxIdentifier('data-testid'),
              t.stringLiteral(testId)
            )
          );
        }
      }
    }
  };
};
Enter fullscreen mode Exit fullscreen mode

Team Adoption Guidelines

1. Documentation Strategy

  • Create a test ID style guide document
  • Maintain a living catalog of all test IDs
  • Include test ID patterns in code review checklists

2. Migration Approach

// Gradual migration utility
const LEGACY_TEST_IDS = {
  'submit-button': 'auth.login-form.submit-btn',
  'email-input': 'auth.login-form.email-input',
  // ... other mappings
};

export const getTestId = (legacyId: string): string => {
  return LEGACY_TEST_IDS[legacyId] || legacyId;
};
Enter fullscreen mode Exit fullscreen mode

3. Team Training

  • Conduct workshops on the new conventions
  • Create reusable component templates
  • Set up automated checks in CI/CD pipeline

Common Pitfalls to Avoid

  1. Over-nesting: Don't go beyond 3-4 levels deep
   // ❌ Too deep
   data-testid="app.dashboard.sidebar.navigation.menu.item.link.text"

   // ✅ Better
   data-testid="dashboard.nav-menu.item-link"
Enter fullscreen mode Exit fullscreen mode
  1. Inconsistent naming: Stick to your chosen convention
   // ❌ Mixed conventions
   data-testid="userProfile_avatar"
   data-testid="user-profile.settings"

   // ✅ Consistent
   data-testid="user.profile.avatar"
   data-testid="user.profile.settings"
Enter fullscreen mode Exit fullscreen mode
  1. Dynamic values without context: Always provide stable identifiers
   // ❌ Brittle
   data-testid={`item-${Math.random()}`}

   // ✅ Stable
   data-testid={`product.list.item-${product.id}`}
Enter fullscreen mode Exit fullscreen mode

Conclusion

Modern test ID conventions are about more than just naming—they're about creating a sustainable testing ecosystem that grows with your application. By adopting these industry-proven patterns, you'll create tests that are more maintainable, readable, and reliable.

The key is to start with a solid foundation and gradually migrate your existing tests. Remember, the best test ID strategy is the one your team actually follows consistently.

Key Takeaways:

  • Use hierarchical dot notation for scalability
  • Implement TypeScript integration for type safety
  • Create centralized test ID management
  • Include state and context information
  • Establish team conventions and tooling
  • Plan for gradual migration from legacy approaches

Start implementing these patterns in your next React/TypeScript/Next.js project, and watch your testing experience transform from frustrating to delightful.

Top comments (0)