Master Playwright's locator API to find elements by ARIA role, label, text, and test ID. Learn the semantic locator hierarchy that makes tests resilient to CSS and markup refactoring.
The locator you choose determines how resilient your test is to future UI changes. A test that finds a button by CSS class breaks when the designer renames a class. A test that finds a button by its visible text and ARIA role survives class renames, markup restructuring, and component library migrations — because those changes rarely affect what users see and interact with.
Playwright's locator API is built around this philosophy. The hierarchy mirrors the Web Content Accessibility Guidelines: prefer role-based locators that mirror the accessibility tree, fall back to label text and visible content, and only use test IDs or CSS selectors as a last resort. This lesson teaches you every locator type and when to use each.
getByRole finds elements by their implicit or explicit ARIA role. It is the most resilient locator because roles are semantically meaningful and stable across refactors.
1import { test, expect } from '@playwright/test';
2
3test('locating elements by ARIA role', async ({ page }) => {
4 await page.goto('/courses');
5
6 // Buttons
7 await page.getByRole('button', { name: 'Enrol Now' }).click();
8 await page.getByRole('button', { name: /sign in/i }).click(); // regex — case-insensitive
9
10 // Links
11 await page.getByRole('link', { name: 'Home' }).click();
12 await page.getByRole('link', { name: 'React Fundamentals' }).click();
13
14 // Headings (with level)
15 await expect(page.getByRole('heading', { level: 1 })).toHaveText('All Courses');
16 await expect(page.getByRole('heading', { level: 2, name: 'TypeScript' })).toBeVisible();
17
18 // Form elements
19 await page.getByRole('textbox', { name: 'Email' }).fill('[email protected]');
20 await page.getByRole('checkbox', { name: 'Remember me' }).check();
21 await page.getByRole('combobox', { name: 'Level' }).selectOption('BEGINNER');
22 await page.getByRole('radio', { name: 'Monthly billing' }).check();
23
24 // Lists and listboxes
25 const listItems = page.getByRole('listitem');
26 await expect(listItems).toHaveCount(5);
27
28 // Navigation
29 await expect(page.getByRole('navigation')).toBeVisible();
30 await expect(page.getByRole('main')).toBeVisible();
31
32 // Dialog
33 await expect(page.getByRole('dialog', { name: 'Confirm purchase' })).toBeVisible();
34
35 // Alert / status messages
36 await expect(page.getByRole('alert')).toContainText('Payment failed');
37 await expect(page.getByRole('status')).toContainText('Saving...');
38});
39
40// exact option: false (default for role names) does substring match
41await page.getByRole('button', { name: 'Enrol', exact: false }).click();
42// exact: true requires exact string match
43await page.getByRole('button', { name: 'Enrol Now', exact: true }).click();Form inputs are most reliably located by their associated label text — this also verifies that the label is correctly associated (important for accessibility).
1test('filling a registration form', async ({ page }) => {
2 await page.goto('/register');
3
4 // getByLabel finds input associated via htmlFor/id, aria-label, or aria-labelledby
5 await page.getByLabel('Full name').fill('Alice Smith');
6 await page.getByLabel('Email address').fill('[email protected]');
7 await page.getByLabel('Password').fill('SecurePass1!');
8 await page.getByLabel('Confirm password').fill('SecurePass1!');
9
10 // For inputs without visible labels, use getByPlaceholderText
11 await page.getByPlaceholderText('Search courses...').fill('TypeScript');
12
13 // getByLabel with exact: false for partial match
14 await page.getByLabel('phone', { exact: false }).fill('+1234567890');
15
16 // Submit
17 await page.getByRole('button', { name: 'Create account' }).click();
18 await expect(page).toHaveURL('/verify-email-sent');
19});
20
21// Distinguishing multiple inputs with the same placeholder:
22const form = page.getByRole('form', { name: 'Shipping address' });
23await form.getByLabel('Street address').fill('123 Main St');
24await form.getByLabel('City').fill('San Francisco');
25
26// getByRole('textbox') when there's only one:
27await page.getByRole('textbox').fill('single input on page');getByText matches visible text content. Use it for non-interactive elements like paragraphs, spans, and table cells.
1test('text and alt text locators', async ({ page }) => {
2 await page.goto('/courses/1');
3
4 // Exact text match (default)
5 await expect(page.getByText('TypeScript Fundamentals')).toBeVisible();
6
7 // Partial text match
8 await expect(page.getByText('TypeScript', { exact: false })).toBeVisible();
9
10 // Regex match
11 await expect(page.getByText(/\$\d+\.\d{2}/)).toBeVisible(); // price like $49.99
12
13 // Image alt text
14 await expect(page.getByAltText('Course thumbnail')).toBeVisible();
15 await expect(page.getByAltText(/instructor photo/i)).toBeVisible();
16
17 // getByTitle — matches the title attribute (tooltip text)
18 await expect(page.getByTitle('Remove from wishlist')).toBeVisible();
19});
20
21// Prefer getByRole over getByText for interactive elements:
22// BAD: page.getByText('Submit') — matches any element with that text
23// GOOD: page.getByRole('button', { name: 'Submit' }) — only matches buttons
24
25// getByText is correct for static content:
26const price = page.getByText('$49.99');
27await expect(price).toBeVisible();When a page has multiple similar elements, use .filter(), .nth(), and parent locators to narrow down to the specific element.
1test('narrowing locators to specific elements', async ({ page }) => {
2 await page.goto('/courses');
3
4 // nth(0) — first match (0-indexed)
5 const firstCard = page.getByRole('article').nth(0);
6 await expect(firstCard.getByRole('heading')).toBeVisible();
7
8 // first() and last() — convenience methods
9 await page.getByRole('button', { name: 'Enrol' }).first().click();
10 await page.getByRole('listitem').last().click();
11
12 // .filter() — filter by text content
13 const reactCourse = page.getByRole('article').filter({ hasText: 'React' });
14 await expect(reactCourse).toHaveCount(1); // only one React course
15 await reactCourse.getByRole('button', { name: 'Enrol Now' }).click();
16
17 // .filter() with has — filter by child locator
18 const featuredCards = page.getByRole('article').filter({
19 has: page.getByRole('img', { name: /featured/i }),
20 });
21 await expect(featuredCards).toHaveCount(3);
22
23 // .locator() — nested selector from a parent
24 const nav = page.getByRole('navigation');
25 await nav.getByRole('link', { name: 'Courses' }).click();
26 // This scopes the link search to within the nav element
27
28 // Locator.and() — element matching both locators
29 const visibleButton = page.getByRole('button').and(page.getByText('Save'));
30 await expect(visibleButton).toBeEnabled();
31});
32
33// Data-testid for elements that have no semantic role:
34// <div data-testid="price-badge">$49.99</div>
35await expect(page.getByTestId('price-badge')).toContainText('49.99');
36// Use sparingly — only when no semantic locator is availablePlaywright automatically waits for elements to be visible, enabled, and stable before interacting. This eliminates most explicit waits.
1// Playwright auto-waits for ALL of these conditions before acting:
2// - Element is attached to the DOM
3// - Element is visible (not display:none, not visibility:hidden)
4// - Element is stable (not animating)
5// - Element is enabled (not disabled attribute)
6// - Element is editable (not readonly)
7// - Element receives pointer events (not obscured by overlay)
8
9// You do NOT need:
10await page.waitForSelector('.btn'); // AVOID — CSS selector, no auto-wait
11await page.waitForTimeout(2000); // NEVER — arbitrary sleep
12
13// You DO need expect() for assertions:
14await expect(page.getByRole('button')).toBeVisible(); // waits up to 5s
15await expect(page.getByRole('alert')).toContainText('Saved'); // waits for text
16
17// For elements that appear after async actions:
18await page.getByRole('button', { name: 'Save' }).click();
19// Playwright waits for the click to complete, then:
20await expect(page.getByRole('status')).toContainText('Saved successfully');
21// No need for waitForResponse or arbitrary sleep
22
23// When you genuinely need to wait for a network response:
24await page.waitForResponse('**/api/courses');
25// OR
26await Promise.all([
27 page.waitForResponse(resp => resp.url().includes('/api/checkout') && resp.status() === 200),
28 page.getByRole('button', { name: 'Pay Now' }).click(),
29]);
30
31// Adjusting timeout for slow operations:
32await expect(page.getByText('Report generated')).toBeVisible({ timeout: 30_000 });A complete course enrolment test using only semantic locators (getByRole, getByLabel, getByText) that will survive CSS refactoring, class renames, and component library migrations.
1// e2e/enrolment.spec.ts
2import { test, expect } from '@playwright/test';
3
4// Helper to log in — reused across tests
5async function loginAs(page: any, email: string, password: string) {
6 await page.goto('/login');
7 await page.getByLabel('Email').fill(email);
8 await page.getByLabel('Password').fill(password);
9 await page.getByRole('button', { name: 'Sign in' }).click();
10 await expect(page).toHaveURL('/dashboard');
11}
12
13test.describe('Course Enrolment', () => {
14 test.beforeEach(async ({ page }) => {
15 await loginAs(page, '[email protected]', 'TestPass1!');
16 });
17
18 test('student can browse and enrol in a free course', async ({ page }) => {
19 // Navigate to courses
20 await page.getByRole('link', { name: 'Courses' }).click();
21 await expect(page).toHaveURL('/courses');
22 await expect(page.getByRole('heading', { level: 1 })).toContainText('Courses');
23
24 // Find a free course using filter
25 const freeCourse = page.getByRole('article').filter({ hasText: 'Free' }).first();
26 await expect(freeCourse).toBeVisible();
27
28 const courseTitle = await freeCourse.getByRole('heading').textContent();
29
30 // Click to view course detail
31 await freeCourse.getByRole('link', { name: /learn more/i }).click();
32 await expect(page.getByRole('heading', { level: 1 })).toHaveText(courseTitle!);
33
34 // Enrol
35 await page.getByRole('button', { name: 'Enrol for Free' }).click();
36
37 // Confirmation should appear
38 await expect(page.getByRole('status')).toContainText('Successfully enrolled');
39 await expect(page.getByRole('button', { name: 'Start Learning' })).toBeVisible();
40 });
41
42 test('enrolled course appears in My Courses dashboard', async ({ page }) => {
43 // Enrol in a known free course
44 await page.goto('/courses/html-fundamentals');
45 await page.getByRole('button', { name: 'Enrol for Free' }).click();
46 await expect(page.getByRole('status')).toContainText('Successfully enrolled');
47
48 // Navigate to dashboard
49 await page.getByRole('link', { name: 'My Courses' }).click();
50 await expect(page).toHaveURL('/dashboard/courses');
51
52 // Course should appear in the enrolled list
53 const enrolledList = page.getByRole('region', { name: 'Enrolled courses' });
54 await expect(enrolledList.getByText('HTML Fundamentals')).toBeVisible();
55 });
56
57 test('attempting to enrol twice shows already enrolled message', async ({ page }) => {
58 await page.goto('/courses/html-fundamentals');
59 await page.getByRole('button', { name: 'Enrol for Free' }).click();
60 await expect(page.getByRole('status')).toContainText('Successfully enrolled');
61
62 // Reload and try again
63 await page.reload();
64 // Button should now say Continue Learning, not Enrol
65 await expect(page.getByRole('button', { name: 'Continue Learning' })).toBeVisible();
66 await expect(page.getByRole('button', { name: 'Enrol for Free' })).not.toBeVisible();
67 });
68
69 test('unenrolled user sees course preview but not full content', async ({ page }) => {
70 await page.goto('/courses/advanced-typescript');
71
72 // First lesson should be accessible as preview
73 const lessonList = page.getByRole('list', { name: 'Course curriculum' });
74 await expect(lessonList.getByRole('listitem').first()).toContainText('Preview');
75
76 // Clicking a locked lesson shows upgrade prompt
77 const lockedLesson = lessonList.getByRole('listitem').filter({
78 has: page.getByRole('img', { name: /lock/i }),
79 }).first();
80 await lockedLesson.click();
81 await expect(page.getByRole('dialog')).toContainText('Enrol to access');
82 });
83});Every locator in this test uses semantic methods: getByRole for interactive elements, getByLabel for form fields, getByText for content, and filter() with hasText for narrowing article lists. No CSS classes, no XPath, no arbitrary test IDs. The loginAs helper demonstrates extracting repeated setup into a named function. The 'attempting to enrol twice' test demonstrates state-dependent UI changes — the button text changes from 'Enrol for Free' to 'Continue Learning' after enrolment.
The next lesson covers user interactions: filling forms, clicking, hovering, keyboard navigation, file uploads, and handling dialogs and popups in Playwright.
Your progress is saved in this browser, on this device only.