Ai Driven TestingAdvanced

Migrating from Selenium to Playwright

The real migration mechanics from Selenium WebDriver to Playwright: locators, waits, grids, and what changes when ObserveOne generates and runs the suite for you.

ObserveOne Team
10 min read

Most Selenium migrations stall on the same wrong question: how do I make Playwright do what my WebDriver test does? The better question is what your WebDriver test was working around in the first place. A lot of a mature Selenium suite is compensation for things Playwright just handles: driver binaries, implicit waits, cross-browser quirks. Port that compensation code over unchanged and you end up with Playwright tests that look like Selenium tests wearing a costume.

This guide covers the actual mechanical differences, where a straight port is fine, and where you should throw the old approach out rather than translate it. If Playwright specifically hasn't been decided yet, the Selenium alternatives comparison lines up the wider field, Cypress and WebdriverIO included, before you commit to a migration path.

Driver management disappears#

A Selenium project spends real effort on drivers: pinning a chromedriver version to your installed Chrome, running webdriver-manager update, or standing up a Grid so CI has consistent browsers. None of that exists in Playwright.

npm install @playwright/test
npx playwright install

playwright install downloads browser binaries Playwright has already tested against that release, so there is no driver-to-browser version to track. If your Selenium CI config has a step that resolves or caches a driver binary, delete it. It has no Playwright equivalent because the problem it solved is gone.

Locators: stop describing addresses, start describing intent#

This is the change that actually matters, and it is not just syntax. Selenium's By.xpath and By.cssSelector describe where an element sits in the DOM. Playwright's locator API can describe what the element is:

// Selenium: an address. Breaks when a wrapper div gets added.
driver.findElement(By.cssSelector("div.form > div:nth-child(3) button"));
// Playwright: an identity. Survives DOM restructuring.
page.getByRole("button", { name: "Submit" });

getByRole and getByTestId are worth standardizing on for a reason beyond readability: ObserveOne's self-healing depends on the locator carrying enough intent to recover from. A CSS path like the one above gives a healing system nothing to work with if the path changes, because the path was the entire selector. A role-and-name locator survives markup changes the same way a human tester would, by recognizing the button rather than its coordinates. If you want the detail on what healing can and cannot recover, self-healing test automation covers the actual failure modes rather than the marketing version.

Do not treat this as a one-time find-and-replace. A Selenium suite full of #login-btn and .modal-close-x needs each selector re-evaluated against what the element actually is, not just re-encoded in Playwright's API.

Waits: delete the ones you can, keep the ones you can't#

Selenium requires explicit WebDriverWait and ExpectedConditions because findElement does not wait at all; it either finds the element now or throws. Most Selenium suites accumulate a lot of these:

// Selenium
WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(10));
wait.until(ExpectedConditions.elementToBeClickable(By.id("submit")));
driver.findElement(By.id("submit")).click();

Playwright's actions wait automatically for the element to be attached, visible, stable, and enabled before acting:

// Playwright: the click itself waits
await page.getByRole("button", { name: "Submit" }).click();

That removes the wait boilerplate for anything tied to an action. It does not remove waits for state that isn't an action target: waiting for a toast to appear before asserting its text, or waiting for a network response before checking what rendered. Those still need an explicit await expect(locator).toBeVisible() or page.waitForResponse(). The mistake teams make going the other direction is assuming Playwright's auto-wait covers everything and ripping out every wait indiscriminately, which just relocates the flakiness from "wait timing" to "assertion ran before the state existed."

Assertions: less library, more built-in#

A Selenium suite usually leans on JUnit's assertEquals or TestNG's Assert, called after the fact once you already have a value in hand:

String text = driver.findElement(By.id("status")).getText();
Assert.assertEquals(text, "Order confirmed");

That pattern has a race condition baked in: getText() reads whatever is there right now, so if the confirmation hasn't rendered yet, the assertion fails on timing rather than on the app being wrong. Playwright's expect is web-first, meaning it retries against the locator until the condition is true or a timeout is hit:

await expect(page.locator("#status")).toHaveText("Order confirmed");

If you're porting Selenium assertions mechanically, this is the pattern to catch: anywhere the old test read a value and then compared it, the new version should hand the locator to expect directly rather than reading first and comparing after. Reading first throws away the retry behavior that fixes half of what used to be a flaky assertion.

Grid and parallelization: this is a subtraction, not a port#

Selenium Grid exists to run tests across machines and browser versions at the same time, and configuring it, hub, nodes, capacities, is its own maintenance job. Playwright's projects config in playwright.config.ts runs the same suite against multiple browsers with no separate infrastructure:

export default defineConfig({
projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
{ name: "firefox", use: { ...devices["Desktop Firefox"] } },
{ name: "webkit", use: { ...devices["Desktop Safari"] } },
],
workers: process.env.CI ? 4 : undefined,
});

If migrating means moving execution to ObserveOne rather than your own CI runner, there is no Grid-equivalent to configure at all. A generated suite already runs on ObserveOne's own infrastructure; you are not standing up parallel workers, you are pointing at a URL.

Generating a parity suite instead of hand-porting it#

Hand-translating fifty Selenium tests one at a time is the slow way to do this. ObserveOne's CLI can crawl your app and generate an initial Playwright suite directly:

obs suite generate https://app.example.com \
--name "Checkout flow" \
--var [email protected] --var PASSWORD=secret \
--max-tests 15

That command plans test scenarios against the live app, then generates a Playwright test file per planned scenario. A suite moves through real states as it does this, visible if you check on it: pending, crawling, planning, generating, and (if a selector needs recovery mid-generation) healing, before landing on scheduled or failed. Credentials passed with --var are stored against the suite, not in the generated test files, and --var-file loads them from an existing .env instead of putting them on the command line where they end up in shell history.

This is a starting point, not a finished migration. Generated tests cover what the crawler could reach and infer from the page; anything behind a multi-step wizard, a flow the crawler couldn't guess the intent of, or business logic your Selenium tests encoded from tribal knowledge still needs a human to write or correct the test. Treat the generated suite as the boilerplate you'd otherwise hand-write, then edit in the assertions and edge cases that mattered enough to be in the old suite in the first place.

Two gotchas that come up if you're moving credentials over from a Selenium project's environment file. Variable keys get normalized: --var "email address=x" and --var EMAIL_ADDRESS=x both end up stored as EMAIL_ADDRESS, uppercased with spaces converted to underscores, so don't assume the key you typed is the key that lands. And a form the crawler treats as anything other than login is left alone by default; a checkout form, a "delete account" button, or a newsletter signup won't get submitted during generation unless you explicitly pass --allow-form-submit, which exists precisely so the crawler doesn't fire off real side effects against a real form while it's mapping your app.

Once a suite exists, running it and scheduling it are separate, deliberate steps rather than something baked into a Grid or Jenkins job:

obs suite run <suite-id> --wait
obs suite schedule <suite-id> --cron "0 */6 * * *" --enable

obs suite run --wait blocks until the execution finishes and prints pass/fail per test, which is what you'd wire into a CI gate. obs suite schedule is separate from running once, for the recurring monitoring case a one-off migration usually didn't have: your Selenium Grid job likely only ran on a CI trigger, not continuously.

Replacing the Jenkins or Grid CI step#

A Selenium CI job usually does three things: start a Grid or spin up driver containers, run the suite against it, and fail the build on any red test. With ObserveOne, none of that setup step exists, because there's no Grid to start. A GitHub Actions job that gates a merge on the suite passing is just the CLI, authenticated with an API key from the environment rather than an interactive login:

- name: Run regression suite
run: obs suite run $SUITE_ID --wait
env:
OBS_API_KEY: ${{ secrets.OBS_API_KEY }}

obs suite run --wait exits non-zero if any test failed, which is what a CI gate checks, so this replaces the Grid-provisioning step and the assertion step in one line instead of two. If you only want to re-run a subset, --tests <id1,id2> runs specific tests from the suite instead of the whole thing, which is useful for a smoke-test-only merge gate versus a full nightly run on a schedule.

What you lose going this route, stated plainly#

Selenium's WebDriver protocol has bindings in Java, Python, C#, Ruby, and JavaScript, and a Selenium Grid can run whatever mix of those your teams wrote. ObserveOne's generated and hosted suites are TypeScript Playwright tests, full stop. If your organization has Selenium tests in Java maintained by a team that has no interest in touching TypeScript, generating an ObserveOne suite does not migrate that team's workflow, it replaces it with a different one, and that is a people decision as much as a technical one.

Config-as-code migrates more cleanly than test code does. If your Selenium setup is paired with infrastructure scripts that provision monitors or checks alongside the tests, obs export pulls your existing ObserveOne resources into a JSON file and obs apply syncs a JSON file back, which is closer to how a Selenium Grid's own config-as-code setup usually works than it is to porting test logic.

Where a straight code port is genuinely fine#

Page object structure and test data management do not need to change in spirit, only in API. A page object class that wraps interactions with a page still makes sense in Playwright, using page.locator() calls instead of driver.findElement(). If your Selenium suite already isolates test data from test logic, keep doing that; nothing about Playwright rewards or punishes that decision differently than Selenium did.

Next steps#

Run obs suite generate against a staging environment first, not production, since it interacts with real forms and buttons while crawling. Review what it planned before letting it generate every test, using --plan-only to stop after planning and inspect the scenarios. For the handful of Selenium tests that encode business logic no crawler could infer, port those by hand using the locator and wait guidance above rather than waiting for a generator to guess your intent correctly.

Related comparisons

Weighing the tools you just read about? Here's how they stack up side by side.

See alternatives to your stack

Looking to swap one of these out? Here's where to start.

Ready for AI-Powered Testing?

ObserveOne monitors your selectors 24/7 and automatically heals them when websites change. Never deal with broken tests again.

Start Free Trial