jest-redux-snap
v1.0.3
Published
reactive test helpers for redux and jest
Downloads
7
Readme
Jest Redux Snap
Jest Redux Snap solves a core problem with testing Redux apps: keeping your components up to date as you take snapshots of them.
It lets you think of the area of your application that you're writing tests for as an app
object, which you can call app.dispatch()
and app.snap()
on any time you want,
all while staying up to date with your Redux store.
Installation
yarn add --dev jest-redux-snap
Usage
import { createApp } from 'jest-redux-snap'
const store = configureStore()
const app = createApp(store, MyComponent)
app.snap()
app.dispatch({ type: 'FOO' })
app.snap() // snapshot reflects updated state and component tree!
As you can see, it's as simple as creating your app by pairing your store with a component, and then dispatching and snapping at will.
we also have a simple multi-purpose snap
function for when you don't plan to dispatch additional actions:
import { snap } from 'jest-redux-snap'
snap(<MyComponent foo='bar' />)
it can snap anything, not just react components.
Motivation
There are several challenges when it comes to testing Redux-based reactive components, the biggest being: lifecycle methods like componentWillReceiveProps
and shouldComponentUpdate
will
not be called if you don't have a reactively "alive" instance of your app. That means if you go to render it the regular Jest way,
and take a snapshot of it--even after correctly using a <Provider />
to provide a store
, those lifecycle methods won't be called.
For example:
import Provider from 'react-redux'
import renderer from 'react-test-renderer'
import configureStore from './configureStore'
const store = configureStore()
const instance = renderer.create(
<Provider store={store}>
<MyConnectedComponent />
</Provider>
)
const tree = instance.toJSON()
expect(tree).toMatchSnapshot()
store.dispatch({ type: 'FOO' })
const instance = renderer.create( // not all lifecycle methods called!
<Provider store={store}>
<MyConnectedComponent />
</Provider>
)
const tree = instance.toJSON()
expect(tree).toMatchSnapshot()
Only componentDidMount
will be called in both calls to renderer.create()
.componentWillReceiveProps
, shouldComponentUpdate
and methods such as componentWillEnter
from ReactTransitionGroup
will not!
The reason is because the component tree thinks thinks its being rendered for the first time in both cases.
There's a few other similar such issues we solve.
What Jest Redux Snap does is let you get down to business and allow you
to think of your app
as a single reactive unit which you can operate on in the obvious ways (e.g. snap
, dispatch
, getState
, etc).
We like to think Jest Redux Snap lets you treat the components your testing as if they are "alive" and truly reactive. And of course while "snapping" all along the way.
API:
createApp(store, ComponentClass, [props | mapStateToProps])
no props:
const store = configureStore() // we recommend you have a configureStore function just for tests
const app = createApp(store, MyComponent)
with props:
const store = configureStore() // you can setup state by dispatching actions before its returned
const app = createApp(store, MyComponent, { foo: 'bar' })
with mapStateToProps:
const store = configureStore()
const mapStateToProps = state => ({ foo: state.foo })
const app = createApp(store, MyComponent, mapStateToProps)
The idea of mapStateToProps
is simply that you may be taking snapshots of a component deep within your component tree (i.e. not your <App />
root component)
and that nested component may be getting props passed to it which are determined from Redux state. So mapStateToProps
solves that problem.
The props passed to your parent component will stay up to date as you dispatch
actions against the store
.
That said, mapStateToProps
is just a frill feature. The most value you will get from Jest Redux Snap is from how even more deeply nested components stay up to date
with the redux store.
📸 app.snap()
Take a snapshot of the reactive component contained within app
. If you dispatch
any actions on the
store, no more work is required to capture an updated snapshot of the component. Just call app.snap()
again.
Example:
const store = configureStore()
const app = createApp(store, MyComponent)
app.snap()
app.dispatch({ type: 'FOO' })
app.snap()
app.dispatch({ type: 'BAR' })
app.snap()
So clearly this is the where using Jest Redux Snap pays off. Enjoy!
app.dispatch()
Same as store.dispatch(action)
. It is available here so you can think of your component as one atomic unit known as as app
.
app.getState()
Same as store.getState()
app.tree()
Equivalent to the following:
import renderer from 'react-test-renderer'
const instance = renderer.create(<MyComponent />)
const tree = instance.toJSON()
with one important capability: it stays up to date as you dispatch against the app
's associated store.
app.component()
The equivalent of:
import renderer from 'react-test-renderer'
const instance = renderer.create(<MyComponent />)
But, again, of course it reactively alive! Moo ha ha!!!
app.element()
alias: app.story()
If you passed MyComponent
to createApp(store, MyComponent)
, you will be returned from app.element()
:
<MyComponent foo='bar' />
This can be helpful if you want to pass it to renderer.create()
manually or if you ever want to render your JSX in another context.
For example Jest Storybook Facade
allows you to return React elements from your it
tests to display the components used in your tests in React Storybook!!! Hence,
the alias app.story()
!
app.snapState()
The equivalent of:
expect(app.getState()).toMatchSnapshot()
app.snap(action) 🔥
The equivalent of:
expect(action).toMatchSnapshot() // note: it will NOT snapshot thunks
app.dispatch(action)
expect(app.getState()).toMatchSnapshot()
app.snap()
So that's 2 or 3 snapshots it will take, depending on whether you supply a thunk or an action object. This is the BFG 9000 of Jest Redux Snap.
snap(target, [props], [deep = true])
target: Component Class
snap(MyComponent)
snap(MyComponent, { foo: 'bar' })
target: <ReactElement />
snap(<MyComponent foo='bar' />)
target: Component Instance
import renderer from 'react-test-renderer'
const instance = renderer.create(<MyComponent />)
snap(instance)
target: Component JSON Tree
import renderer from 'react-test-renderer'
const instance = renderer.create(<MyComponent />)
const tree = instance.toJSON()
snap(tree)
target: Anything
snap({ foo: 'bar' })
snap(123)
props?: Object
props
can only be second parameter when passing a Component Class as the first argument
deep?: boolean = true
if false
is supplied as the final argument, react-addons-test-utils
will be used to shallowly
render your component instead. For example, if you take the first test in the __tests__/snap.js
folder
and you try passing false
as a third parameter, you will see this difference:
DEEP SNAPSHOT: snap(Target, { className: 'foo' })
<div>
<span>
A TITLE
</span>
<div
className="foo"
id="shallowComponent"
/>
</div>
SHALLOW SNAPSHOT: snap(Target, { className: 'foo' }, false)
<div>
<span>
A TITLE
</span>
<ShallowComponent
className="foo"
/>
</div>
So obviously if you pass false
, the system won't try to traverse imported components, but rather
will just snapshot the props passed to it, in this case: ShallowComponent
.
This is extremely useful for Jest, because it saves you from having to mock child components, which in turn saves you from having to create a separate file to achieve snapshots of both the mocked and unmocked versions of the child component.
In other words, using mocking in Jest, you CANNOT have the same indirectly imported child component/module mocked and unmocked within the same file, since mocks operate statically on an entire-file-basis.
Jest Redux Snap solves that problem, making it "a snap" to capture every "angle" of your components :)
Recommendation (create a shoot()
function):
It could look like this (and in fact this is what we use):
import { createApp, snap, isClass, isStore } from 'jest-redux-snap'
import configureStore from './configureStore'
export default function shoot(Component, props, ...args) {
if (!isClass(Component)) {
return snap(Component)
}
const store = isStore(args[0]) ? args[0] : configureStore(...args)
const app = createApp(store, Component, props)
app.snap()
app.snapState()
return app
}
And here is the various ways you could use it:
no need to provide store:
shoot(MyComponent)
shoot(MyComponent, { foo: 'bar' })
const mapState = state => ({ foo: state.foo })
shoot(MyComponent, mapState)
provide store anyway:
const store = configureStore()
shoot(MyComponent, {}, store)
use it to snap anything because you're too lazy to also import snap
:
shoot({ foo: 'bar' })
shoot(<My Component />)
provide configuration options for configureStore(...args):
const loadUsers = true
const loadOtherEntities = true
shoot(MyComponent, {}, loadUsers, loadOtherEntities)
So the idea with the last example is that your configureStore
function takes arguments that tell it what asyncronous data-loading
actions to perform, and it does it, returning you a fully stocked store! That way you don't have to worry about setting up your store
throughout your tests.
You likely have a few ways you commonly fill the store with data. Use the additional ...args
passed to
configureStore()
to tell it what asyncronous actions to dispatch. Make sure you have the responses mocked of course.