Storybook Case Study: Automate image snapshots with different devices

Igor Davydkin
7 min readSep 13, 2018

--

Ensuring your app looks as you expect across different devices is a critical part of providing a quality experience for all your users. Visual-snapshotting automates this process and thankfully it’s quite easy to set up with Storybook. There are a lot of tools that provide Visual Snapshots out of the box — some of them are open source, some of them are paid services. This post will show how to visual-snapshot your stories with different devices' view-ports by using in-house addon-storyshots and addon-storyshots-puppeteer (That was contributed by Thomas BERTET not a long time ago 💪). The final example can be found in this repo.

Here, I assume that you are familiar with Storybook and hold a basic knowledge of its concepts. To make this post understandable to a wide-variety of frontend devs, I will use the@storybook/html package that is shipped with version 4 (at the time of writing it’s v4.0.0-alpha.18), so there is no need of understanding frameworks such as React/Angular or others.

Storybook has a dedicated addon — addon-viewport that allows showing different view-ports in Storybook’s UI. This addon is “mimicking” a real device’s view-port, by changing the dimension of the preview area in Storybook, since storybook is a webapp that runs in a browser, we are a bit limited of what we can change in a browser. With puppeteer, we can manipulate browser’s configuration from the testing framework.

Configuring storybook

First of all, we need to install all the needed dependencies to show our simple html stories. I will use yarn, but of course, everything is achievable with npm as well:

yarn add -D @storybook/html@alpha babel-loader @babel/core

Note we are installing babel-loader and babel/core, since storybook is dependent on them (babel-loader v8 needs babel/core)

Now let’s create a config.js under the .storybook directory:

import { configure } from '@storybook/html';

function loadStories() {
require('../src/story');
}

configure(loadStories, module);

Our configuration will load a single story file called story.js from within the src directory. This story will contain a few simple html examples:

import { storiesOf } from '@storybook/html';

import './style.css';

storiesOf('Example', module)
.add('Div', () => {
return '<div>Here is a first story in div</div>';
})
.add('Button', () => {
return '<button>Button</button>';
})
.add('Table', () => {
return `<table>
<tr>
<td>td</td>
<td>td</td>
</tr>
<tr>
<td>td</td>
<td>td</td>
</tr>
</table>`
;
});

