Imagine you are drawing something into HTML5 Canvas element. You want to confirm that the application is working, so you compare the drawing with a previously saved good image. This blog post shows how to do this using Cypress with a bonus feature meant to solve visual flake: the visual diffing is retried until the rendering matches the saved image (or the command times out).
- The smiley face
- Saving the canvas
- Visual test using odiff
- The test must wait
- Visual flake
- Retry-ability
- Visual retry-ability
- Waiting for static canvas
The smiley face
The example application in the bahmutov/monalego repo uses Legra.js to draw a smiley face. The face is stretching its smile
The script to draw the face is below
1 | import Legra from 'https://unpkg.com/legra?module' |
We want to confirm the final rendering - the full smile, so we have saved the PNG at the end of the animation. How do we confirm the smile is rendered from now on?
Saving the canvas
First, let's save the canvas as a PNG image we can compare against the good image. We can convert the HTML5 canvas to data URL and then save the base64 string on disk using cy.writeFile command.
1 | export const downloadPng = (filename) => { |
1 | import { downloadPng } from './utils' |
The file is saved - note we had to wait for 4 seconds to be safe the animation has fully finished and the canvas has finished rendering.
I have moved the saved PNG file into images/darwin/smile.png
file to be used as good "gold" image for the comparison.
Let's write the visual test.
Visual test using odiff
Our test should download the canvas as a PNG image and compare the new image against the saved good image. For fast comparison I will use odiff module from my co-worker Dmitriy Kovalenko @dmtrKovalenko. Since this is a Node operation I will call odiff
from the plugins file.
1 | const { compare } = require('odiff-bin') |
Here is our test that calls the image comparison using cy.task
1 | it('smiles broadly with wait', () => { |
Meanwhile we can see the terminal output
1 | comparing base image images/darwin/smile.png to the new image smile.png |
The test must wait
Can we speed up the test? Can we remove the cy.wait(4000)
? Well, if we remove it completely, the test fails - because the canvas has not rendered at all
Here is the diff PNG image the odiff
comparison generates showing in red the pixels different from the good image.
Maybe we can only wait one second? No, the test still fails
The visual difference image shows a smiling image, but not enough to match our good image.
We see the mismatched red pixels around the mouth - our smile is bad!
Visual flake
In general, "catching" the page at the right moment so that all its pixels are updated turns out to be tricky and flakey. There could be JavaScript and CSS animations still running, there could be asynchronous data loading, there could be little page alerts and badges suddenly showing up. I have written about debugging a flaky visual regression test and must say - it is no picnic.
Things like a text box that hasn't fully opened are common:
Or the entire element is missing from the DOM
Or the element is present, but its text hasn't been rendered yet
What do we do? How do we fight the visual flake?
Retry-ability
First, let me note that the flake in our case is due to comparing the image prematurely. The good image has been taken after the canvas has completely finished its animation. The good image was "approved" by the human user, thus we know it is good. Our test has a problem - it takes an image too soon.
A similar problem happens in E2E testing very often - the Cypress Test Runner cannot know when an element is ready to pass an assertion, thus it just retries again and again. Eventually the element changes its state and the assertion passes. If the assertion never passes, no matter how many times the command retries, then the command times out, and the test fails. This Cypress feature is called retry-ability and it is the keystone to fighting the test flake.
Here is a typical test that relies on the retrying the command until the assertion passes
1 | cy.get('.todo-list li') // command |
Here is the retry-ability in action. Notice how the application inserts the new todo items after a noticeable delay - maybe the items have to be saved at a remote server first, thus they appear in the DOM only after a few seconds.
The command is retried until the page reaches the desired state. The page is similar to the smiling face, the page is changing - and the test keeps "grabbing" the elements and checking if there are two of them. Once there are two elements found, the test is done.
Visual retry-ability
The solution to visual flake is to retry getting the canvas and comparing it to the good image. Getting the canvas is similar to getting the DOM elements - it is an idempotent operation that is safe to run again and again. Our only challenge is to implement the retries using the same commands we used during the visual test above; the Cypress commands cy.writeFile
and cy.task
are NOT retried by default.
Luckily, I have written a small reusable module called cypress-recurse for recursively calling any series of Cypress commands until a predicate becomes true. You can read and watch how I coded it up in the Writing Cypress Recurse Function tutorial.
In our case the test is so simple, the code speaks for itself:
1 | it('smiles broadly', () => { |
The test calls the first function that performs the Cypress commands. This function downloads the canvas PNG and calls the cy.task('compare')
. The task yields an object with a property match
. Our predicate function simply grabs that property. Once the match
is true the visual test is done - the two images matched.
Of course, we can turn off such verbose logging. But here I kept it on to show how every iteration of saving the canvas and image comparison takes about 100-150ms. The odiff
visual comparison only takes 25ms for a 480x480 pixel image - the rest of the time is taken by saving the image and the Cypress command overhead.
You can see how I am writing the above test in this video and find the full project at bahmutov/monalego
Of course we can refactor the code and make it into a reusable utility function:
1 | import { looksTheSame } from './utils' |
Waiting for static canvas
One optimization we can do to avoid relatively expensive visual image retries, is to wait for the canvas to become static (unchanging) first, before doing the image comparison with the file on disk. An image diffing module like pixelmatch can compare canvas pixels right in the browser. Thus we can check the canvas against itself after N milliseconds. If there are pixel differences, then the canvas is still changing. We need to wait then try again. If the images match - the canvas is static and can be compared to an image on disk.
The utility function can still use the cypress-recurse
module to implement such in-browser image diffing
1 | import { recurse } from 'cypress-recurse' |
A typical test would call the ensureCanvasStatic
function first, then the function looksTheSame
:
1 | import { looksTheSame } from './utils' |
The GIF below shows the dwindling number of different pixels. As it reaches 0, the canvas finished animation and is ready to be compared against the baseline image on disk.
Happy Visual Testing!