Integrating Storybook with Cypress and HMR

Matti Bar-Zeev - Feb 4 '22 - - Dev Community

In this post join me as I integrate 2 of Frontend's super tools - Storybook and Cypress, to create real e2e automation tests which runs over Storybook’s stories.

As I see it, a high quality component has to have the holy trinity - good Storybook stories, well covering unit tests and good e2e automation tests for it.

We, as FE developers, have many tools which help us achieve this goal but there seems to be an inevitable overlap between them. For example, say I’m testing my React component’s click handling in Jest using React Testing Library and then I test the same functionality with Cypress (or any other e2e framework you may use).

Now, it’s ok to have this overlap. Each test type has it’s advantages. Still I was wondering if it would be possible to reuse parts of a FE dev ecosystem and reduce the maintenance and boilerplate code required to run automation tests over my components.

By the end of this post you will see that it’s very much possible - I will run a Cypress test over a component’s Storybook story, and have it all support HMR (Hot Module Replacement) so that any change to the related files will run the test again.

Let’s get to it -

When I started to play with this idea the first option which came to my mind was to launch Storybook and then tell Cypress to navigate to the component’s iFrame source url and start interacting with it.
It can work but it has some challenges, like making sure the Storybook is up first, and how it is accessed on on-demand spawned environments in the build pipeline, but then another method presented itself to me - using a library that Storybook team has developed called @storybook/testing-react

This library's main purpose is to allow developers to use the already written component’s render configuration done in Storybook for the benefit of unit testing, but you know what? You can also use it to render your component for Cypress tests.

I’m taking the Pagination simple component from my @pedalboard/components package to perform some tests on it. It currently has a Storybook story to it, which looks like this:



import React from 'react';
import Pagination from '.';

// More on default export: https://storybook.js.org/docs/react/writing-stories/introduction#default-export
export default {
 title: 'Components/Pagination',
 component: Pagination,
 // More on argTypes: https://storybook.js.org/docs/react/api/argtypes
 argTypes: {
   onChange:{ action: 'Page changed' },
 },
};

// // More on component templates: https://storybook.js.org/docs/react/writing-stories/introduction#using-args
const Template = (args) => <div><Pagination {...args} /></div>;

export const Simple = Template.bind({});
// More on args: https://storybook.js.org/docs/react/writing-stories/args
Simple.args = {
   totalPages:10,
   initialCursor:3,
   pagesBuffer:5,
};


Enter fullscreen mode Exit fullscreen mode

And here is how it looks under Storybook:

Image description

I know - it cannot go any simpler than that ;)
Let's set the requirements of my tests as follows:

  1. Mount the component, which has its cursor set on “3” (as defined in the story)
  2. Click the “PREV” button 3 times
  3. Assert that the “PREV” button is disabled and no longer can be clicked.

Yes, you are right - this can also be checked with a react testing library, but remember that some things cannot, and more so, we’re using real DOM here.

We start with installing Cypress:



yarn add -D cypress


Enter fullscreen mode Exit fullscreen mode

I will just kick start it to check that everything is working as expected and then I can move on:



yarn run cypress open


Enter fullscreen mode Exit fullscreen mode

Yep, all seem to work well. Cypress launches a Chrome browser and I have a load of sample tests under the packages/components/cypress/integration directory, but I don’t care about it at the moment.

Creating our test file

I like to keep all the tests of a component under its own directory. This will also go for the Cypress test I’m about to create. I will stick to the *.spec.js convention and create a file called index.spec.js under the component’s directory.

The current content of this test will be pasted from Cypress docs:



describe('My First Test', () => {
  it('Does not do much!', () => {
    expect(true).to.equal(false)
  })
})


Enter fullscreen mode Exit fullscreen mode

But when running Cypress again it does not find the newly created tests, and I don’t blame it since it does not look in the right place. Let’s change that - in the cypress.json file I will add the following configuration:



{
   "testFiles": "**/*.spec.{js,ts,jsx,tsx}",
   "integrationFolder": "src"
}


Enter fullscreen mode Exit fullscreen mode

Running Cypress again, and sure enough my test fails as expected. We’re on track!

Image description

And now for the interesting part…

Integrating

I first need to install 2 key libraries:

The first is the @storybook/testing-react I mentioned at the beginning, which will allow me to compose a component from a Story, or in other words, allowing me to “generate” a render ready component from a Storybook story.

The second one is @cypress/react which will allow me to mount the component so that Cypress can start interacting with it:



yarn add -D @storybook/testing-react @cypress/react


Enter fullscreen mode Exit fullscreen mode

Here it gets a little bit complicated -
I will first start with the additional libraries we need to install and explain later on:



yarn add -D @cypress/webpack-dev-server webpack-dev-server


Enter fullscreen mode Exit fullscreen mode

