Cypress vs Playwright: What Is the Difference?

Gleb Bahmutov

LA Fires

brought to you by the fossil fuel companies

image source: https://www.dailysabah.com/world/americas/more-damage-anticipated-as-california-fire-season-sets-records

Control your life

Join an organization

Vote

Greenpeace Β 350 Β Sunrise Β Citizen Climate Lobby

Speaker: Gleb Bahmutov PhD

C / C++ / C# / Java / CoffeeScript / JavaScript / Node / Angular / Vue / Cycle.js / functional programming / testing

Gleb Bahmutov

Sr Director of Engineering

🌎 πŸ”₯ 350.org 🌎 πŸ”₯ citizensclimatelobby.org 🌎 πŸ”₯

Mercari Does A Lot Of Testing

source: https://npmtrends.com/@playwright/test-vs-cypress

Presentation

  • Test runner architecture
  • Syntax examples
  • Capabilities
  • Component testing
  • A big table of checkboxes
  • My partial opinion

All code examples come from the OSS repo

bahmutov/cypress-workshop-cy-vs-pw

import { test } from '@playwright/test'

test('logs hello', async () => {
  console.log('hello, world')
})
it('logs hello', () => {
  console.log('hello, world')
})

// pw

// cy

// pw terminal output

// cy browser DevTools console

Node.js

Playwright test code

Browser

application

Chrome Debugger Protocol

Node.js

Config / plugins file

Browser

application

Cypress test code

WebSocket

for

cy.task

Test runners architecture

const { test, expect } = require('@playwright/test')

test('has title', async ({ page }) => {
  await page.goto('http://localhost:3000/')
  await expect(page).toHaveTitle('todomvc')
})
it('has title', () => {
  cy.visit('http://localhost:3000/')
  cy.title().should('equal', 'todomvc')
})

// pw

// cy

Syntax difference

const { test, expect } = require('@playwright/test')

test('has title', async ({ page }) => {
  await page.goto('http://localhost:3000/')
  await expect(page).toHaveTitle('todomvc')
})

// pw

it('has title', () => {
  cy.visit('http://localhost:3000/')
  cy.title().should('equal', 'todomvc')
})

// cy

Of course the test is async

Of course loading the page is async

Of course the test must wait for the command to finish

it('has title', () => {
  cy.visit('http://localhost:3000/')
  cy.title().should('equal', 'todomvc')
})

// cy

const { test, expect } = require('@playwright/test')

test('has title', async ({ page }) => {
  await page.goto('http://localhost:3000/')
  await expect(page).toHaveTitle('todomvc')
})

// pw

Of course globals are bad

// confirm there are 3 todo items on the page
// use the CSS selector ".todo-list li"
const selector = '.todo-list li'
await expect(page.locator(selector)).toHaveCount(3)

// pw

cy.get('.todo-list li').should('have.length', 3)

// cy

PW UI

CY UI

test('adding todos', async ({ page }) => {
  const input = page.getByPlaceholder('What needs to be done?')
  const todos = page.locator('.todo-list li label')

  await page.goto('/')
  await page.locator('body.loaded').waitFor()
  await expect(todos).toHaveCount(0)
  await input.fill('Write code')
  await input.press('Enter')
  await expect(todos).toHaveText(['Write code'])
})

// pw

Interacting with elements

it('adding todos', () => {
  const input = '[placeholder="What needs to be done?"]'
  const todos = '.todo-list li label'

  cy.visit('/')
  cy.get('body.loaded').should('be.visible')
  cy.get(todos).should('not.exist')
  cy.get(input).type('Write code{enter}')
  cy.get(todos).should('have.length', 1)
    .and('have.text', 'Write code')
})

// cy

Interacting with elements

test.beforeEach(async ({ request }) => {
  await request.post('http://localhost:3000/reset', 
                     { data: { todos: [] } })
})
beforeEach(() => {
  cy.request('POST', 
    'http://localhost:3000/reset', { todos: [] })
})

// pw

// cy

API requests

GET /todos

loads the data

await page.route('/todos', (route) =>
  route.fulfill({
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(items)
  })
)

// pw

Network stub

cy.intercept('/todos', { fixture: 'products.json' })
  .as('load')

// cy

Network stub

Stub GET /todos

Spy on POST /todos

await page.route('/todos', (route) => {
  if (route.request().method() === 'GET') {
    return route.fulfill({
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify([])
    })
  } else {
    return route.continue()
  }
})

// pw

Stub GET /todos

Spy on POST /todos

cy.intercept('GET', '/todos', { body: [] })
cy.intercept('POST', '/todos').as('post-todo')

// cy

