You Don't Have to Leave Selenium to Get Playwright's Locators
getByRole, getByLabel and web-first assertions are what most teams actually migrate to Playwright for. They work on Selenium, in Java, on your existing Grid. Here's the API and what it replaces.
Every few months someone on the team opens the same ticket. The login test broke again.
Nobody changed the login page — a designer renamed .btn-primary to .button-primary,
and now driver.findElement(By.cssSelector("button.btn-primary[type='submit']")) throws
NoSuchElementException in eleven tests.
Somebody says it out loud: maybe we should just move to Playwright.
That’s where the conversation usually ends up, and it’s built on a false choice. The thing
your team wants from Playwright is almost never the runner, the trace viewer, or the
Node.js ecosystem. It’s the locators — getByRole, getByLabel, getByText — and
assertions that wait instead of failing on the first miss.
You can have those on Selenium. In Java. Against the Grid you already run. Without rewriting a single existing test.
The real problem isn’t Selenium
By.cssSelector and By.xpath are not broken. They do exactly what they say: find me the
element matching this description of the markup.
That’s the problem. Your test doesn’t care about the markup. It cares that there is a button a human would recognise as “Sign in.” Every time you write a CSS selector, you’re translating an intention into an implementation detail, and then your test breaks when the implementation detail changes — even though the intention didn’t.
Here’s what that costs in practice. A sign-in test, written the way most Java suites are written today:
WebDriver driver = new ChromeDriver();
driver.get("https://app.example.com");
WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(10));
wait.until(ExpectedConditions.visibilityOfElementLocated(By.cssSelector("#e")))
.sendKeys("ada@example.com");
driver.findElement(By.cssSelector("input.fld--lg[type='password']")).sendKeys("hunter2");
driver.findElement(By.cssSelector("button.btn--primary")).click();
driver.quit();
Three selectors, three separate couplings to markup that has nothing to do with signing in. One explicit wait, written by hand, that someone will forget on the next line. Plus driver setup and teardown you’ll copy into every test class.
What accessibility-first locators actually do
The idea is small: find elements the way a person does. A screen reader doesn’t look for
.btn--primary. It announces “Sign in, button.” That role-and-name pair is what the
element is, and it changes far less often than the class list.
Given this page:
<h1>Sign in to Acme</h1>
<form>
<label for="e">Email</label>
<input id="e" type="email" class="fld fld--lg" data-testid="email-input">
<label for="p">Password</label>
<input id="p" type="password" class="fld fld--lg" data-testid="password-input">
<input id="r" type="checkbox"><label for="r">Remember me</label>
<button type="submit" class="btn btn--primary">Sign in</button>
<button type="button" class="btn btn--ghost">Cancel</button>
</form>
<a href="/reset">Forgot your password?</a>
the same flow becomes:
open();
getByLabel("Email").type("ada@example.com");
getByLabel("Password").type("hunter2");
getByRole(Role.BUTTON, "Sign in").click();
No driver lifecycle — BaseTest owns it. No WebDriverWait — every action waits for the
element to be ready. No selector that knows what CSS classes exist.
Now rename .btn--primary to .button-primary. Reorder the form. Swap the <div>
wrappers for <section>. The test still passes, because none of those changed what the
button is.
The locator surface
Every one of these is available directly on a test or page object — no import, no factory, no setup:
| Call | Finds |
|---|---|
getByRole(Role.BUTTON, "Sign in") |
An element by ARIA role and accessible name |
getByLabel("Email") |
A form control by its associated <label> |
getByText("Sign in to Acme") |
An element by visible text |
getByPlaceholder("you@company.com") |
An input by its placeholder |
getByTestId("email-input") |
An element by data-testid (configurable) |
getByAltText("Company logo") |
Usually an <img>, by its alt |
getByTitle("Close") |
An element by its title attribute |
Name matching is case-insensitive substring by default, so getByRole(Role.LINK, "Forgot your password") matches a link reading “Forgot your password?”. Call .exact() when you
need the strict version.
Roles are an enum rather than a string, which means your IDE completes them and a typo is a compile error instead of a test that mysteriously finds nothing.
Test ids, when semantics run out
Not every element has a good accessible name. An icon-only button, a chart, a row in a
virtualised table — sometimes the honest answer is to ask the frontend team for a
data-testid and locate by that:
assertThat(getByTestId("email-input")).isVisible();
That’s still better than a CSS class, because a data-testid exists for your test.
Nobody renames it during a redesign, because it isn’t part of the design.
The other half: assertions that wait
Locators are only half of what teams want. The other half is assertions that don’t fail because they arrived a beat too early.
assertThat(getByRole(Role.BUTTON, "Sign in")).isVisible();
assertThat(getByLabel("Email")).hasValue("ada@example.com");
assertThat(getByRole(Role.BUTTON)).count(2);
Each of these retries until it passes or the timeout expires. That is the difference
between a suite that’s green and a suite that’s green four times out of five — and it’s
where most of the Thread.sleep(2000) calls in your codebase came from. You can delete
them.
Available on any locator: isVisible, isHidden, isEnabled, isDisabled, isChecked,
hasText, containsText, hasValue, hasAttribute, hasClass, count.
Narrowing without XPath
Real pages have twelve “Delete” buttons. The usual answer is an XPath with a positional predicate that nobody can read six months later. Instead, chain:
$("button").withText("Delete").within(By.id("orders")).nth(1).click();
Filter by CSS, by visible text, by container, by index — each one a named method rather than another segment of a selector string.
One sharp edge worth knowing before you reach for it: withText matches the element’s
entire trimmed text exactly, while getByText matches a case-insensitive substring.
So $(".order-row").withText("ORD-4417") finds nothing if the row also contains a Delete
button — its text is "ORD-4417 Delete". Use withText on leaf elements like buttons and
links, and getByText when you want to match part of a longer string.
What this is not
This is not an argument that Playwright is a bad tool. It’s an excellent one, and if you’re starting a greenfield project with no existing suite, no Grid, and no team expertise to preserve, it’s a completely reasonable default.
The argument is narrower: if you already have a Selenium suite, the locator ergonomics
are not a reason to migrate. Migration costs you every existing test, your Grid setup,
your CI wiring, and the accumulated knowledge of everyone who’s ever debugged a failure in
that suite. Paying all of that to get getByRole is a bad trade when getByRole is
available where you already are.
It’s also worth being clear that Selenium Boot never takes Selenium away from you. There’s no wrapper you have to fight when the conventions don’t fit:
String title = getDriver().getTitle();
Raw WebDriver, By and WebElement are always one call away. The conventions are a
default, not a cage.
Trying it
One dependency:
<dependency>
<groupId>io.github.seleniumboot</groupId>
<artifactId>selenium-boot</artifactId>
<version>3.3.0</version>
</dependency>
Then extend BaseTest and the locators are there. Existing tests that use By and
findElement keep working unchanged — this is additive, so you can convert the tests that
break most often and leave the rest alone.
Full reference: semantic locators, and if you’re weighing the migration question specifically, coming from Playwright.
Every code sample in this post was run against real Chrome using the HTML shown above.