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" />
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" />
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"
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"
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"
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];
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 };
};
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>
);
};
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>
);
};
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>
);
};
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>
);
};
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();
});
});
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();
});
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
}
}
]
}
}
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)
)
);
}
}
}
};
};
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;
};
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
- 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"
- 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"
- Dynamic values without context: Always provide stable identifiers
// ❌ Brittle
data-testid={`item-${Math.random()}`}
// ✅ Stable
data-testid={`product.list.item-${product.id}`}
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)