SPDT - Storybook Puppeteer Declarative Testing
TL;DR
npm i -D spdt
or yarn add spdt -D
Overview
Declarative testing of isolated React components using storybook (v4) as a renderer and puppeteer+jest as a test runner.
It's not yet another testing framework. It's just the way how to implement DRY (don't repeat yourself) principle for testing React components.
- write a fixture - props for React component
- add a declaration - a single line which describes what to test
- write a simple storybook story
- call npm run spdt to generate tests and run storybook server
- call npm run spdt:test to run generated jest tests - puppeteer reads react components in storybook and calls assertions
Here is an example.
We have a React component to display a list of products.
// file: src/example/ProductList.jsx
const ProductList = ({list}) => (
<div>
{list.map((item, index) => <div key={index} className="itemElement">{item}</div>)}
</div>
)
export default ProductList
We have some testing data (fixture) to render: ['apricot', 'banana', 'carrot']
We need to write a test to check that three items (with class .itemElement) will be rendered.
Let's write a fixture in such a form:
// file: src/example/__tests__/ProductList.fixture.js
export default {
listOfThree: {
props: {
items: ['apricot', 'banana', 'carrot']
},
spdt: {
checkSelector: {selector: '.itemElement', length: 3} // <----
}
}
}
That simple declaration checkSelector: {selector: '.itemElement', length: 3}
makes spdt library to generate a test for you.
No need to copy-paste unit tests any more.
Just write a test pattern (declaration) once and reuse it over and over (see example testH1 below)
You can add as many fixtures as you want:
// file: src/example/__tests__/ProductList.fixture.js
export default {
listOfThree: {
props: {
items: ['apricot', 'banana', 'carrot']
},
spdt: {
checkSelector: {selector: '.itemElement', length: 3} // <----
}
},
listOfTwo: {
props: {
items: ['orange', 'tangerine']
},
spdt: {
checkSelector: {selector: '.itemElement', length: 2} // <----
}
}
}
The idea behind this module was to make testing of React+D3 components based on fixtures. However spdt can speed up testing of any React application
Here is a short description of the workflow:
- create a React component, e.g
Comment.js
- create a fixture file with a set of properties for your component:
Comment.fixture.js
- create a story file
Comment.story.js
which is used by Storybook to generate versions of your component based on fixture file: - run
npm run spdt:generate-story-index
to generate.spdt/index.js
file for Storybook - add asserts/expectations for the component in fixture file (see examples below)
- run
npm run spdt:generate-test-index
to generate.spdt/test-index.generated.js
file. - run
npm run spdt:generate-tests
to generate test files for each React component (which has story and fixture files), e.g.Comment.generated.spdt.js
- run Storybook server
npm run spdt:storybook
- run generated tests using jest + puppeteer
npm run spdt:test
(in another terminal tab)
How to install spdt
- Install it from npm
npm i -D spdt
- Run initialization
node_modules/.bin/spdt:init
It will copy config files (jest, puppeteer, storybook) to predefined folder (by default./spdt
) - Copy generated scripts from terminal to your package.json file
"spdt:generate-story-index": "./node_modules/.bin/spdt:generate-story-index",
"spdt:generate-test-index": "./node_modules/.bin/spdt:generate-test-index",
"spdt:generate-tests": "./node_modules/.bin/spdt:generate-tests",
"spdt:test": "jest --detectOpenHandles --config ./.spdt/jest.spdt.config.js",
"spdt:test:chrome": "HEADLESS=false jest --detectOpenHandles --config ./.spdt/jest.spdt.config.js",
"spdt:test:chrome:slow": "SLOWMO=1000 HEADLESS=false jest --detectOpenHandles --config ./.spdt/jest.spdt.config.js",
"spdt:storybook": "start-storybook -p 9009 -c ./.spdt",
"spdt": "npm run spdt:generate-story-index && npm run spdt:generate-test-index && npm run spdt:generate-tests && npm run spdt:storybook"
Which npm modules need to be installed
- @storybook/react@5.x (if you still using storybook v4 please use version spdt@1.1.6)
- @babel/node@^7.2 (@babel libs are used for generating test files)
- @babel/core@^7.3
- @babel/plugin-transform-runtime@^7.3
- puppeteer@^1.20.0
- jest-puppeteer@^4.2.0
- react@16.x
How to use
For example you have a react component like this
// src/components/SimpleComponent+.js
import React from 'react'
export default function(props) {
const { title, children } = props
return (
<div className="simple-component">
{title}
<br />
{children}
</div>
)
}
Create a fixture file SimpleComponent.fixture.js
inside of __tests__
folder near the component
// file src/components/__tests__/SimpleComponent.fixture.js
export default {
fixtureOne: {
props: {
title: 'Component Title',
children: 'Some children components',
},
spdt: {
checkSelector: 'div.simple-component',
},
},
fixtureTwo: {
props: {
title: 'Another Title',
children: 'Some children components',
},
spdt: {
checkSelector: 'div.simple-component',
},
},
}
Here is a schema of the fixture file
{
[unique name of fixture]: {
props: { ... }, // list of all props for your component
spdt: { ... }, // list of assertions such as checkSelector, checkAxes, checkBars, checkArcs, etc.
},
[another fixture]: ...
}
Create a file SimpleComponent.story.js
inside of __tests__
folder
// file src/components/__tests__/SimpleComponent.story.js
const { storiesOf } = require('@storybook/react')
import SimpleComponent from '../SimpleComponent'
import fixtures from './SimpleComponent.fixture'
export default (storyGenerator) =>
storyGenerator({
storiesOf,
title: 'SimpleComponent',
Component: SimpleComponent,
fixtures,
})
Now run npm run spdt
to call four commands sequencially
-
spdt:generate-story-index
- it will generate./.spdt/index.js
for Storybook -
spdt:generate-test-index
- it will generate a./.spdt/test-index.generated.js
for TestGenerator -
spdt:generate-tests
- it will generate<your component name>.generated.spdt.js
files based on pairs (story.js + fixture.js) -
spdt:storybook
- it will run Storybook server, available at http://localhost:9009
If everything went well and Storybook started to work you can run generated tests
npm run spdt:test
example of a complicated component with dependencies such as Redux, Router
// file src/components/Article/Comment.js
import DeleteButton from './DeleteButton'
import { Link } from 'react-router-dom'
import React from 'react'
const Comment = (props) => {
const comment = props.comment
const show = props.currentUser && props.currentUser.username === comment.author.username
return (
<div className="card">
<div className="card-block">
<p className="card-text">{comment.body}</p>
</div>
<div className="card-footer">
<Link to={`/@${comment.author.username}`} className="comment-author">
<img src={comment.author.image} className="comment-author-img" alt={comment.author.username} />
</Link>
<Link to={`/@${comment.author.username}`} className="comment-author">
{comment.author.username}
</Link>
<span className="date-posted">{new Date(comment.createdAt).toDateString()}</span>
<DeleteButton show={show} slug={props.slug} commentId={comment.id} />
</div>
</div>
)
}
export default Comment
A file Comment.fixture.js
inside of __tests__
folder
// file src/components/Article/__tests__Comment.fixture.js
export default {
fixture1: {
props: {
comment: {
id: 'id123',
body: 'some text',
author: {
username: 'Author name',
image: 'https://i.pinimg.com/originals/b6/89/81/b6898148bfa9df9e67330fca31571f9b.png',
},
createdAt: 'Sat Aug 25 2018',
},
slug: 'slug123',
currentUser: {
username: 'user name',
},
},
spdt: {
checkSelector: ['div.card', 'div.card-footer', 'img.comment-author-img']
},
},
}
A file Comment.story.js
inside of __tests__
folder
import React from 'react'
import { Route, BrowserRouter, browserHistory } from 'react-router-dom'
import { Provider } from 'react-redux'
import reducer from '../../../reducer'
import { createStore } from 'redux'
import Comment from '../Comment'
import fixtures from './Comment.fixture'
const { storiesOf } = require('@storybook/react')
const Component = (props) => (
<div>
<Provider store={createStore(reducer)}>
<BrowserRouter history={browserHistory}>
<Route path="/" component={() => <Comment {...props} />} />
</BrowserRouter>
</Provider>
</div>
)
export default (storyGenerator) =>
storyGenerator({
storiesOf,
title: 'Comment',
Component: Component,
fixtures,
})
Run npm run spdt
and then npm run spdt:test
in another terminal tab
and you should see results of jest test runner
> npm run spdt:test
> react-redux-realworld-example-app@0.1.0 spdt:test .../react-redux-realworld-example-app
> jest --detectOpenHandles --config ./.spdt/jest.spdt.config.js
Setup Test Environment.
PASS src/components/Article/__tests__/Comment.generated.spdt.js
Comment - fixture fixture1
✓ should find component matching selector [div.card] 1 time(s) (16ms)
✓ should find component matching selector [div.card-footer] 1 time(s) (8ms)
✓ should find component matching selector [img.comment-author-img] 1 time(s) (4ms)
Test Suites: 1 passed, 1 total
Tests: 3 passed, 3 total
Snapshots: 0 total
Time: 1.372s, estimated 2s
Ran all test suites.
Teardown Puppeteer
Teardown Test Environment.
SPDT Declarations
Use these declarations as keys in fixtures
export default {
[fixture name]: {
props: { ... },
spdt: {
[declaration name]: [declaration value]
}
}
}
checkSelector
Value can be
-
string
, e.g. 'div.className' -
object
, e.g. {selector: 'div.className', length: 0} -
array
of strings or objects, e.g. ['div.className', {selector: 'li', length: 5}]
This assertion will generate a separate it
test to check provided selector
it('should find component matching selector [div.card] 1 time(s)', async () => {
const components = await page.$$('div.card')
const expected = 1
expect(components).toHaveLength(expected)
}),
checkAttr
Value can be
-
object
, with required keys { selector: 'div.className', expected: 'value-of-attribute', attribute: 'name-of-attribute'} -
array
of objects, e.g.[{selector:'div', expected:'x', attribute:'y'}]
checkSvg
Value can be of Boolean
type
- value
true
means thatit
test will be generated to checksvg
tag is present on the page - value
false
means thatit
test won't be generated
it('should load component as <svg>', async () => {
const component = await page.$('svg')
expect(component._remoteObject.description).toMatch('svg') // eslint-disable-line no-underscore-dangle
})`
checkAxes
Value can be of Number
type
- value means the number of expected axes of D3 chart based on selector
g.axis
it('should have ${checkAxes} axes', async () => {
const axes = await page.$$('g.axis')
const expected = ${checkAxes}
expect(axes).toHaveLength(expected)
})`
checkBars
Value can be of Boolean
type
- value
true
means thatit
test will be generated to check selectorrect.bar
has found elements as many as in arrayfixture.props.data
- value
false
means thatit
test won't be generated
it('should have ${checkBarsValue} bars according to fixture data', async () => {
const bars = await page.$$('rect.bar')
const expected = ${checkBarsValue} // fixture.props.data.length
expect(bars).toHaveLength(expected)
})`
checkArcs
Value can be of Boolean
type
- value
true
means thatit
test will be generated to check selectorpath.arc
has found elements as many as in arrayfixture.props.data
- value
false
means thatit
test won't be generated
it('should have ${checkArcsValue} arcs according to fixture data', async () => {
const arcs = await page.$$('path.arc')
const expected = ${checkArcsValue} // fixture.props.data.length
expect(arcs).toHaveLength(expected)
})`
Custom Declarations
When you run initialization node_modules/.bin/spdt:init
it creates the file test-declarations.js and a directory custom-declarations in the folder .spdt
Use the example testH1 declaration as a guideline to add more custom declarations
General requirements
- The file test-declarations.js should export an object.
- The key of the object is the name of a custom declaration
- The value of the object is a function which takes a fixture and returns a string - generated it test for puppeteer+jest environment
Example
const declarationTestH1 = (fixture) => {
const { testH1 } = (fixture && fixture.spdt) || {}
let selector
let value
if (typeof testH1 === 'string') {
selector = 'h1'
value = testH1
}
if (!selector || !value) {
return null
}
return `
it('testH1: should find component matching selector [${selector}] with value ${value}', async () => {
const components = await page.$$eval('[id=root] ${selector}', elements => elements.map(e => e.innerText))
const expected = '${value}'
expect(components).toContain(expected)
})`
}
module.exports = {
testH1: declarationTestH1,
}
Continuous Integration workflow
- make sure you have installed the module
start-server-and-test
npm i -D start-server-and-test
- check
package.json
icludes these script commands
"spdt:storybook:ci": "start-storybook --ci --quiet -p 9009 -c ./.spdt",
"spdt:ci": "npm run spdt:generate-story-index && npm run spdt:generate-test-index && npm run spdt:generate-tests && npm run spdt:storybook:ci",
"ci": "start-server-and-test spdt:ci 9009 spdt:test"
-
just run
npm run ci
-
module
start-server-and-test
does the magic:- it executes
npm run spdt:ci
- it listens when the port 9009 is available (storybook is up and running)
- it runs the tests
npm run spdt:test
- it stops storybook when tests end
- it executes
New features
Check out helpers
folder with several high order functions
- withParent - simple way to wrap a component with a
div
tag with some css styles - withRouter - it wraps a component with Router from
react-router-dom
- withStoreFromFixture - simple way to provide some values to redux store from the fixture
- withTheme - a wrapper in case you use material-ui
Check out new custom declarations in custom-declarations
folder
- checkInnerText
- checkInnerTexts
- checkValue