If you know Cypress and you are moving to Playwright, most of what you know still applies. You still find elements, act on them, and check the result. What changes is the shape of the code.
This page is the translation table, plus the four differences that will actually catch you out. I have watched several people move between these tools, and it is never the table that causes problems. It is the four things after it.
The translation table
| What you want | Cypress | Playwright |
|---|---|---|
| Visit a page | cy.visit('/') |
await page.goto('/') |
| Find by test id | cy.get('[data-testid="save"]') |
page.getByTestId('save') |
| Find by text | cy.contains('Save') |
page.getByText('Save') |
| Find a button by name | cy.contains('button', 'Save') |
page.getByRole('button', { name: 'Save' }) |
| Find a field by label | cy.get('#email') |
page.getByLabel('Email') |
| Click | .click() |
await locator.click() |
| Type text | .type('hello') |
await locator.fill('hello') |
| Clear then type | .clear().type('x') |
await locator.fill('x') |
| Select an option | .select('Customer') |
await locator.selectOption('Customer') |
| Check a checkbox | .check() |
await locator.check() |
| Element is visible | .should('be.visible') |
await expect(locator).toBeVisible() |
| Exact text | .should('have.text', '1') |
await expect(locator).toHaveText('1') |
| Contains text | .should('contain', 'Saved') |
await expect(locator).toContainText('Saved') |
| Count elements | .should('have.length', 3) |
await expect(locator).toHaveCount(3) |
| Does not exist | .should('not.exist') |
await expect(locator).toBeHidden() |
| First match | .first() |
.first() |
| Watch a request | cy.intercept('POST', '/api/x').as('save') |
page.waitForResponse('**/api/x') |
| Wait for that request | cy.wait('@save') |
await savePromise |
| Fake a response | cy.intercept(url, { body }) |
await page.route(url, r => r.fulfill({ body })) |
| Setup before each test | beforeEach(() => {}) |
test.beforeEach(async ({ page }) => {}) |
| Group tests | describe('x', () => {}) |
test.describe('x', () => {}) |
| One test | it('does x', () => {}) |
test('does x', async ({ page }) => {}) |
That table covers maybe 90% of a normal suite. Now the parts that break.
Difference 1: everything is awaited
This is the big one, and it is the reason a copied Cypress test does not work.
Cypress builds a queue. You write commands, Cypress runs them in order afterwards. You never write await, and in fact await on a cy command does not work at all.
Playwright is normal asynchronous JavaScript. Every command that touches the browser returns a promise, and you await it:
// Cypress
cy.get('[data-testid="save"]').click();
// Playwright
await page.getByTestId('save').click();
Miss one await and your test runs ahead of the browser. That is one of the very few ways to make a Playwright test unstable, so let TypeScript warn you and pay attention when it does.
There is one useful exception. Creating a locator is not a browser action, so it is not awaited:
const saveButton = page.getByTestId('save'); // no await, just a description
await saveButton.click(); // await. This touches the browser
This is worth understanding, because it is also the answer to something Cypress users miss. In Cypress you cannot store an element in a variable and reuse it. In Playwright you can, because a locator is a description that is resolved fresh each time you use it, not a captured element.
Difference 2: strict mode
In Cypress, cy.get('button') on a page with twelve buttons yields all twelve. Add .click() and Cypress complains, but many commands quietly work on the collection.
Playwright is stricter. If a locator matches more than one element and you try to act on it, the test fails:
strict mode violation: getByRole('button') resolved to 12 elements
The first time you see this it feels like the tool being difficult. It is not. It just caught a selector bug for you: your test was about to click something and you did not know which. Fix it by being specific:
await page.getByRole('button', { name: 'Add to cart' }).first().click();
Use .first() when any match is genuinely fine, such as "add any product". If a specific element matters, describe that element properly instead.
Difference 3: chaining works differently
Cypress chains from a subject. Each command passes its result to the next:
cy.get('[data-testid="row"]').find('.name').should('contain', 'Maya');
Playwright chains by narrowing the locator, and reads more like a query:
await expect(page.getByTestId('row').locator('.name')).toContainText('Maya');
The bigger change is what happens when you want the value itself. In Cypress you use .then():
cy.get('[data-testid="total"]').then(($el) => {
const value = $el.text();
});
In Playwright you just await it, because it is ordinary JavaScript:
const value = await page.getByTestId('total').textContent();
A warning that costs people real time. The moment you pull a value into a variable, you lose the automatic retrying. This looks correct and is unstable:
const text = await page.getByTestId('cart-count').textContent();
expect(text).toBe('1'); // reads once, no retry
Write it as a web-first assertion instead, so it retries until the value updates:
await expect(page.getByTestId('cart-count')).toHaveText('1');
If I had to name the single most common mistake among Cypress users learning Playwright, it is this one. The two versions look nearly identical, and only one of them waits.
Difference 4: waiting on the network
Both tools let you wait for a real request instead of guessing a duration. The order of the code is different, and getting it wrong causes a hang.
Cypress registers the watch, then you act, then you wait by name:
cy.intercept('POST', '/api/contacts').as('save');
cy.get('[data-testid="save-contact"]').click();
cy.wait('@save');
Playwright starts the wait as a promise before the action, then awaits it after:
const savePromise = page.waitForResponse('**/api/contacts');
await page.getByTestId('save-contact').click();
const response = await savePromise;
expect(response.status()).toBe(201);
If you click first and then call waitForResponse, the response may already have arrived, and your test waits until it times out for something that has passed.
What stays exactly the same
It is worth saying clearly, because tool changes feel bigger than they are.
Every habit that makes a test good is unchanged. Check the value, not just that an element exists. Count the rows after a filter instead of finding one match. Test what a form refuses, not only what it accepts. Give each test its own data. Never wait for a number of milliseconds.
Those habits are the actual skill. The syntax is a table you can look up.
Should you migrate at all?
Since you are reading this, here is the honest answer, and it is usually no.
A migration takes months and ends with exactly the coverage you already had. That is very hard to justify to a manager, and I have seen teams stall halfway and maintain two suites for a year.
Migration makes sense in two cases:
- Cypress blocks something you truly need. Real Safari coverage, complex multi-tab or multi-domain flows, or cheap parallel runs without a paid service.
- You are rewriting the tests anyway because the suite is unreliable. Then migrating during the rewrite is nearly free, since the work was already happening.
If you do migrate, do it gradually. Run both suites. Move the most valuable tests first. Do not switch off the old suite until the new one covers the same ground.