// spy on the "POST /todos" call
const postTodo = page.waitForRequest((req) => {
  return req.method() === 'POST' && 
    req.url().endsWith('/todos')
})
const request = await postTodo
const sent = request.postDataJSON()
expect(sent, 'request data').toEqual({
  title: 'Learn testing',
  completed: false,
  id: expect.any(String)
})

// pw

Check network spy

Check network spy

cy.intercept('POST', '/todos').as('post-todo')
cy.wait('@post-todo')
  .its('request.body')
  .should('deep.include', {
  	title: 'Learn testing',
  	completed: false
  })
  .its('id')
  .should('be.a', 'string')

// cy

await page.route('/todos', (route) => {
  setTimeout(() => {
    route.continue()
  }, 2000)
})

// pw

Delay network call by 2s

Delay network call by 2s

cy.intercept('GET', '/todos', 
   () => Cypress.Promise.delay(2000))

// cy

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

test('adds todos', async ({ page, request }) => {
  await request.post('/reset', { data: { todos: [] } })
  await page.goto('/')
  // create a new todo locator
  const newTodo = page.getByPlaceholder('What needs to be done?')
  await newTodo.fill('one')
  await newTodo.press('Enter')
  await newTodo.fill('two')
  await newTodo.press('Enter')
  const todoItems = page.locator('li.todo')
  await expect(todoItems).toHaveCount(2)
})
it('adds todos', () => {
  cy.request('POST', '/reset', { todos: [] })
  cy.visit('/')
  cy.get('.new-todo').type('one{enter}').type('two{enter}')
  cy.get('li.todo').should('have.length', 2)
})

// cy

The Syntax

// pw

  • imperative syntax
  • promise-based
  • declarative syntax
  • reactive stream

gleb.devΒ  Β  Β  Β  Β  Β  Β  Β  Β  Β  Β https://slides.com/bahmutov/the-debate-cy-and-pw

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

test('adds todos', async ({ page, request }) => {
  await request.post('/reset', { data: { todos: [] } })
  await page.goto('/')
  // create a new todo locator
  const newTodo = page.getByPlaceholder('What needs to be done?')
  await newTodo.fill('one')
  await newTodo.press('Enter')
  await newTodo.fill('two')
  await newTodo.press('Enter')
  const todoItems = page.locator('li.todo')
  await expect(todoItems).toHaveCount(2)
})
it('adds todos', () => {
  cy.request('POST', '/reset', { todos: [] })
  cy.visit('/')
  cy.get('.new-todo').type('one{enter}').type('two{enter}')
  cy.get('li.todo').should('have.length', 2)
})

// cy

The "Tinder" data flow

// pw

  • imperative syntax
  • promise-based
  • declarative syntax
  • reactive stream

Swipe right

(data flows to the right)

Swipe left

(data is assigned to the left)

var k = 0;
for(k = 0; k < numbers.length; k += 1) {
  console.log(numbers[k] * constant);
}
// 6 2 14
// _ is Lodash / Ramda
_(numbers)
  .map(_.partial(mul, constant))
  .forEach(print);
// 6 2 14

// functional

for-loop vs Array.forEach

// imperative

Swipe right

(data flows to the right)

Swipe left

(data is assigned to the left)

const n = Number(await locator.getText())
// n is set
cy.get('#count')
  .invoke('text')
  .then(Number)
  .then(n => {
    // n is set
  })

// pw

// cy

Component Testing

expect(formatTime({ seconds: 3 }))
  .to.equal('00:03')

Unit test

import { Component } from './Component'
cy.mount(<Component props=... />)
cy.get(...).click()

Component test

cy.visit('/')
cy.get(...).click()

End-to-End test

  • Small chunks of code like functions and classes
  • Front-end React / Angular / Vue / X components
  • Easy to test edge conditions
  • Web application
  • Easy to test the entire user flow

Playwright v1.50 status

  • @playwright/experimental-ct-react

  • @playwright/experimental-ct-svelte

  • @playwright/experimental-ct-vue

⍺

const Button = ({
  customClass,
  label,
  onClick,
  size,
  testId,
  type,
  ...props
}) => {
  ...
  return (
    <button
      className={`btn${buttonTypeClass}${buttonSize}${extraClass}`}
      onClick={onClick}
    >
      {type === BUTTON_TYPES.BACK && <BackImage />}
      {label}
    </button>
  )
}

src/components/Button.jsx

Example React Button component

import { test, expect } from '@playwright/experimental-ct-react17'
import Button from './Button'

test('shows a button', async ({ mount }) => {
  const component = await mount(<Button label="Test button" />)
  await expect(component).toContainText('Test button')
})
import React from 'react'
import Button from './Button'

it('shows a button', () => {
  cy.mount(<Button label="Test button" />)
  cy.contains('button', 'Test button')
})

// cy

// pw

React component test

PW component test

CY component test

