Testing With Cypress vs Playwright: What Is the Difference?

Gleb Bahmutov

Sr Director of Engineering, Mercari US

Heavy smoke fills the air as people cross 34th Street in Herald Square in New York City on June 6, 2023.

source: https://time.com/6285871/wildfire-smoke-photos-us-canada/

survival is possible* but we need to act now

  • change your life
  • join an organization

Why doesn't this work

It used to work

Agenda

  • JavaScript vs The World
  • JavaScript vs JavaScript vs JavaScript
  • PW vs Cy syntax
  • PW vs Cy architecture

Speaker: Gleb Bahmutov PhD

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

Gleb Bahmutov

Sr Director of Engineering

Lots Of Testing

Edit and view HTML documents, 1991

source: http://digital-archaeology.org/the-nexus-browser/

JavaScript!

Problem: given an array of numbers, multiply each number by a constant and print the result.

var numbers = [3, 1, 7];
var constant = 2;
// 6 2 14

There is no single "correct" way to write JavaScript

procedural / imperative JS

var numbers = [3, 1, 7];
var constant = 2;
var k = 0;
for(k = 0; k < numbers.length; k += 1) {
  console.log(numbers[k] * constant);
}
// 6 2 14

Arrays: object-oriented + functional

var numbers = [3, 1, 7];
var constant = 2;
function mul(a, b) {
  return a * b;
}
function print(n) {
  console.log(n);
}
var mulBy = mul.bind(null, constant);
numbers
    .map(mulBy)
    .forEach(print);
// 6 2 14
  • clear "multiply then print" semantics

functional bits

Object-oriented methods

Arrays: object-oriented + functional

numbers
  .map(mulBy)
  .forEach(print);

JS was influenced by Self and Scheme

numbers
  .map(mulBy)
  .forEach(print);
// same as
const a1 = numbers.map(mulBy);
a1.forEach(print)
numbers
  .map(mulBy)
  .filter(predicate)
  .map(...)
  .reduce(...)

Fluent and chained

I want a value!

const X = numbers
  .map(mulBy)
  .filter(predicate)
  .map(...)
  .reduce(...)
// use x

"But what if my computation is asynchronous?"

compute((x) => {
  // x is the value
})
// runs before callback
// cannot access x

callback

const X = compute()
expect(X).to.deep.equal([6, 2, 14])

What about testing?

"But what if my computation is asynchronous?"

it('computes', (done) => {
  compute((x) => {
    expect(x).to.deep.equal(...)
    done()
  })
})

Promises

Single async operation with a value or an error

const p = new Promise((resolve, reject) => {
  ...
  resolve(42)
})
// after promise successfully completes
p.then(...)
 .catch(...)
var sleepSecond = _.partial(Q.delay, 1000);

var pauseMulAndPrint = function (n) {
  return function () {
    return sleepSecond()
        .then(_.partial(byConstant, n))
        .then(print);
  };
};

Sleep, mul then print using promises

numbers.map(pauseMulAndPrint)
  .reduce(Q.when, Q())
  .done();
// ... 6 ... 2 ... 14
it('computes', () => {
  return compute().then(x => {
    expect(x).to.deep.equal(...)
  })
})

What about testing?

Async to the rescue

it('computes', async () => {
  const x = await compute()
  expect(x).to.deep.equal(...)
})

Async to the rescue

<body>
  <h1>Fruits</h1>
  <ul>
    <li>Apples</li>
    <li>Grapes</li>
    <li>Kiwi</li>
  </ul>
</body>

Static page

test('has 3 fruits', async ({ page }) => {
  await page.goto(index)
  await expect(page.getByRole('listitem')).toHaveText([
    'Apples',
    'Grapes',
    'Kiwi',
  ])
})

Playwright (pw)

Dynamic page

test('has 3 fruits', async ({ page }) => {
  await page.goto(index)
  await expect(page.getByRole('listitem')).toHaveText([
    'Apples',
    'Grapes',
    'Kiwi',
  ])
})

