Home / Services / Selenium to Playwright migration

Selenium to Playwright migration, test by test

At the end of it you have a Playwright suite in your repository, running on every commit in your pipeline, and a Grid with nothing left to do.

Short answer

We move Selenium suites onto Playwright: the tests, the waits, the page objects and the CI job. Implicit waits, stale-element retry helpers and the Grid have no Playwright equivalent, so we name which parts of your suite that hits before the work starts. Engineers are billed hourly, from $50 an hour, minimum one full-time engineer for one month.

The first conversation is a scoping one: what is in the suite, and whether converting it is worth what it would cost you.

Who this is for

Teams who decided on Playwright months ago and have been stuck on the same question since: what happens to the tests they already have.

  • A suite big enough that converting it by hand would eat a quarter of somebody's roadmap.
  • A Grid that one person understands and nobody wants to own.
  • A flake rate that has taught everyone to re-run a red build rather than read it.

If your suite is Java, the page objects are annotated with @FindBy and the waits are WebDriverWait. That is a different conversion from a suite of inline driver.findElement calls, and the language is the first thing we ask about, ahead of the test count. It does not have to become TypeScript to become Playwright: we deliver a suite in any of the four official bindings, so a Java team keeps writing Java. The language does decide the harness around the tests: Playwright's own test runner ships only with the JavaScript and TypeScript binding, so a Java suite runs under JUnit or TestNG and is sharded and reported through that instead of through the config shown below.

Forty tests that run green in six minutes do not need us. Convert them yourself over a fortnight — everything below about waits, locators and the Grid still applies, and none of it needs a contract. If there is a Cypress or Protractor suite in the same repository, the method that covers all of them is on Playwright migration services.

What you get

The converted suite

Playwright specs in your repository, reviewed through your pull requests. Every test that was quarantined is either fixed or listed as deliberately dropped, with the reason next to it.

The CI job that runs it

The pipeline definition, sharded across jobs, with the reporter wired up and traces kept on retry. It does the work the Grid was doing.

The translation record

One file saying which Selenium idiom became which Playwright one, and which were deleted with nothing put back. It keeps your engineers able to read the new suite in week one.

How it works

  1. Inventory

    We read the suite and the last few weeks of CI history: how many tests there are, how many pass today, how many are quarantined, which selectors are XPath, how many pipelines run any of it. The tests that were already flaky in Selenium get named here, so that nobody blames Playwright for them in month two. You give us repository access, a look at the pipeline, and one engineer who can answer questions about why a thing is the way it is.

  2. A pilot slice, converted end to end

    One vertical slice of the suite — a login, a checkout, whatever your riskiest flow is — converted, running in CI, reviewed by your team. The translation gets argued over working tests instead of in a document, and the rest of the conversion follows what the pilot settles.

  3. The bulk conversion

    Test by test, in reviewable batches, against the vocabulary the pilot settled. Selectors that quietly matched more than one element start failing in this phase, which is the first time anyone finds out about them. It costs your side review time: the batches arrive as pull requests and somebody who knows the product has to read them. Each batch merges as it is reviewed, so if the engagement stops here the converted tests are already in your repository and the Selenium suite is still running.

  4. Cutover

    Both suites run on every commit. The Selenium job stays in the pipeline until the Playwright job has been green on the same commits for long enough that your team believes it, and then it comes out.

There are no weeks in that list. The inventory is what puts a number on it, and it does that against your repository rather than against an average, which is why the phases say what they do and not how long they take. What gets counted is in the questions at the foot of this page.

What changes in the code

A conversion is done by engineers reading tests, one at a time. The Selenium below is Selenium 4: several of the things people repeat about Selenium were true of Selenium 3 and stopped being true when 4 shipped.

One test — sign in, wait, assert — as it stands today:

import java.time.Duration;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.openqa.selenium.By;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.chrome.ChromeDriver;
import org.openqa.selenium.support.ui.ExpectedConditions;
import org.openqa.selenium.support.ui.WebDriverWait;

class LoginTest {
  @Test
  void signsIn() {
    WebDriver driver = new ChromeDriver();
    try {
      driver.get("https://example.com/login");
      driver.findElement(By.id("username")).sendKeys("ada");
      driver.findElement(By.id("password")).sendKeys("hunter2");
      driver.findElement(By.cssSelector("button[type='submit']")).click();

      WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(10));
      WebElement banner = wait.until(
          ExpectedConditions.visibilityOfElementLocated(By.cssSelector(".welcome")));

      Assertions.assertEquals("Welcome, Ada", banner.getText());
    } finally {
      driver.quit();
    }
  }
}

And the same test after:

import { test, expect } from '@playwright/test';