// PW component
const component = await mount(<Button label="Test button" />)
await expect(component).toContainText('Test button')
// CY component
cy.mount(<Button label="Test button" />)
cy.contains('button', 'Test button')
// PW component
// const component = await mount(<Button label="Test button" />)
// PW E2E
await page.goto('/')
// the SAME testing commands
await expect(component).toContainText('Test button')
// CY component
// cy.mount(<Button label="Test button" />)
// CY E2E
cy.visit('/')
// the SAME testing commands
cy.contains('button', 'Test button')
// PW component
// const component = await mount(<Button label="Test button" />)
// PW E2E
await page.goto('/')
// the SAME testing commands
await expect(component).toContainText('Test button')
// CY component
// cy.mount(<Button label="Test button" />)
// CY E2E
cy.visit('/')
// the SAME testing commands
cy.contains('button', 'Test button')

Testing async onClick prop

test('callback prop is called on click', async ({ mount }) => {
  let clicked = false
  const component = await mount(
    <Button
      label="Test button"
      onClick={() => {
        clicked = true
      }}
    />
  )
  await component.click()
  await expect.poll(() => clicked).toBeTruthy()
})

// pw

PW component test

Testing async onClick prop

it('callback prop is called on click', () => {
  cy.mount(<Button label="Test button" 
           onClick={cy.stub().as('onClick')} />)
  cy.get('button').click()
  cy.get('@onClick').should('have.been.calledOnce')
})

// cy

CY component test

Node.js

Playwright test code

Browser

application

Chrome Debugger Protocol

Node.js

Config / plugins file

Browser

application

Cypress test code

WebSocket

for

cy.task

Test runners architecture

Node.js

Playwright test code

Component

<Button ...>

Chrome Debugger Protocol

Node.js

Config / plugins file

Component

<Button ...>

Cypress test code

WebSocket

for

cy.task

Test runners architecture

const InputPrice = ({ priceFormatter }) => {
  const formatter = priceFormatter || defaultPriceFormatter
  ...
  {!isNaN(price) && <span className="price">{formatter(price)}</span>}
}
  const customFormatter = (price) => {
    console.log('formatting', price)
    return `Price: ${price} cents`
  }
  const component = await mount(
    <InputPrice priceFormatter={customFormatter} />)
  await component.getByPlaceholder('Enter price (cents)')
    .fill('9')
  await expect(component.locator('.price'))
    .toHaveText('Price: 9 cents')

// pw

PW component test fails 😭

const InputPrice = ({ priceFormatter }) => {
  const formatter = priceFormatter || defaultPriceFormatter
  ...
  {!isNaN(price) && <span className="price">{formatter(price)}</span>}
}
  const customFormatter = (price) => {
    console.log('formatting', price)
    return `Price: ${price} cents`
  }
  const component = await mount(
    <InputPrice priceFormatter={customFormatter} />)
  await component.getByPlaceholder('Enter price (cents)')
    .fill('9')
  await expect(component.locator('.price'))
    .toHaveText('Price: 9 cents')

// pw

Must be a synchronous call

Remote call to Node.js

Promise<string>

Problems:Β function calls, passing objects, browser APIs

const customFormatter = (price) => {
  console.log('formatting', price)
  return `Price: ${price} cents`
}
cy.mount(<InputPrice priceFormatter={customFormatter} />)
cy.get('[placeholder="Enter price (cents)"]').type('9')
cy.get('.price').should('have.text', 'Price: 9 cents')

// cy

CY component test is happyβœ…

Feature Cy Pw
tests, hooks βœ… βœ…
UI βœ… βœ…
network control βœ… βœ…
assertions βœ… βœ…
async / await βœ…
code elegance βœ…
parallel same machine βœ…
parallel CI plugin βœ…
clock control βœ… βœ…
functional spies and stubs βœ…
traces free screenshots and videos βœ…
component testing πŸ† πŸ§ͺπŸ€”
test tags plugin βœ…

πŸ§‘β€πŸ’»

🀦

developer

qa

Cy

Pw

  • syntax
  • direct access to objects and elements
  • spies and stubs
  • component testing
  • syntax
  • multiple domains
  • iframes
  • speed
  • traces

Component Testing

UI polish

Cy

Pw

Gleb's personal opinion

Final

Thoughts

Different Tools Are Different

Don't Get Hanged Up On Syntax

Learn Both Cy and PW

Gleb Bahmutov

πŸ‘ Thank You πŸ‘

Cypress vs Playwright: What Is the Difference?

By Gleb Bahmutov

Cypress vs Playwright: What Is the Difference?

I will show how the two most popular modern web application testing tools, Cypress and Playwright, approach the same problem differently. We will see how to write end-to-end, API, and component tests using both tools and how to execute them on a continuous integration system. Instead of declaring a winner, we will see the advantages of each test runner. Presented at ConFoo 2025, 45 minutes

  • 829