Sinon.JS
Enzyme
Jest
React
import React from 'react'
import { shallow } from 'enzyme'
import { inject } from 'mobx-react'
const Button = inject('store')(({ store }) => {
return <button>{store.label}</button>
})
describe('Button', () => {
it('renders with label', () => {
// notice the .dive() here
const component = shallow(<Button store={{ label: 'foo' }}/>).dive()
expect(component.find('button').text()).toEqual('foo')
})
})
import React from 'react'
import { shallow } from 'enzyme'
import { inject } from 'mobx-react'
import { withRouter } from 'react-router-dom'
const Button = inject('store')(withRouter(({ store }) => {
return <button>{store.label}</button>
}))
describe('Button', () => {
it('renders with label', () => {
// You can already see the problem...
const TestedButton = Button.wrappedComponent.WrappedComponent
const component = shallow(<TestedButton store={{ label: 'foo' }}/>)
expect(component.find('button').text()).toEqual('foo')
})
})
need to remember how escape hatch is implemented (wrappedComponent for mobx-react but WrappedComponent for react-router etc.) and in which order they were applied
// components/button.js
import React from 'react'
import { inject } from 'mobx-react'
import { withRouter } from 'react-router-dom'
const Button = ({ store }) => {
return <button>{store.label}</button>
}
export { Button }
export default inject('store')(withRouter(Button))
// components/button.test.js
import React from 'react'
import { shallow } from 'enzyme'
import { Button } from 'components/button'
describe('Button', () => {
it('renders with label', () => {
const component = shallow(<Button store={{ label: 'foo' }}/>)
expect(component.find('button').text()).toEqual('foo')
})
})
import React from 'react'
import { shallow } from 'enzyme'
import snapshot from 'enzyme-to-json'
const Button = ({ store }) => {
return <button>{store.label}</button>
}
describe('Button', () => {
it('matches the snapshot', () => {
const component = shallow(<Button store={{ label: 'foo' }}/>)
// this line is important
expect(snapshot(component)).toMatchSnapshot()
})
})
import React from 'react'
import { shallow } from 'enzyme'
import toJson from 'enzyme-to-json'
const Button = ({ store }) => {
return <button>{store.label}</button>
}
describe('Button', () => {
it('renders label', () => {
const component = shallow(<Button store={{ label: 'foo' }}/>)
expect(component.find('button').text()).toEqual('foo')
})
})
class EmptyForm extends React.Component {
componentWillMount() {
this.form = new Form()
}
onSubmit(e) {
this.form.onSuccess(e)
}
render() {
return (
<form onSubmit={this.onSubmit.bind(this)}>
<button type="submit">Submit</button>
</form>
)
}
}
const sandbox = sinon.createSandbox()
describe('EmptyForm', () => {
afterAll(() => sandbox.restore())
it('triggers form object onSuccess on submit', () => {
const component = shallow(<EmptyForm/>)
const instance = component.instance()
const stub = sandbox.stub(instance.form, 'onSuccess')
.callsFake(() => true)
component.find('form').simulate('submit')
expect(stub.calledOnce).toBe(true)
})
})
enzyme does not support event bubbling and does not truly fake browser events, it simply runs the event handler from props
component.simulate('event', { extraData })
// is the same as
component.prop('onEvent')({ extraData })
// so these are equivalent:
component.simulate('click')
component.prop('onClick')()
// if you need to test actual event bubbling, you
// need to fake it yourself :(
class TotalPrice extends React.Component {
calculateTotal() {
return this.props.items.reduce(
(sum, item) => sum + (item.price || 0)
, 0)
}
render() {
return (
<div>
Total price: {this.calculateTotal()}
</div>
)
}
}
describe('TotalPrice', () => {
describe('calculateTotal', () => {
it('sums all items', () => {
const items = [ { price: 10 }, { price: 15 } ]
const component = shallow(<TotalPrice items={items}/>)
const instance = component.instance()
expect(instance.calculateTotal()).toEqual(25)
})
it('works with empty list', () => {
const component = shallow(<TotalPrice items={[]}/>)
const instance = component.instance()
expect(() => {
expect(instance.calculateTotal()).toEqual(0)
}).not.toThrow()
})
it('works with items with no price', () => {
const component = shallow(<TotalPrice items={[{}, {}]}/>)
const instance = component.instance()
expect(() => {
expect(instance.calculateTotal()).toEqual(0)
}).not.toThrow()
})
})
})
Nightmare
chromeless + jest-image-snapshot
Like, seriously:
Sometimes you need to set some things up before testing (like Enzyme adapter). You can create a test.config.js file and put this code there.
{
"name": "react-testing-workshop",
"version": "0.1.0",
// ...
"jest": {
"setupFilesAfterEnv":
"<rootDir>/test.config.js"
}
}
import { configure } from 'enzyme';
import Adapter from 'enzyme-adapter-react-15';
configure({ adapter: new Adapter() });
add this to your package.json
now you can configure enzyme adapter
If you're using create-react-app, the file already exists as setupTests.js
describe('User', () => {
it('returns username', () => {
const user = new User('username')
expect(user.username).toBe('username')
})
// Asynchronous tests are easy with async/await
it('fetches user', async () => {
const user = await User.fetch()
expect(user).toEqual({ username: 'Foo' })
})
})
describe('refreshAccessToken', () => {
it('requests access token via JSON request to API', () => {
expect.assertions(4)
sandbox.stub(global, 'fetch').callsFake(async (url, args) => {
expect(url).toBe(oauthTokenPath())
expect(args.method).toEqual('POST')
expect(args.headers).toMatchObject({ 'Content-Type': 'application/json' })
expect(JSON.parse(args.body)).toEqual({
refresh_token: 'REFRESH_ME',
grant_type: 'refresh_token'
})
return successResponse
})
return refreshAccessToken('REFRESH_ME')
})
})
If you're testing complex things that you don't have complete control over but you still want to make sure all expectations are ran and accounted for, you can use expect.assertions()
When you require strict comparison, use toBe
Examples: true/false, numbers, strings
describe('calculator', () => {
it('adds numbers', () => {
expect(calculator.add(2, 2)).toBe(4)
// there is also .not
expect(calculator.add(2, 2)).not.toBe(5)
}
})
When you require value comparison, use toEqual
Examples: arrays, objects (tested recursively)
describe('sign in', () => {
it('returns user', () => {
expect(signIn('user', 'pass')).toEqual({ username: 'user' })
expect(signIn('user', 'pass')).not.toEqual({ username: '1' })
}
})
To compare a value (serializable object) with pre-existing snapshot,
use toMatchSnapshot. You'll often need an extra lib (like enzyme-to-json)
it('matches snapshot', () => {
const component = shallow(<Foo/>)
expect(toJson(component)).toMatchSnapshot()
}
There are also matchers for null, undefined, defined (!== undefined)
and truthy/falsy values (treated by if() as true or false)
it('showcases all matchers :P', () => {
const n = null;
expect(n).toBeNull();
expect(n).toBeDefined();
expect(n).not.toBeUndefined();
expect(n).not.toBeTruthy();
expect(n).toBeFalsy();
})
There are matchers for numbers. The most useful is toBeCloseTo because it works around issues with floating point precision.
it('two plus two', () => {
const value = 2 + 2;
expect(value).toBeGreaterThan(3);
expect(value).toBeGreaterThanOrEqual(3.5);
expect(value).toBeLessThan(5);
expect(value).toBeLessThanOrEqual(4.5);
// toBe and toEqual are equivalent for numbers
expect(value).toBe(4);
expect(value).toEqual(4);
});
it('adding floating point numbers', () => {
const value = 0.1 + 0.2;
expect(value).not.toBe(0.3); // It isn't! Because rounding error
expect(value).toBeCloseTo(0.3); // This works.
});
To match strings according to regular expression,
use toMatch
it('returns correct error message', () => {
expect(form.error).toMatch(/does not exist/)
})
To check if element is part of an array, use toContain
it('does reality check', () => {
expect(edibleFood).toContain('meat')
})
Finally, to check if a block of code does (or doesn't) throw an exception, use toThrow
it('crashes with invalid data', () => {
expect(() => {
signIn()
}).toThrow()
})
it('crashes with specific error', () => {
expect(() => {
signIn()
}).toThrow(/specific error/)
})
For the rest just keep this tab open:
https://facebook.github.io/jest/docs/en/using-matchers.html
it('redirects to root path after saving', () => {
// mocking minimal react-router history API
const history = { push: sinon.spy() }
const component = shallow(<Foo history={history}/>)
const instance = component.instance()
instance.onSave()
expect(history.push.calledWith('/')).toBe(true)
})
it('throws if user does not exist', () => {
const userStore = new UserStore(1)
sinon.stub(userStore, 'fetchUser')
.callsFake(() => null)
expect(() => {
userStore.getUserProfile()
}).toThrow(/does not exist/)
})
it('fetches user from server', () => {
const mock = sinon.mock(Api);
mock.expects("fetch").once().withArgs(1);
const store = new UserStore
store.getUser(1)
mock.verify();
})
Sinon implements a Sandbox mechanism which allows you to
easily restore the state in global objects between tests.
const sandbox = sinon.createSandbox()
describe('User', () => {
afterEach(() => sandbox.restore())
it('does something', () => {
// use sandbox.foo instead of sinon.foo
sandbox.spy()
sandbox.stub()
sandbox.mock()
})
})
jest.mock('react-native-web', () => ({
...jest.requireActual('react-native-web'),
InteractionManager: {
runAfterInteractions: cb => cb()
}
}))
// now we don't have to worry in our tests about
// API calls being scheduled asynchronously as a
// workaround for performance issues on Android
class Timer extends React.Component {
constructor(props) {
super(props)
this.state = { value: 0 }
this.tick = this.tick.bind(this)
}
componentWillMount() {
this.timer = setInterval(this.tick, 1000)
}
tick() {
this.setState({ value: this.state.value + 1 })
}
comopnentWillUnmount() {
removeInterval(this.timer)
}
render() {
return (
<span className="value">
{this.state.value}
</span>
)
}
}
let clock
describe('Counter', () => {
beforeEach(() =>
clock = sinon.useFakeTimers()
)
afterEach(() => clock.restore())
it('updates after time has passed', () => {
const component = shallow(<Timer/>)
// Advance clock 1000 ms
clock.tick(1000)
component.update()
const value = component.find('.value').text()
expect(value).toMatch("1")
})
})
The most universal way if you're using XMLHttpRequest (either directly or via library like $.ajax) is to use Sinon's fake XHR and server
class UserProfile extends React.Component {
constructor(props) {
super(props);
this.state = {}
}
componentWillMount() {
$.ajax('/api/users/' + this.props.id)
.then((user) => this.setState({ user }))
}
render() {
const { user } = this.state
if(user) {
return <span>User name: {user.name}</span>
} else {
return <span>Loading...</span>
}
}
}
const sandbox = sinon.createSandbox()
let fakeXHR, requests
describe('UserProfile', () => {
beforeEach(() => {
fakeXHR = sinon.useFakeXMLHttpRequest()
requests = []
fakeXHR.onCreate = req => requests.push(req)
})
afterEach(() => {
fakeXHR.restore()
requests.length = 0
})
it('fetches user data', () => {
const component = shallow(<UserProfile id="1"/>)
expect(requests.length).toBe(1)
expect(requests[0].url).toEqual("/api/users/1")
})
})
CAREFUL: you may be inclined to write a test like this. It will not work, because jQuery resolve promises/deferreds asynchronously.
it('shows user name', () => {
const component = shallow(<UserProfile id="1"/>)
requests[0].respond(200,
{'Content-type': 'application/json'},
JSON.stringify({ name: 'Hello' })
)
component.update()
// THIS WILL FAIL, the callback did not run yet
// and the state is not yet set
expect(component.text()).toMatch(/Hello/)
})
You can make sure all of the callbacks have run by using fake timers.
This is not an ideal solution as it relies on an implementation detail.
it('shows user name', () => {
const component = shallow(<UserProfile id="1"/>)
requests[0].respond(200,
{"Content-type": "application/json"},
JSON.stringify({ name: "Hello" })
)
// run all setTimeout(0) callbacks
clock.runAll()
component.update()
expect(component.text()).toMatch(/Hello/)
})
Expecting requests to always come in the same order may lead to brittle tests. Sinon provides fake XHR server to keep track of requests.
let server, clock
describe('SignUpPage', () => {
beforeAll(() => {
server = sinon.createFakeServer()
clock = sinon.useFakeTimers()
})
afterAll(() => {
server.restore()
clock.restore()
})
it('fetches user data', async () => {
server.respondWith("GET", "/api/users/1", [
200, { "Content-Type": "application/json" }, JSON.stringify({ name: "Hello" })
])
const component = shallow(<UserProfile id="1"/>)
server.respond()
clock.runAll()
component.update()
expect(component.text()).toMatch("Hello")
})
})
To wait in your test, use delay library. It returns a Promise that resolves itself after N ms. Be careful: Sinon fake timers break this.
class UserProfile extends React.Component {
constructor(props) {
super(props)
this.state = {}
}
componentWillMount() {
axios.get('/api/users/' + this.props.id)
.then(({ data }) =>
this.setState({ user: data })
)
}
render() {
const { user } = this.state
if(user) {
return <span>User name: {user.name}</span>
} else {
return <span>Loading...</span>
}
}
}
let mock
describe('UserProfile', () => {
beforeEach(() => mock = new MockAdapter(axios))
afterEach(() => mock.restore())
it('fetches user data', async () => {
mock.onGet("/api/users/1").reply(200,
{ name: "Hello" }
)
const component = shallow(<UserProfile id="1"/>)
await delay(0)
component.update()
expect(component.text()).toMatch("Hello")
})
})
Why delay(0) ? Because of how event loop in node works, see: https://nodejs.org/en/docs/guides/event-loop-timers-and-nexttick/
axios-mock-adapter supports multiple ways of specifying
on what exactly to react
// onGet, onPost, onPatch etc.
mock.onGet('/url')
mock.onGet('/url', { params })
mock.onGet(/regexp/)
mock.onGet() // matches all GET requests
mock.onAny() // all types of requests
axios-mock-adapter supports multiple ways of specifying
on how exactly to react
mock.onGet('/url').reply(200) // HTTP 200, no response
mock.onGet('/url').reply(200, {}) // JSON
// replyOnce unpins mock after first matching request
mock.onGet('/url').replyOnce(200, "abc")
mock.onGet('/url').reply((config) => {
// entire request is available
// e.g. config.url, config.method
return [200, {}]
})
Keep in mind that the order of mocks matter (they're evaluated from oldest to newest). Axios-mock-adapter also supports method chaining.
mock.onGet('/url').reply(200)
.onPost('/url').reply(200, {})
// last onAny can be used as catch-all
mock.onAny().reply((config) => {
throw "Unmocked request to ${config.url}!!!"
})
axios-mock-adapter also allows us to
fake errors, timeouts and network errors
mock.onGet('/url').reply(404) // HTTP 404
mock.onGet('/url').reply(403, {error: "Nope"})
// low-level network error
mock.onGet('/url').networkError()
// timeout (ECONNABORTED)
mock.onGet('/url').timeout()
By default, axios-mock-adapter returns 404 for non-mocked requests.
If you want to actually let a request go through, use passThrough
mock.onGet(/google\.com/).passThrough()
// TRICK: remember about "last onAny"?
// if you want non-mocked requests to
// go through instead of fail with 404:
mock.onAny().passThrough()
As you can see the general idea and feature set are
very similar to previous libraries.
class UserProfile extends React.Component {
constructor(props) {
super(props)
this.state = {}
}
componentWillMount() {
fetch('/api/users/' + this.props.id)
.then(async (response) => {
const user = await response.json()
this.setState({ user })
})
}
render() {
const { user } = this.state
if(user) {
return <span>User name: {user.name}</span>
} else {
return <span>Loading...</span>
}
}
}
describe('SignUpPage', () => {
afterEach(() => fetchMock.restore())
it('fetches user data', async () => {
fetchMock.get("/api/users/1", { name: "Hello" })
const component = shallow(<UserProfile id="1"/>)
await delay(10)
component.update()
expect(component.text()).toMatch("Hello")
})
})
import React, { Component } from 'react'
import { observable, action } from 'mobx'
import { observer, inject } from 'mobx-react'
import classNames from 'classnames/bind'
import Icon from 'fa/icon'
import styles from './index.scss'
const cx = classNames.bind(styles)
@observer
class Quality extends Component {
@observable visible = false
@action onToggle() {
this.visible = !this.visible
}
@action setStream(stream) {
this.props.store.setCurrentStream(stream)
this.visible = false
}
render() {
const { store } = this.props
const className = cx({
'quality-picker__dropdown': true,
'quality-picker__dropdown--visible': this.visible
})
if(!store.isReady) {
return (
<div className={cx('button')}><Icon name="cog"/></div>
)
}
return (
<div>
<div className={cx('button', 'quality-picker')} onClick={this.onToggle.bind(this)}>
{store.currentStream && store.currentStream.shortLabel}
</div>
<div>
<div className={className}>
{store.video.getVideoStreams().map((stream) => (
<div className={cx('quality-picker__item')}
onClick={this.setStream.bind(this, stream)}
key={stream.format}>
{stream.label}
</div>
))}
</div>
</div>
</div>
)
}
}
export { Quality }
export default inject('store')(Quality)
import React, { Component } from 'react'
import { observable, action } from 'mobx'
import { observer, inject } from 'mobx-react'
import classNames from 'classnames/bind'
import Icon from 'fa/icon'
import styles from './index.scss'
const cx = classNames.bind(styles)
@observer
class Quality extends Component {
@observable visible = false
@action onToggle() {
this.visible = !this.visible
}
@action setStream(stream) {
this.props.store.setCurrentStream(stream)
this.visible = false
}
render() {
const { store } = this.props
const className = cx({
'quality-picker__dropdown': true,
'quality-picker__dropdown--visible': this.visible
})
if(!store.isReady) {
return (
<div className={cx('button')}><Icon name="cog"/></div>
)
}
return (
<div>
<div className={cx('button', 'quality-picker')} onClick={this.onToggle.bind(this)}>
{store.currentStream && store.currentStream.shortLabel}
</div>
<div>
<div className={className}>
{store.video.getVideoStreams().map((stream) => (
<div className={cx('quality-picker__item')}
onClick={this.setStream.bind(this, stream)}
key={stream.format}>
{stream.label}
</div>
))}
</div>
</div>
</div>
)
}
}
export { Quality }
export default inject('store')(Quality)
local state
calling store methods
different rendering paths based on store state
behaviour (user action), localized
behaviour (user action), from store
more local state
When testing component that has local state, we should not check the implementation but rather how it behaves (behaviour test)
it('shows dropdown on click', () => {
const store = {
isReady: true,
video: { getVideoStreams: ()=>[] }
}
const component = shallow(<Quality store={store}/>)
component.find('div.button').prop('onClick')()
component.update()
expect(component.find('.quality-picker__dropdown--visible').length).toBe(1)
})
This test will not work with react-test-renderer 16.0.0 due to https://github.com/facebook/react/issues/11236
Notice how the store's mock gets more complicated.
You may feel inclined to just pass the actual store here. Don't.
Unfortunately enzyme 3.x is a bit buggy and this is an example. This line should not be necessary but it is :(
There's a contract between store and component.
It needs to be tested against.
it('sets current stream in store', () => {
const mp4 = { format: 'mp4', label: 'mp4' }
const store = {
isReady: true,
setCurrentStream: sinon.spy(),
video: { getVideoStreams: () => [mp4] }
}
const component = shallow(<Quality store={store}/>)
component.find('div.button').prop('onClick')()
component.update()
component.find('.quality-picker__item').prop('onClick')()
expect(store.setCurrentStream.calledOnce).toBe(true)
expect(store.setCurrentStream.calledWith(mp4)).toBe(true)
})
Same object
(same identity)
The clicking behaviour has one more side effect.
You didn't forget about it, did you?
it('hides dropdown after setting stream', () => {
const mp4 = { format: 'mp4', label: 'mp4' }
const store = {
isReady: true,
setCurrentStream: ()=>{},
video: { getVideoStreams: () => [mp4] }
}
const component = shallow(<Quality store={store}/>)
component.find('div.button').prop('onClick')()
component.update()
expect(component.instance().visible).toBe(true)
component.find('.quality-picker__item').prop('onClick')()
component.update()
expect(component.instance().visible).toBe(false)
})
If there's more than one way your component may end up rendering, you should test all alternatives.
it('shows loading spinner when loading', () => {
const store = {
isReady: false
}
const component = shallow(<Quality store={store}/>)
expect(component.find('Icon[name="cog"]').length).toBe(1)
})
It would make so much sense to have dedicated <Loading/> component here, wouldn't it?
ALL alternatives.
This could've also been a snapshot test.
it('lists streams from store', async () => {
const mp4 = { format: 'mp4', label: 'mp4' }
const ogg = { format: 'ogg', label: 'ogg' }
const store = {
isReady: true,
video: { getVideoStreams: () => [ mp4, ogg ] }
}
const component = shallow(<Quality store={store}/>)
expect(component.find('.quality-picker__item').length).toBe(2)
})