I will configure cypress’ component testing to look for tests under the src directory in the cypress.json file:



{
   "component": {
       "componentFolder": "src",
       "testFiles": "**/*spec.{js,jsx,ts,tsx}"
   }
}


Enter fullscreen mode Exit fullscreen mode

Since we’re testing components, I’m using the “component” key here, to define how it should act. You can read more about it here.

We’re not done yet. In order to support HMR for the tests we need to set cypress to work with the dev-server plugin we installed earlier. We do that by adding the following to the cypress/plugins/index.js file like so:



module.exports = async (on, config) => {
   if (config.testingType === 'component') {
       const {startDevServer} = require('@cypress/webpack-dev-server');

       // Your project's Webpack configuration
       const webpackConfig = require('../../webpack.config.js');

       on('dev-server:start', (options) => startDevServer({options, webpackConfig}));
   }
};


Enter fullscreen mode Exit fullscreen mode

If you have a sharp eye you probably noticed the reference to a webpack.config.js file there. Yes, it is a must. There are a few ways you can do it (as described here) and I decided to use the custom Webpack config way.

The project I’m doing this for does not have its own Webpack configuration (yet) and my first attempt was to get the configuration that Storybook uses, but it presented some challenges. I think it would be nice to have some sort of an adapter to fetch and use Storybook’s Webpack configuration, much like the existing adapter for fetching Create-React-App Webpack configuration.

My webpack.config.js for this purpose is the bare minimum needed. It does not have an entry point, nor an output. Just rules for babel-loader, style-loader and css-loader:



module.exports = {
   module: {
       rules: [
           {
               test: /\.(jsx|js)$/,
               exclude: /(node_modules)/,
               use: {
                   loader: 'babel-loader',
                   options: {
                       presets: ['@babel/preset-env', '@babel/preset-react'],
                   },
               },
           },
           {
               test: /\.css$/i,
               exclude: /(node_modules)/,
               use: ['style-loader', 'css-loader'],
           },
       ],
   },
};


Enter fullscreen mode Exit fullscreen mode

Now that I get these all set up, I can modify my test to start interacting with Storybook. My test currently just mounts the Pagination component and that’s it. No interactions or assertions yet:



import React from 'react';
import {composeStories} from '@storybook/testing-react';
import {mount} from '@cypress/react';
import * as stories from './index.stories.jsx';

// compile the "Simple" story with the library
const {Simple} = composeStories(stories);

describe('Pagination component', () => {
   it('should render', () => {
       // and mount the story using @cypress/react library
       mount(<Simple />);
   });
});


Enter fullscreen mode Exit fullscreen mode

Let’s run the cypress tests and hope for the best :) I’m doing that using the open-ct cypress command which will launch only the component testing.



yarn cypress open-ct


Enter fullscreen mode Exit fullscreen mode

Dang! The component is rendered on Cypress’ opened browser. The cool thing about it is that you don’t need new rendering instructions for the component’s instance you are testing, but rather you are actually using the rendering instructions from the story :)

Image description

Testing at last

So if you can still remember, after all this joy-ride of configurations, the test I wanted to create is very simple - click several times on the “PREV” button and then assert that you can no longer click it, since you’ve reached the first page and the button is disabled.

Here is my test now:



import React from 'react';
import {composeStories} from '@storybook/testing-react';
import {mount} from '@cypress/react';
import * as stories from './index.stories.jsx';

// compile the "Simple" story with the library
const {Simple} = composeStories(stories);

describe('Pagination component', () => {
   describe('PREV button', () => {
       it('should be disabled when reaching the first page', () => {
           // and mount the story using @cypress/react library
           mount(<Simple />);

           const prevButton = cy.get('button').contains('PREV');

           prevButton.click();
           prevButton.click();
           prevButton.click();

           prevButton.should('be.disabled');
       });
   });
});


Enter fullscreen mode Exit fullscreen mode

And yes - saving this file runs the test again (HMR is a bliss) and it does exactly what I expected from it (and quite fast, my I add):

Image description

And that’s it, we have it!

Wrapping up

So let’s see what we have -
We got a Cypress running a single test on a component whose rendering configuration is imported from the component’s Storybook story. Any time I change the tests, the story or the component Cypress will run the test again, which gives me a great immediate feedback to any changes I make.
Although the integration is not the smoothest as it can be, the final result is still totally worth it -
In case you have more stories for your component, you can mount them as well and have Cypress run different tests correspondingly. The ability to reuse the component’s stories for Cypress tests significantly reduces duplication in rendering configuration and helps with tests maintenance.

Pretty darn good ;) but as always, if you have any ideas on how to make this better or any other technique, be sure to share with the rest of us!

Hey! If you liked what you've just read check out @mattibarzeev on Twitter 🍻

Photo by Vardan Papikyan on Unsplash

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .