Visual Testing for React Components
Gleb Bahmutov
VP of Engineering
Cypress.io
@bahmutov
Use Sli.do to answer poll questions and ask your own question
our planet is in imminent danger
https://lizkeogh.com/2019/07/02/off-the-charts/
+3 degrees Celsius will be the end.
survival is possible. but we need to act now
- change your life
- join an organization
350.org
citizenclimatelobby.org
greenpeace.org
rebellion.earth
sunrisemovement.org
sierraclub.org
Dr Gleb Bahmutov PhD
C / C++ / C# / Java / CoffeeScript / JavaScript / Node / Angular / Vue / Cycle.js / functional
(these slides)
Cypress T-shirts
Reply to the survey after this meetup
Presentation
- Component tests
- Visual snapshot tests
- Deterministic data
- clock control
- data mocking
- Local workflow
- Pull request workflow
Built from React Components
import React from 'react'
import { render } from 'react-dom'
import { App } from './App'
render(<App />, document.getElementById('root'))
index.js
import React from 'react'
import { Game } from './Game'
import './App.css'
import { SudokuProvider } from './context/SudokuContext'
export const App = () => {
return (
<SudokuProvider>
<Game />
</SudokuProvider>
)
}
App.js
Top level component App
import React, { useState, useEffect } from 'react'
import moment from 'moment'
import { Header } from './components/layout/Header'
import { GameSection } from './components/layout/GameSection'
import { StatusSection } from './components/layout/StatusSection'
import { Footer } from './components/layout/Footer'
import { getUniqueSudoku } from './solver/UniqueSudoku'
import { useSudokuContext } from './context/SudokuContext'
export const Game = () => {
...
}
Game.js
Game component
return (
<>
<div className={overlay?"container blur":"container"}>
<Header onClick={onClickNewGame}/>
<div className="innercontainer">
<GameSection
onClick={(indexOfArray) => onClickCell(indexOfArray)}
/>
<StatusSection
onClickNumber={(number) => onClickNumber(number)}
onChange={(e) => onChangeDifficulty(e)}
onClickUndo={onClickUndo}
onClickErase={onClickErase}
onClickHint={onClickHint}
onClickMistakesMode={onClickMistakesMode}
onClickFastMode={onClickFastMode}
/>
</div>
<Footer />
</div>
</>
)
Game.js
Game component
Component Inputs
import React from 'react';
import { useSudokuContext } from '../context/SudokuContext';
/**
* React component for the Number Selector in the Status Section.
*/
export const Numbers = (props) => {
let { numberSelected } = useSudokuContext();
return (
<div className="status__numbers">
{
[1, 2, 3, 4, 5, 6, 7, 8, 9].map((number) => {
if (numberSelected === number.toString()) {
return (
<div className="status__number status__number--selected"
key={number}
onClick={() => props.onClickNumber(number.toString())}>{number}</div>
)
} else {
return (
<div className="status__number" key={number}
onClick={() => props.onClickNumber(number.toString())}>{number}</div>
)
}
})
}
</div>
)
}
Numbers.js
<Numbers onClickNumber={(number) => props.onClickNumber(number)} />
StatusSection.js
<Numbers .../>
props
context
user clicks
DOM
prop calls
React Component Tests
yarn add -D cypress cypress-react-unit-test
React Component Tests
// cypress/support/index.js
require('cypress-react-unit-test/support')
// cypress/plugins/index.js
module.exports = (on, config) => {
require('cypress-react-unit-test/plugins/react-scripts')(on, config)
return config
}
// cypress.json
{
"experimentalComponentTesting": true,
"componentFolder": "src"
}
import React from 'react'
import { mount } from 'cypress-react-unit-test'
import { Numbers } from './Numbers'
describe('Numbers', () => {
it('shows all numbers', () => {
mount(<Numbers />);
[1, 2, 3, 4, 5, 6, 7, 8, 9].forEach(k => {
cy.contains('.status__number', k)
})
})
})
Numbers.spec.js
test Numbers component
import React from 'react'
import { mount } from 'cypress-react-unit-test'
import { Numbers } from './Numbers'
describe('Numbers', () => {
it('shows all numbers', () => {
mount(<Numbers />);
[1, 2, 3, 4, 5, 6, 7, 8, 9].forEach(k => {
cy.contains('.status__number', k)
})
})
})
Numbers.spec.js
test Numbers component
import React from 'react'
import { mount } from 'cypress-react-unit-test'
import { Numbers } from './Numbers'
import '../App.css'
describe('Numbers', () => {
it('shows all numbers', () => {
mount(<Numbers />);
[1, 2, 3, 4, 5, 6, 7, 8, 9].forEach(k => {
cy.contains('.status__number', k)
})
})
})
Numbers.spec.js
apply global styles
import React from 'react'
import { mount } from 'cypress-react-unit-test'
import { Numbers } from './Numbers'
import '../App.css'
describe('Numbers', () => {
it('shows all numbers', () => {
mount(<Numbers />);
[1, 2, 3, 4, 5, 6, 7, 8, 9].forEach(k => {
cy.contains('.status__number', k)
})
})
})
Numbers.spec.js
it('shows all numbers', () => {
mount(
<div className="innercontainer">
<section className="status">
<Numbers />
</section>
</div>
)
// confirm numbers
})
Numbers.spec.js
set the right structure
it('shows all numbers', () => {
mount(
<div className="innercontainer">
<section className="status">
<Numbers />
</section>
</div>
)
// confirm numbers
})
Numbers.spec.js
it('reacts to a click', () => {
mount(
<div className="innercontainer">
<section className="status">
<Numbers onClickNumber={cy.stub().as('click')}/>
</section>
</div>
)
cy.contains('.status__number', '9').click()
cy.get('@click').should('have.been.calledWith', '9')
})
Numbers.spec.js
click a number
it('reacts to a click', () => {
mount(
<div className="innercontainer">
<section className="status">
<Numbers onClickNumber={cy.stub().as('click')}/>
</section>
</div>
)
cy.contains('.status__number', '9').click()
cy.get('@click').should('have.been.calledWith', '9')
})
Numbers.spec.js
import {SudokuContext} from '../context/SudokuContext'
describe('Numbers', () => {
it('shows selected number', () => {
mount(
<SudokuContext.Provider value={{ numberSelected: '4' }} >
<div className="innercontainer">
<section className="status">
<Numbers />
</section>
</div>
</SudokuContext.Provider>
)
cy.contains('.status__number', '4')
.should('have.class', 'status__number--selected')
})
})
Numbers.spec.js
import {SudokuContext} from '../context/SudokuContext'
describe('Numbers', () => {
it('shows selected number', () => {
mount(
<SudokuContext.Provider value={{ numberSelected: '4' }} >
<div className="innercontainer">
<section className="status">
<Numbers />
</section>
</div>
</SudokuContext.Provider>
)
cy.contains('.status__number', '4')
.should('have.class', 'status__number--selected')
})
})
Numbers.spec.js
Does it look right?
If I change this CSS (or class name or layout) just a little bit ...
👩💻 ✅
🤖 🛑
desktop
tablet
mobile
Does it look right?
If I change this CSS (or class name or layout) just a little bit ...
👩💻 ✅ 🕰
🤖 🛑
Does it look the same?
If I change this CSS (or class name or layout) just a little bit ...
👩💻 🛑 🕰
🤖 ✅ ⏱
Image Diff Plugin
yarn add -D cypress-image-snapshot
// cypress/support/index.js
require('cypress-react-unit-test/support')
require('cypress-image-snapshot/command').addMatchImageSnapshotCommand()
// cypress/plugins/index.js
module.exports = (on, config) => {
require('cypress-react-unit-test/plugins/react-scripts')(on, config)
require('cypress-image-snapshot/plugin').addMatchImageSnapshotPlugin(on, config)
return config
}
import {SudokuContext} from '../context/SudokuContext'
describe('Numbers', () => {
it('shows selected number', () => {
mount(
<SudokuContext.Provider value={{ numberSelected: '4' }} >
<div className="innercontainer">
<section className="status">
<Numbers />
</section>
</div>
</SudokuContext.Provider>
)
cy.contains('.status__number', '4')
.should('have.class', 'status__number--selected')
cy.get('.status__numbers')
.matchImageSnapshot('numbers-selected')
})
})
confirm the DOM has been updated
take image snapshot
import {SudokuContext} from '../context/SudokuContext'
describe('Numbers', () => {
it('shows selected number', () => {
mount(
<SudokuContext.Provider value={{ numberSelected: '4' }} >
<div className="innercontainer">
<section className="status">
<Numbers />
</section>
</div>
</SudokuContext.Provider>
)
cy.contains('.status__number', '4')
.should('have.class', 'status__number--selected')
cy.get('.status__numbers')
.matchImageSnapshot('numbers-selected')
})
})
Commit this image into source control
All the snapshots
Let's change CSS
.status__number {
- padding: 12px 0;
+ padding: 10px 0;
}
App.css
baseline image
new image
difference
cypress/snapshots/Numbers.spec.js/__diff_output__/numbers-selected.diff.png
baseline image
new image
difference
App visual diff due to difference board
Deterministic Data: the Clock
import { App } from './App'
it('shows the timer', () => {
mount(<App />)
// the timer starts at zero, so this is probably ok
cy.contains('.status__time', '00:00')
.matchImageSnapshot('timer-zero')
})
What about the clock after 10 minutes?
What if the snapshot "catches" clock in transition?
Deterministic Data: the Clock
import { App } from './App'
it('shows the timer', () => {
cy.clock()
mount(<App />)
cy.contains('.status__time', '00:00')
.matchImageSnapshot('timer-zero')
cy.tick(700 * 1000)
cy.contains('.status__time', '11:40')
.matchImageSnapshot('timer-passed')
})
Deterministic Data: Mocks
import { App } from './App'
it('shows the board', () => {
mount(<App />)
cy.get('.container').matchImageSnapshot('the-game')
})
App.spec.js
Why not the entire game?
Deterministic Data: Mocks
import { App } from './App'
it('shows the board', () => {
mount(<App />)
cy.get('.container').matchImageSnapshot('the-game')
})
App.spec.js
?
<App />
<Game />
<Header />
<GameSection />
<StatusSection />
<Footer />
<Timer />
<Difficulty />
<Numbers />
tested
tested
Deterministic Data: Mocks
import { App } from './App'
it('shows the board', () => {
mount(<App />)
cy.get('.container').matchImageSnapshot('the-game')
})
App.spec.js
?
<App />
<Game />
<Header />
<GameSection />
<StatusSection />
<Footer />
<Timer />
<Difficulty />
<Numbers />
Deterministic Data: Mocks
import { App } from './App'
it('shows the board', () => {
mount(<App />)
cy.get('.container').matchImageSnapshot('the-game')
})
App.spec.js
Because every time test runs, a new random board will be generated
// App.js uses Game.js
// Game.js
import { getUniqueSudoku } from './solver/UniqueSudoku'
...
function _createNewGame(e) {
let [temporaryInitArray, temporarySolvedArray] = getUniqueSudoku(difficulty, e);
...
}
// cypress/fixtures/init-array.json
["0", "0", "9", "0", "2", "0", "0", ...]
// cypress/fixtures/solved-array.json
["6", "7", "9", "3", "2", "8", "4", ...]
import { App } from './App'
import * as UniqueSudoku from './solver/UniqueSudoku'
it('shows the board', () => {
cy.fixture('init-array').then(initArray => {
cy.fixture('solved-array').then(solvedArray => {
cy.stub(UniqueSudoku, 'getUniqueSudoku').returns([initArray, solvedArray])
})
})
cy.clock()
mount(<App />)
cy.get('.container').matchImageSnapshot('the-game')
})
Deterministic Data: Mocks
mock ES6 import
import { App } from './App'
import * as UniqueSudoku from './solver/UniqueSudoku'
it('shows the board', () => {
cy.fixture('init-array').then(initArray => {
cy.fixture('solved-array').then(solvedArray => {
cy.stub(UniqueSudoku, 'getUniqueSudoku').returns([initArray, solvedArray])
})
})
cy.clock()
mount(<App />)
cy.get('.container').matchImageSnapshot('the-game')
})
Deterministic Data: Mocks
it('plays one move', () => {
cy.fixture('init-array').then(initArray => {
cy.fixture('solved-array').then(solvedArray => {
cy.stub(UniqueSudoku, 'getUniqueSudoku').returns([initArray, solvedArray])
})
})
cy.clock()
mount(<App />)
cy.get('.game__cell').first().click()
// we can even look at the solved array!
cy.contains('.status__number', '6').click()
cy.get('.game__cell').first()
.should('have.class', 'game__cell--highlightselected')
cy.get('.container')
.matchImageSnapshot('same-game-made-one-move')
})
it('plays one move', () => {
cy.fixture('init-array').then(initArray => {
cy.fixture('solved-array').then(solvedArray => {
cy.stub(UniqueSudoku, 'getUniqueSudoku').returns([initArray, solvedArray])
})
})
cy.clock()
mount(<App />)
cy.get('.game__cell').first().click()
// we can even look at the solved array!
cy.contains('.status__number', '6').click()
cy.get('.game__cell').first()
.should('have.class', 'game__cell--highlightselected')
cy.get('.container')
.matchImageSnapshot('same-game-made-one-move')
})
See What The Test Is Doing
Our Progress
- Component tests
- Visual snapshot tests
-
Deterministic data
- clock control
- data mocking
- Local workflow
- Pull request workflow
yarn cypress open
screenshot 1300x600
yarn cypress run
screenshot 650x300
interactive mode
headless mode
Local Workflow
Pixel density on Mac ...
🔥
Local Workflow
// cypress/support/index.js
import { addMatchImageSnapshotCommand } from 'cypress-image-snapshot/command';
if (Cypress.config('isInteractive')) {
Cypress.Commands.add('matchImageSnapshot', () => {
cy.log('Skipping snapshot 👀')
})
} else {
addMatchImageSnapshotCommand()
}
require('cypress-react-unit-test/support')
Do not take snapshots in interactive mode
Local Workflow
// cypress/support/index.js
import { addMatchImageSnapshotCommand } from 'cypress-image-snapshot/command';
if (Cypress.config('isInteractive')) {
Cypress.Commands.add('matchImageSnapshot', () => {
cy.log('Skipping snapshot 👀')
})
} else {
addMatchImageSnapshotCommand()
}
require('cypress-react-unit-test/support')
Do not take snapshots in interactive mode
"yarn cypress run"
- add a new snapshot
Local Workflow
"CYPRESS_updateSnapshots=true yarn cypress run"
- update existing snapshot
Run on CI
Take image snapshots using exactly the same environment and browser as CI
Mac cypress run
Linux cypress run
difference
Use Docker Everywhere
{
"scripts": {
"docker:run": "docker run -it -v $PWD:/e2e -w /e2e cypress/included:4.5.0"
}
}
package.json
"yarn run docker:run"
- add a new snapshot
- update existing snapshot
Use Docker Everywhere
{
"scripts": {
"docker:run": "docker run -it -v $PWD:/e2e -w /e2e cypress/included:4.5.0"
}
}
package.json
jobs:
cypress-run:
runs-on: ubuntu-latest
container: cypress/browsers:node12.13.0-chrome80-ff74
CI config
exactly the same dependencies
Pull Request Workflow
- run all tests, saving all DIFF images
- do NOT fail tests on image differences
CYPRESS_failOnSnapshotDiff=false
Pull Request Workflow
- do NOT fail tests on image differences
CYPRESS_failOnSnapshotDiff=false
- report # of visual differences (if any)
async function getVisualDiffs() {
return globby('cypress/snapshots/**/__diff_output__/**.png')
}
getVisualDiffs().then(list => {
return setGitHubCommitStatus(list.length, envOptions)
}).catch(onError)
- run all tests, saving all DIFF images
Pull Request Workflow
Summary
- React component tests using cypress-react-unit-test
- Visual snapshotting using cypress-image-snapshot
- Deterministic data
- Local and Ci workflow
Summary
👍 cypress-image-snapshot is good and free
👎 a lot of effort to render images, store them, set up review workflow
Consider 3rd party paid service: Applitools, Happo.io, Percy.io
Thank you 👏
Want to learn more?
https://github.com/bahmutov/sudoku#videos
is 1 hour of free videos showing every step in detail