style.css that is imported up there will use media queries to give an impression of different styles for different view-ports (Based on https://css-tricks.com/snippets/css/media-queries-for-standard-devices):

/* ----------- iPhone 5, 5S, 5C and 5SE ----------- */
@media only screen
and (min-device-width: 320px)
and (max-device-width: 568px)
and (-webkit-min-device-pixel-ratio: 2) {

body {
background-color: #33800b;
}
}

/* ----------- iPhone 6, 6S, 7 and 8 ----------- */
@media only screen
and (min-device-width: 375px)
and (max-device-width: 667px)
and (-webkit-min-device-pixel-ratio: 2) {

body {
background-color: #408053;
}
}

/* ----------- iPhone 6+, 7+ and 8+ ----------- */
@media only screen
and (min-device-width: 414px)
and (max-device-width: 736px)
and (-webkit-min-device-pixel-ratio: 3) {

body {
background-color: #4c8004;
}
}

/* ----------- iPad 1, 2, Mini and Air ----------- */
@media only screen
and (min-device-width: 768px)
and (max-device-width: 1024px)
and (-webkit-min-device-pixel-ratio: 1) {

body {
background-color: #7a8023;
}
}

As you can see, we will color a background of the body to different colors according to the matched media query.

Now we need to add the following scripts to the package.json to be able to run Storybook in dev and prod modes:

"scripts": {
"storybook": "start-storybook -p 8008",
"build-storybook": "build-storybook -o ./public"

}

After the steps above, you should have the following files structure:

project/
.storybook/
config.js
src/
story.js
style.js
node_modules/
package.json
yarn.lock

If everything went well until this moment, yarn storybook will show us this simple example (navigating to http://localhost:8008 in Chrome):

Let’s try to emulate devices with Chrome and see the resulting colors. Open-up DevTools and use the Device Emulation tool to emulate different devices. The relevant media query is kicked in to color the body with a requested color. And this behavior might also be achieved with puppeteer API.

Device Emulation with DevTools

Configuring Jest + Storyshots

Let’s now configure jest to be able to run our visual snapshots. First of all, we need to add the following packages:

  • jest — test runner by Facebook
  • babel-jest, @babel/preset-env, babel-core@7.0.0-bridge.0 — all the babel stuff to be able to run ES6 in jest
  • @storybook/addon-storyshots — addon that integrates storybook to jest
  • @storybook/addon-storyshots-puppeteer — integration of storybook to puppeteer + jest-image-snapshot

And all this together:

yarn add -D jest babel-core@7.0.0-bridge.0 babel-jest @babel/preset-env @storybook/addon-storyshots@alpha @storybook/addon-storyshots-puppeteer@alpha

Now we need to add the jest.confg.js :

module.exports = {
cacheDirectory: '.cache/jest',
clearMocks: true,
moduleNameMapper: {
'\\.(css|scss)$': '<rootDir>/styleMock.js',
},

roots: [
'<rootDir>/src',
],
transform: {
'^.+\\.jsx?$': 'babel-jest',
},

testEnvironment: 'jsdom',
moduleFileExtensions: ['js', 'jsx', 'json'],
};

Notice, I am mocking the css files here - it’s not really important since we don’t really use jsdom in this example, so to make it work just add an empty styleMock.js file at a root level.

And here is the simple .babelrc file:

{
"presets": [
"@babel/preset-env"
]
}

For the last step to make the test work, we need to add a script to package.json (to be honest, it’s mostly needed for npm users):

"scripts": {
"storybook": "start-storybook -p 8008",
"build-storybook": "build-storybook -o ./public",
"test": "jest"
}

Image snapshots could be set up as against Storybook running in a dev mode as against a static build (prod mode). We have already configured build-storybook script to build a static version of Storybook to the public directory, so we will use this mode to our test (I also consider it a better solution for CI, instead of running a dev server during the build, but use-cases may vary). To create the static build run the following command:

yarn build-storybook

Now let’s create our imageSnapshots.test.js under the src directory. Steps above should have been organized the following files structure:

project/
.storybook/
config.js
public/
src/
imageSnapshots.test.js
story.js
style.js
node_modules/
.bablerc
jest.config.js
styleMock.js

package.json
yarn.lock

For the initial test’s logic, we will add a imageSnapshot test method to storyshots:

import path from 'path';
import initStoryshots from '@storybook/addon-storyshots';
import {imageSnapshot} from '@storybook/addon-storyshots-puppeteer';
const storybookUrl = path.resolve('public');initStoryshots({
framework: 'html',
suite: 'Image storyshots',
test: imageSnapshot({
storybookUrl,
})
});

As you can see above, we’ve added a storybookUrl to the file location of our statically exported Storybook. Also, framework is configured to html, since we are using an html example, but for React/Angular/Whatever-supported should be used a relevant alternative.

Currently running the test command with yarn test will generate image snapshots for the standard mode of the preview:

We would like to automate the visual snapshot’s creation for different devices. For that purpose, we can use a DeviceDescriptors module that is shipped with puppeteer. This file comes with a huge amount of pre-configured view-ports that we can use for device emulation (Like we did it with Chrome devtools). For the purpose of this example, we will limit the devices list to this: iPad, iPhone 5, iPhone 6 and iPhone 7 Plus. In order to emulate headless Chrome with a device we will use customizePage parameter of the imageSnapshot.

import path from 'path';
import pupDevices from 'puppeteer/DeviceDescriptors';
import initStoryshots from '@storybook/addon-storyshots';
import {imageSnapshot} from '@storybook/addon-storyshots-puppeteer';

const storybookUrl = path.resolve('public');
const supportedDevices = new Set(['iPad', 'iPhone 5', 'iPhone 6', 'iPhone 7 Plus']);

function createCustomizePage(pupDevice) {
return function(page) {
return page.emulate(pupDevice);
}
}


for (let supportedDevice of supportedDevices) {
const pupDevice = pupDevices[supportedDevice];

if (!pupDevice) {
continue;
}

const customizePage = createCustomizePage(pupDevice);

initStoryshots({
framework: 'html',
suite: `Image storyshots: ${pupDevice.name}`,
test: imageSnapshot({
storybookUrl,
customizePage,
})
});
}

As you can see in the changes above, we are iterating through the allowed devices’ definition, and initializing storyshots in each iteration with different device instance.

Let’s now yarn test again to see the difference:

Looking into the generated images, we can see the difference in colors and sizes 😎:

For the final project, you can take a look into this repo. It also includes changes needed to group every device into its own snapshots directory, but for the purpose of this post, it’s less relevant.

Summarizing the said above, I hope I’ve managed to discover you another powerful side of Storybook that will strengthen your code base. As usual, our links:

--

--