ยท 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 characters

Similar 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:

  1. Role-based selectors - getByRole('button', { name: 'Submit' })
  2. Test IDs - getByTestId('submit-button')
  3. 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());
});

Comments

Back to Blog