Playwright (pw)

Microsoft Playwright automatically retries the assertion

I Can't (A)wait

With prices

test('has 3 fruits', async ({ page }) => {
  await page.goto(index)
  const elements = page.getByRole('listitem')
  await expect(async () => {
    const strings = await elements.allInnerTexts()
    const prices = strings.map((s) => s.split('$')[1])
    	.map(Number)
    expect(prices).toEqual([1, 2, 3])
  }).toPass()
})

Playwright (pw)

test('has 3 fruits', async ({ page }) => {
  await page.goto(index)
  const elements = page.getByRole('listitem')
  await expect(async () => {
    const strings = await elements.allInnerTexts()
    const prices = strings.map((s) => s.split('$')[1])
    	.map(Number)
    expect(prices).toEqual([1, 2, 3])
  }).toPass()
})

Playwright (pw)

test('has 3 fruits', async ({ page }) => {

  
  await expect(async () => {
    
    
    
    
  }).toPass()
})

retry async fn

Simpler async pipelines? A dream?

Playwright (pw)

I want a value!

const X = elements
const strings = X.innerText
const prices = ...
expect(prices).to.deep.equal(...)

Promises were wrapped values

getElements()
  .then(els => els.innerText)
  .then(parsePrices)
  .then(prices => expect(prices)...)
  .catch(e => ...)

In functional programming

const make = (input) =>
  Option.fromNullable(input)
    .map(parseInput)
    .flatMap((input) => Option.fromNullable(transform(input)))
    .map(print)
    .map(prettify)
    .getWithDefault("fallback");

In functional reactive programming

  • Everything is a source of asynchronous events.
  • Your code is a pipeline reacting to one or more input streams and outputting another stream of events

In functional reactive programming

User click events

var Rx = require('rx');

var clickEvents = Rx.Observable
  .fromEvent(document.querySelector('#btn'), 'click')

var numberEvents = Rx.Observable
  .fromArray(numbers);

function pickSecond(c, n) { return n; }

Rx.Observable.zip(clickEvents, numberEvents, 
                  pickSecond)
  .map(byConstant)
  .subscribe(print);
// prints a number on each button click

Promise 

Task, Option, Result

Observable

async / await

🛑

I want a value!

test('has 3 fruits', async ({ page }) => {
  await page.goto(index)
  const elements = page.getByRole('listitem')
  await expect(async () => {
    const strings = await elements.allInnerTexts()
    const prices = strings.map((s) => s.split('$')[1])
    	.map(Number)
    expect(prices).toEqual([1, 2, 3])
  }).toPass()
})

Retry

  • What if we accept that almost everything is async?
  • What if we optimize for data transformations and assertions?
import 'cypress-map'

it('has 3 fruits', () => {
  cy.visit('public/index.html')
  cy.get('#fruits li')
    .map('innerText')
    .mapInvoke('split', '$')
    .mapInvoke('at', 1)
    .map(Number)
    .should('deep.equal', [1, 2, 3])
})

Cypress (cy)

import 'cypress-map'

it('has 3 fruits', () => {
  cy.visit('public/index.html')
  cy.get('#fruits li')
    .map('innerText')
    .mapInvoke('split', '$')
    .mapInvoke('at', 1)
    .map(Number)
    .should('deep.equal', [1, 2, 3])
})
import 'cypress-map'

it('has 3 fruits', () => {
  cy.visit('public/index.html')
  cy.get('#fruits li')
    .map('innerText')
    .mapInvoke('split', '$')
    .mapInvoke('at', 1)
    .map(Number)
    .should('deep.equal', [1, 2, 3])
})

Elements to numbers

pipeline

retry

import 'cypress-map'

it('has 3 fruits', () => {
  cy.visit('public/index.html')
  cy.get('#fruits li')
    .map('innerText')
    .mapInvoke('split', '$')
    .mapInvoke('at', 1)
    .map(Number)
    .should('deep.equal', [1, 2, 3])
})

