Cypress to Playwright: The Syntax Translation Guide

9 min readupdated July 20, 2026

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:

  1. Cypress blocks something you truly need. Real Safari coverage, complex multi-tab or multi-domain flows, or cheap parallel runs without a paid service.
  2. 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.

now prove it

Assert the cart badge updates

Reading is the easy part. Write the test yourself, then we break the app on purpose to check it would really catch the bug.

Solve this problem →

Keep reading