TESTING REACT COMPONENTS
Tooling
Sinon.JS
Enzyme
Jest
React
different kinds of
rendering
shallow rendering
- Renders root component and it's native children (like <div>) as deep as necessary
- Does not render subcomponents meaning parent component tests do not depend on children working properly
- Does not run all lifecycle methods (like componentDidMount)
- Much faster than full mount
full dom rendering
- Renders entire tree
- Requires browser or browser-like environment (JSDOM) to have access to DOM API
- Runs all lifecycle methods
- Can be used to test integrations with external libraries that modify DOM outside of React
- Much closer to what's actually in browser than shallow rendering but at the cost of speed and less isolation
static html rendering
- Renders entire tree to static HTML
- Uses Cheerio.js library
- Useful for testing the validity of resulting HTML and not the individual components implementations
- Components that were mounted or shallow rendered can still use this by calling .render() (returns CheerioWrapper)
Golden rules of testing REACT COMPONENTS
- test one thing at a time to avoid coupling your tests and making them easier to break
- use shallow rendering for unit tests of simple components and full mount for integration tests of complex components
- DO. NOT. TEST. WRAPPED. COMPONENTS.
unwrapping
components
method 1
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')
})
})
use .dive() to render inside of HoC
PROS
- does not require any additional work
CONS
- practically everything else
- can be unpredictable and throw arcane errors
- does not respect context (will not work with react router properly)
method 2
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')
})
})
use escape hatches
PROS
- it's there
- library authors recommend this (?)
CONS
- forces to remember about how many HoC were applied to component
- forces to remember about unwrapping everything in tests or it will break
- adding new HoC requires changing all tests
-
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
method 3
// 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))
export pure class from file
// 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')
})
})
PROS
- clean separation between class and it's decorators
- super easy to use
- makes tests predictable
CONS
- slightly more verbose export
- cannot use @decorator for entire class if it creates a HoC (withRouter, inject)
-
no-named-as-default eslint rule - can be avoided with aliasing, e.g.
export { Foo as BaseFoo }
types of tests
snapshot tests
snapshot tests
- requires extra library to work seamlessly with Enzyme (enzyme-to-json)
- slowest of all
- generates JSON and compares it with the saved one on the hard drive
- needs to be extra careful around passed props to avoid huge JSON files (always mock everything)
snapshot tests
- useful when testing a larger block of HTML to make sure it still works correctly
- not very precise so those tests break most often (any change to resulting HTML changes the tests)
- more of a sanity check than an actual test and should be treated as such (instead of as an easy mean of artificially increasing the test coverage)
- useful when doing Red/Green/Refactor cycle as the diff between rendered and saved snapshot shows exactly where the test failed
snapshot tests
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()
})
})
types of tests
rendering tests
rendering tests
- just render the component (either with shallow rendering, full mount or static HTML) and test if things are OK
- mostly focused on mocking props (ones that are not functions) and checking the result
- faster than snapshot tests but require manual comparisons
- more precise than snapshot testing, less brittle and provides better insight into what exactly changed and how
rendering tests
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')
})
})
types of tests
behaviour tests
behavior tests
- simulate platform (browser, native etc.) events and test component behaviors
-
mostly focused on mocking props (ones that are functions) and checking if event handlers are attached properly
- often side effects or calls outside should be mocked/stubbed to avoid test coupling
bEHAVIOR TEST
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
CAVEAT
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 :(
types of tests
instance methods tests
instance method tests
- either render component or instantiate store, form object etc. and then test it's instance methods
- useful for testing message passing (for ex. checking if form object is called properly)
- useful for testing logic in isolation
- most of the time you shouldn't write those tests for React components (only form objects, stores etc.) and use behaviour tests instead
instance method tests
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()
})
})
})
types of tests
integration tests
integration tests
- testing of components that mount other components
- should always do full mount
- These kinds of tests are basically behaviour tests for multiple components at the same time
- No example, couldn't find a way to simplify it for the slides (sorry!)
types of tests
system tests
system tests
- Very similar to integration tests but run in a browser (or other platform like Android/iOS) instead of simulated environment
- Take longest to run so they should be launched from separate pipeline (and only on CI, not for ex. as pre-commit hook)
- Require browser (can be in headless mode) and running web server
- Actually harder to do in Node.js than in Rails due to tooling being less mature...?
system tests
- The biggest problem when running system tests is that you either need to spawn your API server or mock everything and keep those mocks in sync
- It is preferable to run those tests on production build but with test data
- If you're using Apollo Client, it's default behaviour is to return null when server response does not match schema so you can abuse this behaviour to make sure your mocks are up to date (not a perfect solution though)
system tests
Nightmare
types of tests
visual regression tests
visual regression tests
- Like system tests, these are also required to run in browser
- Something between snapshot and system tests
- Opens a browser, takes a screenshot, compares it with previously saved screenshot and throws if they're not identical
- Only useful for stable parts of the application
- They can be VERY frustrating with various environments due to rendering differences (mostly font rendering)
visual regression tests
chromeless + jest-image-snapshot
Like, seriously:
jest
configuration
configuration
- Jest is a testing framework made by Facebook. It's a fork of Jasmine preloaded with useful plugins and with sane default configuration.
- Most of the time to start testing you just have to add jest package to the project and add script to package.json running jest binary.
- It's automatically preconfigured to look for tests both in __tests__ directory and for *.test.js files in entire project (except node_modules, obviously)
configuration
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
how to write tests
jest basics
jest basics
- Jest uses rspec-like syntax for writing tests.
- Three global methods available in every tests: describe(), it() and expect(). It() is also aliased as test() so don't get surprised if you see it in docs.
- You group your tests using describe() block and then use it() blocks to write specific tests using expect() and matchers.
- If it() block returns Promise, Jest waits for it to resolve.
This means we can use async/await for easier async testing.
example
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' })
})
})
example
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()
how to write tests
jest matchers
jest matchers
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)
}
})
jest matchers
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' })
}
})
jest matchers
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()
}
jest matchers
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();
})
jest matchers
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.
});
jest matchers
To match strings according to regular expression,
use toMatch
it('returns correct error message', () => {
expect(form.error).toMatch(/does not exist/)
})
jest matchers
To check if element is part of an array, use toContain
it('does reality check', () => {
expect(edibleFood).toContain('meat')
})
jest matchers
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/)
})
jest matchers - summary
- toBe - immutable objects, strict comparison
- toEqual - value comparison
- toMatchSnapshot
- toBeGreaterThan
- toBeGreaterThanOrEqual
- toBeLessThan
- toBeLessThanOrEqual
- toBeCloseTo - for numbers with fraction part
- toBeNull - checks if value is null
- toBeUndefined - checks if value is undefined
- toBeDefined - checks if value is NOT undefined
- toBeTruthy - if(value)
- toBeFalsy - if(!value)
- toMatch - matches substring (or regex)
- toContain - matches if array contains element
- toThrow - matches if block of code threw an error
too much?
IN MOST OF THE TESTS YOU ARE GOING TO USE ONLY TOBE, TOEQUAL AND TOTHROW ANYWAY
For the rest just keep this tab open:
https://facebook.github.io/jest/docs/en/using-matchers.html
how to write tests
spies, stubs, mocks
spies, stubs, mocks
- For all our stubbing and spying needs we use Sinon.js
- Jest also implements mocks but Sinon has nicer API and more features
spies
- Spy is a method that keeps track of how it was called
- It's the simplest of the three (spy, stub, mock) as it doesn't give you control over return value
- Useful to replace some operations with noops just to make sure they were called (events, method calls that ignore return value etc.)
spies
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)
})
stubs
- Stubs are more complex spies.
- Instead of only tracking when they're called, stubs can also return values, call original function (or wrap it), throw errors and overall be a test-specific replacement for a function.
- They are most useful when disabling parts of the application we do not want to test at the moment (XHR, complex operations, side effects etc) or when we want to make sure a certain path in code is being run (like making sure an if() somewhere gets true/false or when triggering error handling)
stubs
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/)
})
mocks
- Mocks are basically stubs with their own expectations.
- They are optional if you are ok with specifying the behaviour later rather than on initialization.
- They have their own .verify() method that throws if any of the expectations are not met so it doesn't work that well with Jest.
- They are used to wrap entire objects.
mocks
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();
})
wait, what about
test isolation?!
sandbox
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()
})
})
and how do I
mock dependencies
mocking dependencies
- For mocking dependencies you cannot use sinon
- This is the only case where jest.mock() have to come to play
- You can mock either module dependencies or even local files if needed. This is useful for ex. when building React Native app if you want to mock native parts or when you want to stub external functions without introducing complex dependency injection
- You can use jest.requireActual to escape the mocking
EXAMPLE
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
how to write tests
fake timers
fake timers
- Some of the code you are testing may use setTimeout, setInterval or Date.
- To be able to control when those callbacks trigger, sinon gives you ability to use fake timers. They are reimplementations of time-related functions with controllable clock.
- Even if your own code does not use them, some of the libraries are using setTimeout(0) to run callbacks after main function has been processed.
fake timers
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")
})
})
how to write tests
stubbing ajax requests
stubbing ajax requests
xmlhttprequest
xmlhttprequest
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")
})
})
xmlhttprequest
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/)
})
xmlhttprequest
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/)
})
xmlhttprequest
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")
})
})
stubbing ajax requests
axios
AXIOS
- Due to the way axios is written, using Sinon's fake server can be a bit cumbersome.
- We are going to use axios-mock-adapter instead.
- Our previous trick with fake timers only work for some libraries depending on their internal implementation. Axios is not one of those libraries.
- The simplest solution: use delay()
AXIOS
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
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
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, {}]
})
axios
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
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()
axios
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()
stubbing ajax requests
fetch api
fetch api
- We are going to use fetch-mock for mocking fetch API.
- Remember that Node.js does not provide fetch() by default. You need to use isomorphic-fetch or other polyfill.
- The idea is similar to previous examples so I'm not going to go into details. Consult documentation to see the differences in API.
fetch api
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")
})
})
a word of caution
- These were all artificial examples designed to show how you can mock HTTP requests
- In a real life scenario you should not schedule XHR requests inside your components this way and instead push and test them elsewhere (for ex. as separate functions, props passed from the store etc)
- The delay() thing is totally a hack and should only be used if all other methods have failed (sorry for bad examples!)
case studies
video quality picker
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
local state testing
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 :(
calling store methods
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)
more local state testing
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)
})
more than one rendering path
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?
more than one rendering path
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)
})
any
questions?
SOURCES / RESOURCES
- http://sinonjs.org/releases/v4.0.1/
- https://facebook.github.io/jest/docs/en/using-matchers.html
- https://github.com/airbnb/enzyme/tree/master/docs/api
- https://github.com/mobxjs/mobx-react#testing-store-injection
- https://github.com/ReactTraining/react-router/blob/master/packages/react-router/docs/api/withRouter.md
- https://github.com/airbnb/enzyme/pull/368
- https://segment.com/blog/ui-testing-with-nightmare/
- https://segment.com/blog/perceptual-diffing-with-niffy/
- https://codeburst.io/automatic-visual-regression-testing-23cc06471dd
- https://facebook.github.io/jest/docs/en/snapshot-testing.html
- https://github.com/adriantoine/enzyme-to-json
- https://github.com/ctimmerm/axios-mock-adapter
- https://github.com/wheresrhys/fetch-mock
- https://percy.io/
- https://www.npmjs.com/package/delay
Testing React Components
By Michał Matyas
Testing React Components
- 947