ยท hands on
Why You Should Use Locators Instead of Text in Your Tests
Learn why using semantic locators like data attributes and ARIA roles makes your tests more robust than text-based selectors. Discover best practices for writing frontend tests in TypeScript with Playwright.
Writing end-to-end tests is crucial for catching bugs before they reach production. But if you're still using text-based selectors like page.locator('text=Submit'), your tests might be more fragile than you think. In this article, we'll explore why semantic locators make your frontend tests more robust, maintainable, and accessible.
Contents
The Problem with Text-Based Selectors
Let's look at a common testing pattern:
test('user can submit form', async ({ page }) => {
await page.goto('/form');
await page.locator('text=Submit').click();
await expect(page.locator('text=Success!')).toBeVisible();
});This test works... until it doesn't. Here's what can break it:
Text Changes
Your product manager decides "Submit" should be "Send" for better UX. Now your test fails, even though the functionality works perfectly.
Internationalization (i18n)
You add support for multiple languages. Suddenly, your English-based tests fail in German, French, or Japanese environments:
// This only works in English
await page.locator('text=Submit').click();
// But your button now says "Senden" in German
// Test fails โDynamic Content
Text might be dynamic or include variables:
// Which exact text will appear?
await expect(page.locator('text=Welcome, John!')).toBeVisible();
// Fails if the username changes or includes special charactersSimilar Text
Multiple elements might contain the same text:
// Which "Delete" button? There are 10 of them in a table!
await page.locator('text=Delete').click();The Solution: Semantic Locators
Instead of relying on user-facing text, use semantic locators that describe the purpose of elements, not their content.
Data Attributes
Add data attributes (like data-testid) to your HTML:
<h2 data-testid="404-heading">
<span class="sr-only">Error</span>
<span class="text-primary">404</span>
</h2>
<p data-testid="404-message">Sorry, we couldn't find this page.</p>Then use them in your tests:
test('shows 404 page', async ({ page }) => {
await page.goto('/nonexistent');
await expect(page.getByTestId('404-heading')).toBeVisible();
await expect(page.getByTestId('404-message')).toBeVisible();
});Benefits:
- โ Survives text changes
- โ Works across all locales
- โ Clearly indicates test-specific hooks
- โ Self-documenting code
ARIA Roles and Labels
Use semantic HTML and ARIA roles to make elements discoverable:
<aside role="complementary" aria-label="Error Codes">
<h2>Error Codes</h2>
<!-- Sidebar content -->
</aside>Test using Playwright's role-based selectors:
test('sidebar is visible', async ({ page }) => {
await page.goto('/errors/ts1234');
await expect(page.getByRole('complementary', { name: 'Error Codes' })).toBeVisible();
});Benefits:
- โ Improves accessibility
- โ Makes tests more semantic
- โ Aligns with web standards
- โ Works with screen readers
Best Practices
According to Playwright's best practices, use locators in this order:
- Role-based selectors -
getByRole('button', { name: 'Submit' }) - Test IDs -
getByTestId('submit-button') - Text content (only when necessary) -
getByText('Submit')
Use Descriptive Test IDs
Make test IDs descriptive and consistent (following a naming scheme):
// โ
Good
data-testid="error-not-found-message"
data-testid="user-profile-avatar"
data-testid="checkout-submit-button"
// โ Bad
data-testid="msg"
data-testid="div1"
data-testid="button"Combine Locators
For complex scenarios, combine locators:
// Find a button within a specific section
await page.getByTestId('user-profile').getByRole('button', { name: 'Edit' }).click();
// Find the third delete button in a list
await page.getByTestId('todo-list').getByRole('button', { name: 'Delete' }).nth(2).click();Test User Flows, Not Implementation
Focus on what users see and do, not internal structure:
// โ
Good - tests user behavior
test('user can update profile', async ({ page }) => {
await page.getByRole('button', { name: 'Edit Profile' }).click();
await page.getByLabel('Name').fill('John Doe');
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByTestId('success-message')).toBeVisible();
});
// โ Bad - tests implementation details
test('profile form submits', async ({ page }) => {
await page.locator('#edit-btn').click();
await page.locator('input[name="name"]').fill('John Doe');
await page.locator('form').evaluate((form) => form.submit());
});