Gleb Bahmutov PRO
JavaScript ninja, image processing expert, software quality fanatic
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
Greenpeace Β 350 Β Sunrise Β Citizen Climate Lobby
π¦ bahmutov.bsky.social
C / C++ / C# / Java / CoffeeScript / JavaScript / Node / Angular / Vue / Cycle.js / functional programming / testing
π π₯ 350.orgΒ π π₯ citizensclimatelobby.orgΒ π π₯
source: https://npmtrends.com/@playwright/test-vs-cypress
All code examples come from the OSS repo
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
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
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
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
test.beforeEach(async ({ request }) => {
await request.post('http://localhost:3000/reset',
{ data: { todos: [] } })
})
beforeEach(() => {
cy.request('POST',
'http://localhost:3000/reset', { todos: [] })
})
// pw
// cy
GET /todos
loads the data
await page.route('/todos', (route) =>
route.fulfill({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(items)
})
)
// pw
cy.intercept('/todos', { fixture: 'products.json' })
.as('load')
// cy
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
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
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
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
// pw
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
// pw
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
// 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
expect(formatTime({ seconds: 3 }))
.to.equal('00:03')
import { Component } from './Component'
cy.mount(<Component props=... />)
cy.get(...).click()
cy.visit('/')
cy.get(...).click()
@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
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
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')
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
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
Node.js
Playwright test code
Component
<Button ...>
Chrome Debugger Protocol
Node.js
Config / plugins file
Component
<Button ...>
Cypress test code
WebSocket
for
cy.task
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
Component Testing
UI polish
Cy
Pw
π Thank You π
By Gleb Bahmutov
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
JavaScript ninja, image processing expert, software quality fanatic