This episode covers locator strategies based on text, role, CSS, and XPath, basic interactions like click, fill, select, and hover, form submission and navigation, as well as how auto-waiting and retry-ability work behind every action.

The architecture is clear and the environment is ready. Now comes the most core skill in writing tests: locating elements on the page and interacting with them. This episode 4 is where you learn to write the actions you'll actually use every day — click, fill input, select options, hover, and navigate.
The key to this section is choosing the right locator. A good locator makes tests durable, easy to read, and rarely breaks when the application changes. A bad locator — like CSS selectors that depend on deep structure — becomes a major source of flaky tests and a maintenance nightmare. Let's learn how to do it right from the foundation.
The most human way is to find elements by their text:
await page.getByText('Welcome');
await page.getByText('Price', { exact: true });
await page.getByLabel('Email');page.getByText() finds elements containing a specific text. Add { exact: true } if you want an exact match. page.getByLabel() is very useful for form inputs associated with a label.
Role is the most recommended approach because it reflects how users and assistive technologies view the page:
await page.getByRole('button', { name: 'Sign in' });
await page.getByRole('link', { name: 'Register' });
await page.getByRole('heading', { level: 1 });
await page.getByRole('textbox', { name: 'Full Name' });page.getByRole('button', { name: 'Sign in' }) finds a button whose accessible name is 'Sign in'. The role approach makes tests resilient to CSS class changes and DOM structure.
For cases that roles can't reach, you can use plain CSS or XPath:
await page.locator('.product-card');
await page.locator('#total-price');
await page.locator('xpath=//input[@name="qty"]');CSS selectors are a familiar shortcut, while XPath is used when you need complex traversal logic. Prefer role and text first, then fall back to CSS/XPath as a backup.
Once an element is found, you can interact with it:
await page.getByRole('button', { name: 'Buy' }).click();
await page.getByLabel('Email').fill('user@example.com');
await page.getByRole('combobox').selectOption({ label: 'Jabodetabek' });
await page.getByRole('menuitem').hover();click() clicks an element.fill() fills an input, clearing the old value first.selectOption() selects an option in a select element.hover() moves the mouse pointer over an element.All of these actions apply auto-waiting: Playwright waits for the element to exist, be visible, enabled, and stable before performing the action.
A few specific interactions:
await page.getByRole('checkbox').check();
await page.getByLabel('Photo').setInputFiles('photo.jpg');
await page.getByLabel('Password').pressSequentially('secret123');setInputFiles() handles file uploads, and pressSequentially() types characters one at a time — useful for testing autocomplete.
Here's an example test that fills in a login form and navigates after submit:
import { test, expect } from '@playwright/test';
test('login and go to the dashboard', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('user@example.com');
await page.getByLabel('Password').fill('secret123');
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page).toHaveURL(/dashboard/);
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
});page.goto('/login') uses the baseURL from the configuration, so you only need to write the relative path. After submit, toHaveURL(/dashboard/) confirms the navigation happened, and toBeVisible() confirms the dashboard content is displayed.
Beyond the URL, you can check the page state broadly:
await expect(page).toHaveTitle(/Dashboard/);
await expect(page).toHaveScreenshot();
await expect(page.locator('.summary')).toBeVisible();toHaveTitle() checks the document title, and toHaveScreenshot() is the gateway to visual regression, which we'll discuss in episode 16.
Every Playwright action works by the rule: find the element, wait for it to be actionable, perform the action, verify. If the element hasn't appeared yet, Playwright waits until the assertion timeout is reached. This is called retry-ability — actions and assertions re-evaluate until they succeed or time out.
Unlike Selenium, which often fails when an element isn't there yet, Playwright waits for the right conditions by default. That means you rarely need to write manual waits. If you do need more precise time control, we'll cover it specifically in episode 5 about waits and synchronization.
npx playwright test tests/interactions.spec.tsThe command npx playwright test tests/interactions.spec.ts runs the interactions test file. If a test fails, read the timeout message — it's the first clue whether the locator is wrong or the element simply hasn't appeared.
Episode 4 equipped you with the core skills: choosing text-, role-, CSS-, and XPath-based locators with the right hierarchy, running basic interactions like click, fill, select, and hover, testing form flows and navigation, and understanding the auto-waiting and retry-ability behind every action.
Key takeaways:
getByRole and getByLabel before dropping down to CSS or XPath.fill clears the input before typing; use pressSequentially for autocomplete.toHaveURL and toBeVisible to verify action results.In the next episode we'll discuss waits and synchronization — a deep understanding of Playwright's default auto-waiting, explicit waits with waitForResponse and waitForLoadState, handling dynamic content, and how to make tests stable for SPA and real-time applications.