Storybook is easily one of my favorite tools in the world of UI/UX engineering. If you haven’t used it before, I highly recommend checking it out! This article will show you some Storybook but is not intended as a deep-dive.
Storybook gives us an easy-to-setup, easy-to-use framework for testing and demoing components in isolation and allowing us to simulate different component states and seeing the results without having to architect out the full app to get the component into that state. Super cool stuff.
NX is another favorite tool of mine. It is marketed as “a set of extensible tools for monorepos.” Monorepos are a very hot-topic right now in the engineering world and are gaining momentum, and NX with its CLI and toolchain make developing, structuring, and maintaining your monorepo a breeze (yet again, this article uses and will show nx and some usage with the CLI, but it is not aimed to be a monorepo or nx deep-dive).
The problem at-hand
At my current role, we use both nx
and storybook
in a reasonably rapidly growing way in our architecture as we continue modernizing our stack and moving more and more of our UI/UX over to react. Storybook has been mega in allowing engineers to build and demo components to stakeholders without having to build up the full architecture into the full stack (which involves a Java/Spring API and a large amount of JSP).
All-in-all, this process, once established, has been pretty straight-forward and easy to implement, use and develop. But one problem that has come up is how to demo components that in some way or another make API requests and then interact with that data.
Free eBook
Directives, simple right? Wrong! On the outside they look simple, but even skilled Angular devs haven’t grasped every concept in this eBook.
- Observables and Async Pipe
- Identity Checking and Performance
- Web Components <ng-template> syntax
- <ng-container> and Observable Composition
- Advanced Rendering Patterns
- Setters and Getters for Styles and Class Bindings
This was being solved by a mix of running the API while running storybook (very real-world, but also defeats the purpose a bit and involved a lot of, “Oh yeah, you gotta run the API and use an id
that exists in your local db.”), pulling the API requests up a component and passing the data in to lower-level components as props (the container->view architecture approach, nothing wrong with this other than hooks and context make it less necessary), or not adding stories for these components (also very real-world, and you know sometimes you just gotta ship it).
All of these left me feeling a bit like, “There has got to be a way, right?” And of course, there is…
The solution: Mock-Service-Worker
When I finally got fed up enough to start searching for a solution, my thought process was along the lines of, “How do I mock the API requests being ran in storybook like what I would do in unit-testing?” Which lead me down a path of trying to figure out some like jest-esque way to mock our axios instance and then clear out those mocks, etc. etc. But I could not get any traction on it.
So, I opened up the googles and started going down the rabbit-hole with search terms like, “nx storybook api mock” with a few variations on the theme to try to find something positive (and you know, written somewhat recently; appreciate that after:{year}
functionality). A combo of this and a suggestion from my friend Mat Warger lead me to mock-service-worker (msw
for short, and the remainder of this article).
msw
is an awesome lib that allows for the intercepting of network-level requests by providing mocks for these requests. These mocks are easily reused for testing, development, debugging, or, like what I needed, storybook development. It also has support for both rest
and graphql
, which is super cool as I have some lofty goals to use graphql with this app in the future; outside of being a huge graphql fan in-general.
The installation and setup are a breeze (as I will demo below) and the documentation is well laid out, easy to follow, and even has examples; honestly good documentation is becoming a must for me when I consider using a dep or library.
The only issue (and the crux of why I am writing this article) is that the msw
, storybook
nor the nx
docs had any sort of examples of how I would combine these three frameworks to mock API requests for components in either apps or libs in my nx
monorepo for my storybook
stories. So I followed the advice tweeted out by Sam Julien, and decided to write a blog about how I went about solving this.
Getting Setup
This article assumes you have an existing (or will setup) an nx
workspace that we will use for the duration of this article. This workspace should also have @nrwl/storybook
configured; follow this tutorial to configure storybook in your workspace, if necessary. Our workspace will have these projects:
- app:client - our react application and the default project in our workspace
- lib:models - common, shareable models lib that other projects can use
- lib:util-testing - test mocks and utils for our
msw
mocks and data mocking
Here is (a simplified) directory structure for our workspace.
|- .storybook/
| |- main.js <- this is the main configuration file for storybook since storybook v5.3+.
| |- tsconfig.json
| |- webpack.config.js <- webpack configuration for storybook in our workspace
|- apps/
| |- client/
| |- .storybook/ <- this is the storybook configuration folder for our client project that was selected when we generated our configuration
| |- main.js <- storybook configuration specific to our client project, extends the root storybook config
| |- preview.js <- this used to be config.js. it configures the `preview` iframe that storybook uses to render the components
| |- preview-head.html <- html that gets rendered into the iframe that displays the stories
| |- tsconfig.json
| |- webpack.config.js <- webpack configuration for client project
| |- src/
| |- app/
| |- characters-list-rest/ <- directory for our Rick & Morty Characters list from the REST API
| |- characters-list-rest.stories.tsx <- the stories for our characters-list-rest.tsx component
| |- characters-list-rest.tsx <- component that makes the request to get the characters list from the REST API and renders them
| |- app.tsx <- app react component. has our routes and the app-frame
| |- app.stories.tsx <- stories file for our app.tsx component
| |- environments/
| |- environment.prod.ts <- environment values for production
| |- environment.ts <- dev environment values
| |- index.html <- app mount and entry point
| |- main.tsx <- initializes our react app and renders it into the DOM at the mount point
| |- theme.tsx <- builds our @material-ui theme and ThemeProvider for our app
|- libs/
| |- models/ <- common lib containing our shared application models
| |- util-testing/ <- common lib contains utils and `msw` mocks for testing and storybook dev
| |- src/
| |- lib/mocks/
| |- browser.ts <- sets up `msw` mock service worker with mock handlers
| |- handlers.ts <- builds request mocks for `msw` to intercept API requests
| |- rest.mocks.ts <- builds mock RestResponse data
|- public/
| |- mockServiceWorker.js <- generated mock service worker by `msw`
By default (and configured in the apps/client/.storybook/main.js
file), our storybook
configuration looks for any files with a *.stories.{js|ts|jsx|tsx|mdx}
naming convention (like app.stories.tsx
) in any directory inside of apps/client/src
.
Let’s start our storybook instance and see our stories!
# with npx
npx nx storybook client
# with nx-cli installed
nx storybook client
# or
nx run client:storybook
Once the startup completes successfully, our storybook
instance will be running at http://localhost:4400 (yet again, configurable). Open a web browser and point to the url and we should see our app component stories. Another huzzah!
Storybook v6
With the release of storybook
v6+, the storybook
team also released an addon called @storybook/addon-essentials. This is an aggregation of many addons that have been prevalent and widely used in storybook
. Addons like: knobs, actions, docs, accessibility, viewport, etc. With @storybook/addon-essentials
, instead of having to install and config all of these separate addons, just install the essentials, and add to the addons
in the root /.storybook/main.js
export (and remove the addons) and we get access to use these essentials (as we will see when start working with stories).
# install addons-essentials
npm install --save-dev @storybook/addon-essentials
# with yarn
yarn add --dev @storybook/addon-essentials
# remove any other addons, @storybook/addon-knobs comes with the storybook generation from @nrwl/storybook
npm uninstall --save-dev @storybook/addon-knobs
# with yarn
yarn remove @storybook/addon-knobs
Open the root storybook
config ./.storybook/main.js
and add the @storybook/addon-essentials
addon:
// ./.storybook/main.js
module.exports = {
stories: [],
addons: ['@storybook/addon-essentials'],
};
We also need to remove @storybook/addon-knob
from the preview.js
in our client storybook
configuration. There is nothing else in this file, so it will now be empty. Don’t delete it as we will revist this file shortly.
Making API requests
Now that we have our repo, with a react
app, and storybook
setup, let’s make a component that makes an API request that we can then mock with msw
. We will do a couple versions of this, one with rest
and one with graphql
to demo both and demo mocking both.
Rick and Morty API
This tutorial will utilize the free-to-use Rick & Morty API for both the rest and graphql API examples.
🕵️♂️ Check out the docs for the Rick & Morty API.
REST
Let’s update our characters-list-rest
component to make the API request to the Rick & Morty API and render the data. Open the apps/client/src/app/characters-list-rest/characters-list-rest.tsx
component file and let’s edit it to:
- Import the required components and models.
- Establish some component state for: loading, the characters list, and an error state just-in-case.
- Add a
useEffect
with theurl
prop as a dependency, that will make the rest request with thefetch
api and update the state to reflect this request/handle errors. - Return the JSX to render for the three-different states: loading, errors, with characters returned
// apps/client/src/app/characters-list-rest/characters-list-rest.tsx
// 1. imports
import React, { useState, useEffect } from 'react';
import { makeStyles, Theme } from '@material-ui/core/styles';
import Typography from '@material-ui/core/Typography';
import Card from '@material-ui/core/Card';
import CardHeader from '@material-ui/core/CardHeader';
import CardContent from '@material-ui/core/CardContent';
import List from '@material-ui/core/List';
import ListItem from '@material-ui/core/ListItem';
import ListItemAvatar from '@material-ui/core/ListItemAvatar';
import Avatar from '@material-ui/core/Avatar';
import ListItemText from '@material-ui/core/ListItemText';
import Skeleton from '@material-ui/lab/Skeleton';
import Alert from '@material-ui/lab/Alert';
import AlertTitle from '@material-ui/lab/AlertTitle';
// import our shared Character model from the `libs/models`
import { Character, RestResponse } from '@cmw-uc/models';
const useStyles = makeStyles((theme: Theme) => ({
root: {
width: '100%',
},
charTitle: {
width: '100%',
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
charStatus: {
marginLeft: `${theme.spacing(2)}px`,
},
}));
const CharactersListError: React.FC<{ errorMsg: string }> = ({ errorMsg }) => (
<Alert severity="error" variant="filled">
<AlertTitle>Rick & Morty REST API Character List Error</AlertTitle>
<span>{errorMsg}</span>
</Alert>
);
const CharacterListLoading = () => {
const classes = useStyles();
return (
<div className={classes.root}>
<Typography variant="h3" component="h3">
<Skeleton />
</Typography>
<Skeleton variant="rect" width="100%" height={450} />
<Skeleton variant="text" width="100%" />
<Skeleton variant="text" width="100%" />
<Skeleton variant="text" width="60%" />
</div>
);
};
const CharacterList: React.FC<{ characters: Character[] }> = ({
characters,
}) => {
const classes = useStyles();
return (
<List className={classes.root}>
{characters.map((char: Character) => (
<ListItem key={char.id}>
<ListItemAvatar>
<Avatar alt={char.name} src={char.image} />
</ListItemAvatar>
<ListItemText
primary={
<Typography variant="subtitle1" className={classes.charTitle}>
<span>{char.name}</span>
<Typography variant="body2" className={classes.charStatus}>
{char.status}
</Typography>
</Typography>
}
secondary={
<Typography variant="body1">
<span className={classes.charInfoTitle}>Character info:</span>
<span className={classes.charInfo}>{char.gender},</span>
<span className={classes.charInfo}>{char.species},</span>
<span className={classes.charInfo}>{char.origin.name},</span>
<span className={classes.charInfo}>{char.location.name}</span>
</Typography>
}
/>
</ListItem>
))}
</List>
);
};
export interface CharactersListRestProps {
url: string;
}
const CharactersListRest: React.FC<CharactersListRestProps> = ({ url }) => {
// 2. establish component state
const classes = useStyles();
const [loadingCharactersList, setLoadingCharactersList] = useState(false);
const [characters, setCharacters] = useState<Character[]>([]);
const [charactersListError, setCharactersListError] = useState<Error | null>(
null
);
// 3. add the useEffect to retrieve Rick & Morty Character list from REST API
// hit the Rick & Morty REST API and update the state
useEffect(() => {
setLoadingCharactersList(true);
const retrieveCharactersList = async () => {
try {
const response: Response = await fetch(
`${environment.rickAndMortyRestApiBaseUrl}/character`,
{
headers: {
Accept: 'applications/json',
'Content-Type': 'applications/json',
},
}
);
if (response.status !== 200) {
setCharactersListError(
new Error(
'Did not successfully retrieve Rick & Morty Characters list'
)
);
} else {
// get the response data as a RestResponse instance
const data: RestResponse = await response.json();
if (data == null) {
setCharactersListError(
new Error(
'Did not successfully retrieve Rick & Morty Characters list'
)
);
} else {
setCharacters(data.results);
}
}
} catch (error) {
setCharactersListError(error);
} finally {
setLoadingCharactersList(false);
}
};
retrieveCharactersList();
}, [url]);
// 4. Return JSX with components for different states
return (
<section className={classes.root}>
<Card>
<CardHeader title="Rick & Morty REST API Characters List" />
<CardContent>
{loadingCharactersList ? <CharacterListLoading /> : null}
{charactersListError != null ? (
<CharactersListError errorMsg={charactersListError.message} />
) : null}
{!loadingCharactersList && charactersListError == null ? (
<CharacterList characters={characters} />
) : null}
</CardContent>
</Card>
</section>
);
};
export default CharactersListRest;
And here is our component that loads the characters and renders them into the DOM as well as some loading and error states. It takes a url: string
component prop that is used to make the API request; this is so we can pass in different URLs to mock.
Character List REST Component Stories
Let’s create storybook file for our characters-list-rest
component to demo the component in isolation.
Optional If you decided to use @material-ui
, as I did, let’s real quick configure our stories to use our theme and fonts.
Create a preview-head.html
file in our apps/client/.storybook
directory. Whatever we put in this html will get added to the head of our iframe that renders our stories.
<!-- apps/client/.storybook/preview-head.html -->
<link rel="preconnect" href="https://fonts.gstatic.com" />
<link
href="https://fonts.googleapis.com/css2?family=Open+Sans+Condensed:wght@300&display=swap"
rel="stylesheet"
/>
<link
rel="stylesheet"
href="https://fonts.googleapis.com/icon?family=Material+Icons"
/>
And now update the preview.js
to wrap all of our stories in our ThemeProvider
.
// apps/client/.storybook/preview.js
import React from 'react';
import { addDecorator } from '@storybook/react';
// my custom material-ui ThemeProvider/theme
import { AppThemeProvider } from '../src/theme';
// wrap all of our stories in our AppThemeProvider
addDecorator((story) => <AppThemeProvider>{story()}</AppThemeProvider>);
Our stories file will be pretty light as the characters-list-rest
component doesn’t do a whole lot and the three states are determined by the interaction with the fetch API. Let’s take a look.
// apps/client/src/app/characters-list-rest/characters-list-rest.stories.tsx
import React from 'react';
import { environment } from '../../environments/environment';
import CharactersListRest, {
CharactersListRestProps,
} from './characters-list-rest';
const characterUrls = {
default: `${environment.rickAndMortyRestApiBaseUrl}/character`,
largeList: `${environment.rickAndMortyRestApiBaseUrl}/character/large-list`,
smallList: `${environment.rickAndMortyRestApiBaseUrl}/character/small-list`,
error: `${environment.rickAndMortyRestApiBaseUrl}/character/error`,
};
export default {
component: CharactersListRest,
title: 'Components / Characters List / REST',
argTypes: {
url: {
control: {
type: 'select',
options: {
'Default (Passthrough)': characterUrls.default,
'Large Character List': characterUrls.largeList,
'Small Character List': characterUrls.smallList,
Error: characterUrls.error,
},
},
},
},
};
const Template = (args: CharactersListRestProps) => (
<CharactersListRest {...args} />
);
export const CharactersListRestDemo = Template.bind({});
CharactersListRestDemo.args = {
url: characterUrls.default,
};
This stories structure is called Component Story Format (CSF) and follows the v6 strategy of building stories for components. Defining the argTypes
on our default export allows us to change the value of different components props (in this case just url
) in our story via the controls and see the different results. If we run our storbook for client
again, we can view the Characters List Rest Demo
and see our component as well as our App
component stories.
Free eBook
Directives, simple right? Wrong! On the outside they look simple, but even skilled Angular devs haven’t grasped every concept in this eBook.
- Observables and Async Pipe
- Identity Checking and Performance
- Web Components <ng-template> syntax
- <ng-container> and Observable Composition
- Advanced Rendering Patterns
- Setters and Getters for Styles and Class Bindings
As you will notice, our stories still make and show the data returned from the Rick & Morty API here as well. In this case because this API is public, this is fine. But the point of stories is to view components in isolation, meaning without outside involvement; including API requests.
Time for msw
.
Adding msw
The time has finally come. The crux of the article. It is time to add and configure msw
to our workspace and mock our API requests. The msw docs do a fantastic job of walking you through the install and set up, so I will summarize here:
Install msw
as a dev dependency
# with npm
npm install --save-dev msw
# with yarn
yarn add msw
Now we need to define our mock handlers. Open/create the libs/util-testing/src/lib/mocks/handlers.ts
file and define our mocks to return our mocked data or state based off the url:
// libs/util-testing/src/lib/mocks/handlers.ts
import { RestResponse } from '@cmw-uc/models';
import { rest, RequestHandler } from 'msw';
import { buildRestResponse } from './rest.mocks';
const smallCharCountResponse = buildRestResponse(5);
const largeCharCountResponse = buildRestResponse(20);
export const handlers: RequestHandler[] = [
// mock the character list endpoint
rest.get(
'https://rickandmortyapi.com/api/character/:state',
(req, res, ctx) => {
// check the `state` request param to determine what to return for our mocked response
// the values of state are determined in our `characters-list-rest.stories.tsx`
const { state } = req.params;
switch (state) {
case 'small-list': {
// return a successful response after a 1.5sec delay to show our loading indicator
return res(
ctx.status(200),
ctx.delay(1500),
ctx.json(smallCharCountResponse)
);
}
case 'error': {
// return an error response of 404: Not Found
return res(
ctx.status(404),
ctx.delay(1500),
ctx.json({ errorMessage: 'Failure' })
);
}
default: {
// return a successful response after a 1.5sec delay to show our loading indicator
return res(
ctx.status(200),
ctx.delay(1500),
ctx.json(largeCharCountResponse)
);
}
}
}
),
];
The meat of this is in the handlers
. This defines a list of rest request handlers for the API endpoint. Notice the :state
URL param. We introspect this value and based off the value, return a different response. These values are supplied and defined in our apps/client/src/app/characters-list-rest/characters-list-rest.stories.tsx
story file for different mock API endpoint options to demo. This is a really handy abstraction for intercepting the API requests and returning different states for the response for our component to then use and render different component states, such as error, a short list, a long list, and to see our loading skeleton.
For our client
storybook instance to know about these mocks we need to setup a service worker and pass in our handlers. Create a file in the libs/util-testing/src/lib/mocks
directory called browser.ts
(we use browser
as stories run in a browser, as opposed to server
if this was to mock on a node server).
// lib/util-testing/src/lib/mocks/browser.ts
import { setupWorker, SetupWorkerApi } from 'msw';
import { handlers } from './handlers';
// this configures a browser service work with our mocked request handlers
export const worker: SetupWorkerApi = setupWorker(...handlers);
We also need to create a mock service worker file that our storybook instance will use. msw
will generate this file for us, we just need to point it to the appropriate directory. According to the docs this should be a public
directory. This is interesting as in nx
we don’t have a public
directory like you would in a react app, etc. Note there may be a more appropriate way to do this, but this is how I got this to work. Create a directory at the root workspace level named public
:
# open a terminal in the root workspace directory
mkdir public
Now run the msw init
command
# open a terminal in the root workspace directory
npx msw init public/
This will create a file: public/mockServiceWorker.js
.
We now need to import the exported worker
from our libs/util-testing/src/lib/mocks/browser.ts
and register it when we start storybook in our client. Open the apps/client/.storybook/preview.js
, import and register our worker.
// apps/client/.storybook/preview.js
// Storybook executes this module in both bootstap phase (Node)
// and a story's runtime (browser). However, cannot call `setupWorker`
// in Node environment, so need to check if we're in a browser.
if (typeof global.process === 'undefined') {
// note: we must use relative imports here
const {
worker,
} = require('../../../libs/util-testing/src/lib/mocks/browser');
// Start the mocking when each story is loaded.
// Repetitive calls to the `.start()` method do not register a new worker,
// but check whether there's an existing once, reusing it, if so.
worker.start();
}
Note When I did this the first time, I was inclined to export the worker
out through the util-testing
lib like I would with any lib and then import it like this: const { worker } = require('@cmw-uc/util-testing');
. While this works when you run storybook, the issue is, it will fail when you run your test suite. This is because the test suite is run in a node environment. Because of that, when the @cmw-uc/util-testing
is imported, it will register the worker with the setupWorker
which will then fail as setupWorker
is intended for a browser environment. So while admittedly a bit gnarly, the way I solved this issue was by using a relative import instead of the pathed import from @cmw-uc/util-testing
.
Now, let’s start our client storybook instance again with our mocks. For storybook to pick up and register our mockServiceWorker
, we need to point it at our public
static directory. We can do this when we run the cli command to start storybook by passing in the --static-dir
config paramter with a value of public
nx storybook client --static-dir=public
And open our storybook in the browser at http://localhost:4400, open the browser console and you should see a console.log
line that says [MSW] Mocking enabled
; this is the indication that our service worker mocking is successful and we can mock our API requests and handle them with different resulsts and error states! As we change the url
value in the dropdowns to the different options, our story should show the different results of the different list lengths and error state or passthrough to the actual API.
This is mega cool and very helpful for taking our storybook and app development up in our nx monorepo.
Note We can also permanently set the --static-dir=public
storybook cli param by opening our workspakce.json
file and updating the storybook configuration for our client by adding a "staticDir": ["public"]
option to the configuration object.
{
"version": 1,
"projects": {
"client": {
"root": "apps/client",
"sourceRoot": "apps/client/src",
"projectType": "application",
"schematics": {},
"architect": {
// ... other client architecture configuration
"storybook": {
"builder": "@nrwl/storybook:storybook",
"options": {
"uiFramework": "@storybook/react",
"port": 4400,
"config": {
"configFolder": "apps/client/.storybook"
},
"staticDir": ["public"] // add here for running storybook
},
"configurations": {
"ci": {
"quiet": true
}
}
},
"build-storybook": {
"builder": "@nrwl/storybook:build",
"options": {
"uiFramework": "@storybook/react",
"outputPath": "dist/storybook/client",
"config": {
"configFolder": "apps/client/.storybook"
},
"staticDir": ["public"] // add here for build-storybook
},
"configurations": {
"ci": {
"quiet": true
}
}
}
}
}
// ... other projects
}
// ... remainder of workspace json
}
Conclusion
nx
and storybook
are very useful and powerful toolsets to architect and build enterprise-grade applications. Adding in msw
gives us the ability to take that a step-further but not letting API requests determine our architecture decisions y allowing us to mock those requests and see the results of those mocks in our stories and view our components in isolation with controlled conditions.