test('signs in', async ({ page }) => {
  await page.goto('https://example.com/login');
  await page.getByLabel('Username').fill('ada');
  await page.getByLabel('Password').fill('hunter2');
  await page.getByRole('button', { name: 'Sign in' }).click();

  await expect(page.getByText('Welcome, Ada')).toBeVisible();
});
Every sample on this page is written against Playwright 1.62.

The driver lifecycle and the explicit wait disappear, and the test body is down to five lines. The converted file is also still recognisable: page objects survive as plain classes, and your XPath runs. page.locator('xpath=//button') is a supported locator, and Playwright reads any selector string starting with // as XPath without the prefix. Playwright's other-locators documentation still recommends user-visible locators over XPath tied to the implementation, and it states that XPath does not pierce shadow roots. A suite of plain XPath against server-rendered HTML ports on the first afternoon. A suite of XPath against a component library with shadow DOM has selectors that cannot port at all, and finding out which of the two you have moves the number further than the test count does.

The wait that becomes no wait

Somewhere in your repository is a wait helper that has been growing for three years. In Selenium it earns its place:

import java.time.Duration;
import org.openqa.selenium.By;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.support.ui.ExpectedConditions;
import org.openqa.selenium.support.ui.WebDriverWait;

class Checkout {
  static void placeOrder(WebDriver driver) {
    WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(10));
    wait.until(ExpectedConditions.elementToBeClickable(By.id("place-order"))).click();
  }
}

In Playwright there is nothing to translate. The helper is deleted:

import { test } from '@playwright/test';

test('places the order', async ({ page }) => {
  await page.goto('https://example.com/cart');
  await page.getByRole('button', { name: 'Place order' }).click();
});

Before that click runs, Playwright checks that the locator resolves to exactly one element and that the element is visible, stable, receives events and is enabled, and it waits for those checks to pass within the timeout. Assertions retry on the same principle: expect(locator).toBeVisible() keeps checking until it passes or the assertion timeout is reached, five seconds by default. Deleting that helper is part of why the converted suite is shorter.

Idioms with no clean equivalent

  • driver.manage().timeouts().implicitlyWait(...) — nothing replaces it, because Playwright has no global implicit wait. There is a timeout per test, thirty seconds by default; a timeout per assertion, five seconds by default; and an optional timeout per action. Selenium 4's own documentation warns against mixing implicit and explicit waits, because the two combine into wait times nobody predicted. A suite that mixed the two has behaviour to unwind before any of it can be translated faithfully. Budget for that separately.
  • StaleElementReferenceException and the retry loop around it — the exception has no counterpart. A Playwright locator is a description of how to find an element, and the documentation is explicit that every time a locator is used for an action, an up-to-date DOM element is located in the page. Nothing is held between calls, so nothing goes stale. Deleting the retry loop usually takes out the most-edited file in the repository.
  • @FindBy and PageFactory — there is no annotation-driven page factory. A page object becomes a plain class that holds locators and exposes methods, which is a small change to the file and a large change to a Java team's habits: the field is no longer populated for you, and the constructor stops being ceremonial.
  • Actions chains and JavascriptExecutor — some of this maps straight over. Hover, drag and keypress become locator.hover(), locator.dragTo() and locator.press(). The JavascriptExecutor calls are the ones to read individually: a scripted click was usually there to get past something the driver refused to do, so check whether Playwright still needs the escape hatch before porting one.

The same selector, a different outcome

A selector can port without a single edit and still fail:

import { test, expect } from '@playwright/test';

// Throws a strict mode violation when the invoice table has more than one Delete button.
test('deletes an invoice - ambiguous', async ({ page }) => {
  await page.goto('https://example.com/invoices');
  await page.locator('//button[text()="Delete"]').click();
});

// Names one row, so it can only hit one button.
test('deletes an invoice - unambiguous', async ({ page }) => {
  await page.goto('https://example.com/invoices');
  await page.getByRole('row', { name: 'INV-1042' })
    .getByRole('button', { name: 'Delete' })
    .click();

  await expect(page.getByText('INV-1042')).toHaveCount(0);
});

The Playwright locator documentation puts it this way: "Locators are strict. This means that all operations on locators that imply some target DOM element will throw an exception if more than one element matches." Selenium 4's documentation describes the other behaviour just as plainly: the singular find element method returns a reference to the first element found within a given context. Some share of your suite will fail on conversion for this reason alone, and each of those failures is a test that had been asserting against whichever element the DOM happened to put first. We have not measured what that share is, so we do not publish one.

Where the fix has to be quick, locator.first() is the retreat. The repair is a locator that can only match one thing, and the documentation recommends seven built-in ones for the job: getByRole, getByText, getByLabel, getByPlaceholder, getByAltText, getByTitle and getByTestId. Your converted suite ends up written in that vocabulary, and the translation record maps your old selectors onto it.

What replaces the Grid

Concurrency stops being a machine somebody maintains and becomes a config file:

import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  workers: process.env.CI ? 4 : undefined,
  reporter: [['html'], ['list']],
  use: {
    baseURL: 'https://staging.example.com',
    trace: 'on-first-retry',
  },
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'firefox', use: { ...devices['Desktop Firefox'] } },
    { name: 'webkit', use: { ...devices['Desktop Safari'] } },
  ],
});

Workers are the parallelism on one machine. Across machines, npx playwright test --shard=1/4 in four CI jobs runs a quarter of the suite in each. The webkit project runs Playwright's WebKit build, which is not Safari on an iPhone. That distinction is one of the few places a Selenium suite loses coverage in the move.

Where this stops

A Selenium suite that has been running for four years usually does something Playwright cannot. We do not quote for those parts.

  • Anything driving a native mobile app. If part of the suite drives Appium against an iOS or Android build, that part does not move. Playwright drives browsers: it emulates a mobile browser, which covers your responsive web app at a phone-sized viewport, and it cannot drive a native application. Those tests stay where they are, and we do not sell a replacement for them.
  • A Grid that exists to generate load. If hundreds of sessions are being launched to put the application under stress, Playwright is not what replaces that. It is not k6, JMeter or Gatling, and we do not sell load testing.
  • Safari on a real iOS device. Playwright's WebKit build is not Safari on an iPhone. If your Selenium suite reached real Safari through a device cloud, that coverage does not come across, and the closest Playwright gets is the WebKit project in the config above. Teams that need both keep a small device-cloud suite and move the rest.
  • Internet Explorer. Not supported by the framework. IE tests do not move, and there is no version of this engagement in which they do.
  • Security and penetration testing. Not offered here, in any framework.

What it costs

A migration is priced by the time it takes, which is why we price the engineers rather than the suite. Engineers are billed hourly, from $50 an hour, depending on where the engineer sits. The minimum engagement is one full-time engineer for one month.

For a migration, the number turns on the size of the suite, the language it is written in, how much of it is page objects rather than inline driver calls, and how much of the suite is worth keeping. That last one is the cheapest lever you have. A test that has been quarantined for a year is a test somebody already decided to live without, and converting it costs the same as converting one that works.

Read first

Questions

How long does it take to migrate a Selenium suite to Playwright?

We do not publish a number, because the numbers that circulate belong to suites nobody describes. What decides it: how many tests there are, how many of them pass today, how much of the suite is page objects rather than inline driver calls, how many pipelines run it, and how much of the test data is set up by clicking through the UI. The inventory phase measures those on your repository, and the schedule for the bulk conversion is built from what it finds.

Can we keep our tests in Java, or does everything become TypeScript?

You can keep them in Java. Playwright ships four official bindings — TypeScript and JavaScript, Python, Java and .NET — and we deliver a suite in all four, so a Java suite can stay Java and nobody on your team has to learn TypeScript to read the tests afterwards. The samples on this page are TypeScript because that is the language this site is written in.

What happens to our Selenium Grid?

When the migration is finished, nothing runs on it. Playwright installs its own browsers and runs them where the tests run, so the concurrency the Grid was giving you comes from workers in the config file and from sharding the run across CI jobs. For the period in between there is a bridge: pointing SELENIUM_REMOTE_URL at your hub makes Playwright launch browsers on the Grid instead. The Playwright documentation marks that experimental, it works for Google Chrome and Microsoft Edge only, and it depends on Selenium 4 exposing CDP over a websocket, which the docs say may not always be the case. Treat it as a temporary measure and take the cutover date seriously.

Can we run both suites while the migration is in progress?

Yes, and it is how this is done. The Selenium suite keeps running on the same commits until the Playwright suite has passed on them too, so there is never a morning when the only coverage you have is the one still being built. The two jobs sit side by side in the pipeline, and the Selenium job is switched off after the Playwright job has been green on the same commits for long enough that your team believes it.

Do we need the audit before you start?

No. Most teams arrive knowing the job, and a migration is the clearest version of that: you have a Selenium suite and you want it in Playwright, so we scope that and get on with it. The audit is there for a team that wants to know what it has before it picks a direction. It is optional, it is billed by the hour, and how long it takes depends on the project. The minimum of one full-time engineer for one month does not apply to it.

Tell us how big the suite is

The test count, the language it is written in and a link to a recent CI run are enough for us to come back with a shape for the work. We do not need an audit to start. If your next question is who runs the suite once it is converted, that is a separate engagement.