Elements to numbers

pipeline

test('has 3 fruits', async ({ page }) => {
  await page.goto(index)
  const elements = page.getByRole('listitem')
  await expect(async () => {
    const strings = await elements.allInnerTexts()
    const prices = strings.map((s) => s.split('$')[1])
    	.map(Number)
    expect(prices).toEqual([1, 2, 3])
  }).toPass()
})

Retry

Vs

retry

cy

pw

it('has 3 fruits', () => {
  cy.visit('public/index.html')
})
test('has 3 fruits', async ({ page }) => {
  await page.goto(index)
})

Of course the test is async

Of course loading the page is async

Of course the test must wait for the command to finish

cy

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 Syntax

// pw

  • imperative syntax
  • promise-based
  • declarative syntax
  • reactive stream
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
  })

// right

Data flows left vs right

// left

Swipe right

(data flows to the right)

Swipe left

(data is assigned to the left)

🤯 🤬

const n = Number(await locator.getText())
// n is set

Data flows left vs right

// left

What do you want to check on the page?

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

// right

🤯 🤬

cy.get('li.todo').should('have.length', 2)
There should be 2 items
cy.get('li.todo')
  .should('satisfy', $li => $li.length % 2 === 0)
There should be an even number of items
cy.wait('@load').its('response.body.length')
  .then(n => {
    cy.get('li.todo').should('have.length', n)
  })
There should be the same number of items as returned by the server
cy.intercept('GET', '/todos', { fixture: 'three.json' })
cy.get('li.todo').should('have.length', 3)

If you can control the data in your tests, then the test syntax collapses into a simple and elegant fluent chain

$todo

test('has 3 fruits', async ({ page }) => {
  await page.goto(index)
  await expect(page.getByRole('listitem')).toHaveText([
    'Apples',
    'Grapes',
    'Kiwi',
  ])
})
<body>
  <h1>Fruits</h1>
  <ul>
    <li>Apples</li>
    <li>Grapes</li>
    <li>Kiwi</li>
  </ul>
</body>

Static page

test('has 3 fruits', async ({ page }) => {
  await page.goto(index)
  await expect(page.getByRole('listitem')).toHaveText([
    'Apples',
    'Grapes',
    'Kiwi',
  ])
})
import 'cypress-map'
it('has 3 fruits', () => {
  cy.visit(index)
  cy.get('#fruits li')
    .map('innerText')
    .should('deep.equal', ['Apples', 'Grapes', 'Kiwi'])
})

functional reactive bits

if you do not have "toHaveText" assertion...

If the test looks weird or complicated...

Ok, I give up.

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

// cy

// pw

🤯 🤬

npm i -D cypress-await
const n = Number(await locator.getText())
// n is set
const n = await 
  cy.get('#count')
    .invoke('text')
    .then(Number)

// cy + cypress-awit

// pw

npm i -D cypress-await
const n = Number(await locator.getText())
// n is set
const n = cy
    .get('#count')
    .invoke('text')
    .then(Number)

// cy + cypress-awit

// pw

npm i -D cypress-await

😍 🎁 🎉

Need For Speed 🏎️

import { test } from '@playwright/test'

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

// pw

// cy

// pw

// cy

Cypress iframes the app and its specs

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

Playwright

  • runs outside the browser
  • controls the browser via Chrome Debugger Protocol (FAST)

Cypress

  • runs inside the browser
  • wraps a lot of browser elements using JavaScript
  • creates user interface (SLOW)

Need For Speed 🏎️

I do not care how long the tests take to run.

I care how long it takes to debug a failing test

Time-travel to inspect the result of each command ⏳

$ npx playwright test --trace on
$ npx playwright test --ui

Replaying Tests: The New Hotness

Let me take a look. Is this why my test has failed?

Final

Thoughts

Different Tools Are Different

Don't Get Hanged Up On Syntax

Learn Both Cy and PW

Gleb Bahmutov

gleb.dev

👏 Thank You 👏