@giltayar
@bahmutov
describe('Sudoku', () => {
context('on mobile', () => {
beforeEach(() => {
cy.viewport(300, 600)
cy.visit('/')
})
it('plays on mobile', () => {
// on easy setting there are 45 filled cells at the start
cy.get('.game__cell--filled').should('have.length', 45)
cy.contains('.status__time', '00:00')
cy.contains('.status__difficulty-select', 'Easy')
})
})
})
describe('Sudoku', () => {
context('on mobile', () => {
beforeEach(() => {
cy.viewport(300, 600)
cy.visit('/')
})
it('plays on mobile', () => {
// on easy setting there are 45 filled cells at the start
cy.get('.game__cell--filled').should('have.length', 45)
cy.contains('.status__time', '00:00')
cy.contains('.status__difficulty-select', 'Easy')
})
})
})
.status__difficulty {
/* position: relative; */
top: 39px;
left: 20px;
}
???
π©βπ» β
If I change this CSS (or class name or layout) just a little bit ...
π€ π
desktop
tablet
mobile
At every resolution?
npm i --save-dev @applitools/eyes-cypress
npx eyes-setup
beforeEach(() => {
cy.eyesOpen({
appName: 'Sudoku',
browser: [
{ width: 1024, height: 768 }, // desktop
{ width: 600, height: 750 }, // tablet
{ width: 450, height: 650 }, // mobile
{ width: 1024, height: 768, name: 'firefox' }, // Firefox
{ width: 1024, height: 768, name: 'safari' }, // Safari
{ width: 1024, height: 768, name: 'ie11' }, // π±
{ iosDeviceInfo: { // iOS devices
deviceName: 'iPhone XR',
}
},
{ mobile: true, // Chrome device emulation
width: 800, height: 600, deviceScaleFactor: 3,
}
],
})
})
cy.visit('/')
// on easy setting there are 45 filled cells at the start
cy.get('.game__cell--filled').should('have.length', 45)
cy.contains('.status__time', '00:00')
cy.contains('.status__difficulty-select', 'Easy')
cy.eyesCheckWindow({ tag: 'App' })
functional assertions
visual assertions
cy.visit('/')
// on easy setting there are 45 filled cells at the start
cy.get('.game__cell--filled').should('have.length', 45)
// cy.contains('.status__time', '00:00')
// cy.contains('.status__difficulty-select', 'Easy')
cy.eyesCheckWindow({ tag: 'App' })
functional assertions
visual assertions
Applitools creates screenshots with multiple resolutions, devices, browsers at the same time
If I change this CSS (or class name or layout) just a little bit ...
π©βπ» β π°
π€ π
π€ β β±
π©βπ» π π°
If I change this CSS (or class name or layout) just a little bit ...
.status__difficulty {
/* position: relative; */
top: 39px;
left: 20px;
}
???
// ignore the contents of the board's cells
cy.eyesCheckWindow({
tag: 'App',
layout: {
selector: 'game__board',
},
})
// ignore the contents of the board's cells
cy.eyesCheckWindow({
tag: 'App',
layout: {
selector: 'game__board',
},
})
(because of the random board)
in all possible states
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
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
props
context
user clicks
DOM
prop calls
How does the component look and behave under different inputs?
yarn add -D cypress-react-unit-test
// 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 />); // instead of cy.visit()
[1, 2, 3, 4, 5, 6, 7, 8, 9].forEach(k => {
cy.contains('.status__number', k)
})
})
})
Numbers.spec.js
test Numbers component
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 />); // instead of cy.visit()
[1, 2, 3, 4, 5, 6, 7, 8, 9].forEach(k => {
cy.contains('.status__number', k)
})
})
})
Numbers.spec.js
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
it('shows all numbers', () => {
mount(
<div className="innercontainer">
<section className="status">
<Numbers />
</section>
</div>,
)
// use a single image snapshot after making sure
// the component has been rendered into the DOM
cy.get('.status__number').should('have.length', 9)
cy.eyesCheckWindow({ tag: 'all numbers' })
})
Numbers.spec.js
Assert the UI has updated before taking the snapshot
it('shows all numbers', () => {
mount(
<div className="innercontainer">
<section className="status">
<Numbers />
</section>
</div>,
)
// use a single image snapshot after making sure
// the component has been rendered into the DOM
cy.get('.status__number').should('have.length', 9)
cy.eyesCheckWindow({ tag: 'all numbers' })
})
Numbers.spec.js
Assert the UI has updated before taking the snapshot
Applitools screenshot
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.eyesCheckWindow({ tag: 'selected 4' })
})
Numbers.spec.js
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.eyesCheckWindow({ tag: 'selected 4' })
})
Numbers.spec.js
Applitools screenshot
Applitools is fast enough to work interactively or you could skip diff comparisons when running locally. See code in https://github.com/bahmutov/sudoku-applitools
?
<App />
<Game />
<Header />
<GameSection />
<StatusSection />
<Footer />
<Timer />
<Difficulty />
<Numbers />
tested
import { App } from './App'
it('shows the board', () => {
mount(<App />)
cy.eyesCheckWindow({ tag: 'created board' })
})
App.spec.js
Why not the entire game?
import { App } from './App'
it('shows the board', () => {
mount(<App />)
cy.eyesCheckWindow({ tag: 'created board' })
})
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", ...]
mock ES6 import from test
import initArray from '../cypress/fixtures/init-array.json'
import solvedArray from '../cypress/fixtures/solved-array.json'
import { App } from './App'
import * as UniqueSudoku from './solver/UniqueSudoku'
it('plays one move', () => {
cy.stub(UniqueSudoku, 'getUniqueSudoku').returns([initArray, solvedArray])
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.eyesCheckWindow({ tag: 'same board' })
})
mock ES6 import
import initArray from '../cypress/fixtures/init-array.json'
import solvedArray from '../cypress/fixtures/solved-array.json'
import { App } from './App'
import * as UniqueSudoku from './solver/UniqueSudoku'
it('plays one move', () => {
cy.stub(UniqueSudoku, 'getUniqueSudoku').returns([initArray, solvedArray])
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.eyesCheckWindow({ tag: 'same board' })
})
Same board every time
import initArray from '../cypress/fixtures/init-array.json'
import solvedArray from '../cypress/fixtures/solved-array.json'
it('plays one move', () => {
cy.stub(UniqueSudoku, 'getUniqueSudoku').returns([initArray, solvedArray])
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.eyesCheckWindow({ tag: 'one move' })
})
import initArray from '../cypress/fixtures/init-array.json'
import solvedArray from '../cypress/fixtures/solved-array.json'
it('plays one move', () => {
cy.stub(UniqueSudoku, 'getUniqueSudoku').returns([initArray, solvedArray])
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.eyesCheckWindow({ tag: 'one move' })
})
it('plays to win', () => {
// start with all but the first cell filled with solved array
const almostSolved = [...solvedArray]
// by setting entry to "0" we effectively clear the cell
almostSolved[0] = '0'
cy.stub(UniqueSudoku, 'getUniqueSudoku')
.returns([almostSolved, solvedArray])
.as('getUniqueSudoku')
cy.clock()
mount(<App />)
cy.eyesCheckWindow({ tag: '1 game is almost solved' })
// win the game
cy.get('.game__cell').first().click()
// use the known number to fill the first cell
cy.contains('.status__number', solvedArray[0]).click()
// winning message displayed
cy.get('.overlay__text').should('be.visible')
cy.eyesCheckWindow({ tag: '2 game is solved' })
// clicking the overlay starts the new game
cy.get('@getUniqueSudoku').should('have.been.calledOnce')
cy.get('.overlay__text').click()
cy.get('.overlay').should('not.be.visible')
cy.get('@getUniqueSudoku').should('have.been.calledTwice')
cy.eyesCheckWindow({ tag: '3 start new game after solved game' })
})
it('plays to win', () => {
// start with all but the first cell filled with solved array
const almostSolved = [...solvedArray]
// by setting entry to "0" we effectively clear the cell
almostSolved[0] = '0'
cy.stub(UniqueSudoku, 'getUniqueSudoku')
.returns([almostSolved, solvedArray])
.as('getUniqueSudoku')
cy.clock()
mount(<App />)
cy.eyesCheckWindow({ tag: '1 game is almost solved' })
// win the game
cy.get('.game__cell').first().click()
// use the known number to fill the first cell
cy.contains('.status__number', solvedArray[0]).click()
// winning message displayed
cy.get('.overlay__text').should('be.visible')
cy.eyesCheckWindow({ tag: '2 game is solved' })
// clicking the overlay starts the new game
cy.get('@getUniqueSudoku').should('have.been.calledOnce')
cy.get('.overlay__text').click()
cy.get('.overlay').should('not.be.visible')
cy.get('@getUniqueSudoku').should('have.been.calledTwice')
cy.eyesCheckWindow({ tag: '3 start new game after solved game' })
})
Desktop
Mobile
Applitools checks the screenshots
Always use an assertion before the visual snapshot command
// use a single image snapshot after making sure
// the component has been rendered into the DOM
cy.get('.status__number').should('have.length', 9)
cy.eyesCheckWindow(...)
cy.get('.overlay__text').should('be.visible')
cy.eyesCheckWindow(...)
Always use a singleΒ assertion before the visual snapshot command
// on easy setting there are 45 filled cells at the start
cy.get('.game__cell--filled').should('have.length', 45)
cy.contains('.status__time', '00:00')
cy.contains('.status__difficulty-select', 'Easy')
cy.eyesCheckWindow(...)
Unnecessary
Always use a singleΒ assertion before the visual snapshot command
// on easy setting there are 45 filled cells at the start
cy.get('.game__cell--filled').should('have.length', 45)
cy.eyesCheckWindow(...)
Validates the entire page in a single shot!
Configure IntelliSense
Use tags
cy.eyesCheckWindow({tag: 'solved board'})
// several screenshots in the test
cy.eyesCheckWindow({tag: '1 game is almost solved'})
cy.eyesCheckWindow({tag: '2 game is solved'})
Test components at different resolutions, devices, and browsers
cy.eyesOpen({
appName: 'Sudoku',
batchName: 'Sudoku',
browser: [
{ width: 800, height: 600, name: 'chrome' },
{ width: 1024, height: 768, name: 'chrome' },
{ width: 1920, height: 1080, name: 'chrome' },
{ width: 800, height: 600, name: 'firefox' },
{ deviceName: 'iPhone X' },
{ deviceName: 'iPad' },
],
})
Gleb Bahmutov @bahmutov
Gil Tayar @giltayar