This document explains steps to test React and Redux. When all steps in this document are completed, the entire repository will look like the repository at this point in the history [ github | gitlab ].
As stated in the previous document, Testing GraphQL, this textblog app needs three types of testing: React/Redux, GraphQL and Rails testing. The first pick was GraphQL testing, and the second pick, here, is React/Redux testing.
-
Install React/Redux testing packages
To test React/Redux, a client side testing framework should be installed. At this moment, the combination of
Jest
andEnzyme
is the most popular testing tools for React/Redux.-
Install
jest
,babel-jest
andbabel-preset-es2015
Jest
is a JavaScript testing framework whose syntax is similar to Rspec. WhenJest
package is installed,Babel
's Jest support should be installed as well. Since this textblog app uses the webpacker,Babel
is working behind the scene to compile React to JavaScript.yarn add --dev jest babel-jest babel-preset-env
-
Install
enzyme
,enzyme-adapter-react-16
andenzyme-to-json
Enzyme
is a React component testing framework.Enzyme
has an adapter system to support different versions of React. When installingenzyme
package, its adapter should be added also. The last package,enzyme-to-json
, is used to convert React component output to JSON. If it is in a JSON format, React component testing becomes much easier.yarn add --dev enzyme enzyme-adapter-react-16 enzyme-to-json
-
Install
redux-mock-store
The
redux-mock-store
provides a mock Redux store to test Redux actions.yarn add --dev redux-mock-store
-
-
Setup testing environment
Jest
has its own various settings. If it is a typical React project, default settings would have worked well. However, here,Jest
should run under Rails directory structure. To makeJest
fit in Rails env, it needs a couple of settings.-
Create a directory
The rule is, a directory structure under spec is the same as one under app. ReactJS code resides under
app/javascript/packs
, so createspec/javascript/packs
directory.mkdir -p spec/javascript/packs
-
Tell
Jest
where and howJest
needs to know where test files are located. SinceJest
does exhaustive search for test files,spec/javascript
would be good dir.Following
moduleDirectory
setting is similar one to Rails' requiring files for testing. For example,rails_helper.rb
has the line,Dir[Rails.root.join('spec', 'support', '**', '*.rb')].each { |f| require f }
(commented out by default). JavaScript equivalent is themoduleDirectories
setting.Additionally, really like Rails,
Jest
can run by the commendyarn spec
. Below has the setting of this.Open
package.json
and add below:{ .... .... "jest": { "roots": [ "spec/javascript" ], "moduleDirectories": [ "node_modules", "app/javascript/packs" ] }, "scripts": { "spec": "jest" } }
-
Update
.babelrc
A small update in
.babelrc
should be done so that babel understands syntax of a current ES version. Add"env"
in the top level array. The file will have two "env"s.{ "presets": [ "env", [ "env", {
-
Configure
Enzyme
As mentioned already,
Enzyme
uses the adapter system to support different versions of ReactJS. Because of this,Enzyme
needs an adapter configuration.Create a directory and file. This can be any directory or filename. Here, the directory and filename will be
spec/javascript/setup/enzyme.js
.mkdir -p spec/javascript/setup touch spec/javascript/setup/enzyme.js
import Enzyme from 'enzyme' import Adapter from 'enzyme-adapter-react-16' Enzyme.configure({ adapter: new Adapter() })
In the
"jest"
block ofpackage.json
, addsetupTestFrameworkScriptFile
key and the Enzyme config filename as a value. Additionally,"testPathIgnorePatterns": ["spec/javascript/setup"]
should be added."jest": { "roots": [ "spec/javascript" ], "moduleDirectories": [ "node_modules", "app/javascript/packs" ], "setupTestFrameworkScriptFile": "./spec/javascript/setup/enzyme.js", "testPathIgnorePatterns": ["./spec/javascript/setup"] },
-
-
Refactoring
Before going forward to Redux testing, let's do some refactoring. At this moment, all actions are put together in a single file. It may be ok for now since only two actions are there. However, the number of action will increase as the app becomes more realistic.
The reducer is the same. At this moment, it's ok to branch just two actions in a switch statement. But, as more actions will be added, the switch block grows bigger.
That leads to messy code. The goal here is to separate actions/reducers to individual file.
-
Refactoring actions
Create
app/javascript/packs/actions/update_facebook_login.js
and writeupdateFacebookLogin
action.import { UPDATE_SOCIAL_LOGIN } from '../constants/actions' export const updateFacebookLogin = (response) => { let user = { access_token: response.accessToken, uid: response.userID, name: response.name, email: response.email } return { type: UPDATE_SOCIAL_LOGIN, provider: 'facebook', user } }
Create
app/javascript/packs/actions/update_server_login.js
and writeupdateServerLogin
action.import { UPDATE_SERVER_LOGIN } from '../constants/actions' export const updateServerLogin = (data) => { return { type: UPDATE_SERVER_LOGIN, user: data.signInUser } }
Finally, delete
app/javascript/packs/actions/index.js
-
Update action references in components
Above action refactoring changed action filenames. React components need to update references to actions.
Open
app/javascript/packs/components/FacebookLoginButton.jsx
and change import statement as in below:import { updateFacebookLogin } from '../actions/update_facebook_login'
Open
app/javascript/packs/components/User.jsx
and change import as in below:import { updateServerLogin } from "../actions/update_server_login"
-
Refactoring reducers
Create
app/javascript/packs/reducers/social_login.js
and writesocialLogin
reducer.import { UPDATE_SOCIAL_LOGIN, } from '../constants/actions' const initialState = { provider: '', user: null, } const socialLogin = (state = initialState, action) => { switch (action.type) { case UPDATE_SOCIAL_LOGIN: return { ...state, provider: action.provider, user: action.user } default: return state } } export default socialLogin
Create
app/javascript/packs/reducers/server_login.js
and writeserverLogin
reducer.import { UPDATE_SERVER_LOGIN } from '../constants/actions' const initialState = { user: null, isAuthenticated: false } const serverLogin = (state = initialState, action) => { switch (action.type) { case UPDATE_SERVER_LOGIN: return { ...state, user: action.user, isAuthenticated: action.user && action.user.id ? true : false } default: return state } } export default serverLogin
Update
app/javascript/packs/reducers/index.js
to combine reducers.import { combineReducers } from 'redux' import socialLogin from './social_login' import serverLogin from './server_login' export default combineReducers({ socialLogin, serverLogin })
-
Update reducer references in components
Since two reducers' output states were combined, each state got a key. Above notation in
combineReducers
is a shortcut of JavaScript's map. It means actually:{ socialLogin: socialLogin, serverLogin: serverLogin }
In the components,
state.provier
turns tostate.socialLogin.provider
, andstate.isAuthenticated
turns tostate.serverLogin.isAuthenticated
. Others are the same.Open
app/javascript/packs/components/FacebookLoginButton.jsx
and update the reference to:access_token: state.socialLogin.user ? state.socialLogin.user.access_token : '',
Open
app/javascript/packs/components/User.jsx
and update the reference to:provider: state.socialLogin.provider, access_token: state.socialLogin.user ? state.socialLogin.user.access_token : '', user_id: state.serverLogin.user ? state.serverLogin.user.id : '', name: state.serverLogin.user ? state.serverLogin.user.name : '', isAuthenticated: state.serverLogin.isAuthenticated
Open
app/javascript/packs/components/NavBar.jsx
and update the reference to:isAuthenticated: state.serverLogin.isAuthenticated
-
-
Create Redux action specs
The refactoring made action specs focused. Each spec file contains tests against a single action. (It may be multiple, but all should be the same kind.)
Create a directory and file.
mkdir -p spec/javascript/packs/actions touch spec/javascript/packs/actions/update_facebook_login.spec.js
The action specs looks like this:
import configureStore from 'redux-mock-store' import * as actions from 'actions/update_facebook_login' import * as types from 'constants/actions' const mockStore = configureStore() const store = mockStore() describe('updateFacebookLogin', () => { beforeEach(() => { store.clearActions() }) const response = { accessToken: 'a1b2c3d4e5f6g7h8i9j0', userID: 1234567890, name: 'my name', email: '[email protected]' } const expectedAction = { type: types.UPDATE_SOCIAL_LOGIN, provider: 'facebook', user: { access_token: 'a1b2c3d4e5f6g7h8i9j0', uid: 1234567890, name: 'my name', email: '[email protected]' } } it('should create an action to return user info', () => { expect(actions.updateFacebookLogin(response)).toEqual(expectedAction) }) it('should dispatch action and return expected result', () => { store.dispatch(actions.updateFacebookLogin(response)) expect(store.getActions()).toEqual([expectedAction]) }) })
The spec has two types of tests: directory invokes action function and dispatches an action to see Redux mock store change.
Create
spec/javascript/packs/actions/update_server_login.spec.js
file and add some specs which should be similar to above.The spec runs by the command:
yarn spec
If
--coverage
option is added,Jest
will report the coverage.yarn spec --coverage
-
Create Redux reducer specs
The same as action specs, the refactoring made reducer specs focused. In the same manner, write reducer specs. However, not like actions, the reducer specs can't use the redux-mock-store. This is the limitation of the package.
Create a directory and file.
mkdir -p spec/javascript/packs/reducers touch spec/javascript/packs/reducers/social_login.spec.js
The reducer spec looks like this:
import reducer from 'reducers/social_login' import * as types from 'constants/actions' describe('social_login', () => { const initialState = { provider: '', user: null, } it('should return the initial state', () => { const action = { type: 'DUMMY' } expect(reducer(undefined, action)).toEqual(initialState) }) it('should handle UPDATE_SOCIAL_LOGIN', () => { const action = { type: types.UPDATE_SOCIAL_LOGIN, provider: 'facebook', user: { access_token: 'a1b2c3d4e5f6g7h8i9j0', uid: 1234567890, name: 'my name', email: '[email protected]' } } const expectedState = { provider: 'facebook', user: { access_token: 'a1b2c3d4e5f6g7h8i9j0', uid: 1234567890, name: 'my name', email: '[email protected]' } } expect(reducer(initialState, action)).toEqual(expectedState) }) })
The social_login spec tests dummy and successful actions. For server_login reducer, create
spec/javascript/packs/actions/update_server_login.spec.js
and write specs.Again, spec runs by the command:
yarn spec
-
Create React component specs
When it comes to React testing, many kinds of testings are there. Just a component rendering test, combination of Redux store change and React component, GraphQL response driven component rendering, routing, etc.
UI is the part of application that changes frequently while developing. Too many testings may cause frustration. What to test should be considered. This textblog app starts off by a small set of React component specs. Thinking of the app now, components need to be tested are
FacebookLoginButton
andUser
.-
Create
FacebookLoginButton
component specThe
FacebookLoginButton
component initiates the client side OAuth flow. Then, it updates Redux store and redirects to a home page. This logic should be tested.mkdir -p spec/javascript/packs/components touch spec/javascript/packs/components/FacebookLoginButton.spec.js
The spec
FacebookLoginButton.spec.js
looks like this:import React from 'react' import { mount, shallow } from 'enzyme' import toJson from 'enzyme-to-json' import { Provider } from 'react-redux' import { BrowserRouter as Router, Redirect } from 'react-router-dom' import { createStore } from 'redux' import reducer from 'reducers' import { updateFacebookLogin } from 'actions/update_facebook_login' import FacebookLoginButton from 'components/FacebookLoginButton' describe('<FacebookLoginButton />', () => { describe('without Redux store', () => { let wrapper = shallow(<FacebookLoginButton/>) it('should render a component', () => { expect(toJson(wrapper)).toMatchSnapshot() }) }) describe('with Redux store', () => { let store = createStore(reducer) let response = { accessToken: 'a1b2c3d4e5f6g7h8i9j0', userID: 1234567890, name: 'my name', email: '[email protected]' } it('should have Redirect link after dispatch', () => { store.dispatch(updateFacebookLogin(response)) let wrapper = mount( <Provider store={store}> <Router> <FacebookLoginButton/> </Router> </Provider>) expect(wrapper.find(Redirect)).toHaveLength(1) wrapper.unmount() }) }) })
Above spec has two kinds of tests. The first one tests component output by Jest's
toMatchSnapshot
function. Enzyme'sshallow
function renders a top level component, so it's good to test existence.The second one tests react-router's
Redirect
is rendered after ReduxupdateFacebookLogin
action is dispatched. Enzyme'smount
function renders full DOM including child components. Also, it sees Redux store change as well. After Redux action is dispatched,access_token
property will have a value. It means the page will be redirected to the home. -
Create
User
component specThe
User
component has a scenario to be tested. The component specifically looks atsocialLogin.access_token
andserverLogin.isAuthenticated
state values in Redux store and behaves accordingly.At the beginning, those are undefined and false, so the component doesn't output a name. After the first action,
updateFacebookLogin
, is dispatched (assuming OAuth login was successful), the component getsaccess_token
. However,isAuthenticated
state remains false, so still the component doesn't output the name. When the second action,updateServerLogin
, is dispatched (assuming Rails server returns an expected user information),isAuthenticated
turns true. Finally, the user name shows up.access_token isAuthenticated component behavior undefined false renders <li></li>
valid value false renders <li></li>
valid value true renders <li>NAME</li>
Above should be tested.
touch spec/javascript/packs/components/User.spec.js
The spec,
User.spec.js
looks like below:Note: The code had a bit of refactoring on GraphQL query. The query string was moved to
app/javascript/packs/components/queries.js
User
component imports a query string so thatUser
spec can do the same.import React from 'react' import { mount, shallow } from 'enzyme' import toJson from 'enzyme-to-json' import { MockedProvider } from "react-apollo/test-utils"; import { createStore } from 'redux' import { Provider } from "react-redux"; import reducer from 'reducers' import { updateFacebookLogin } from 'actions/update_facebook_login' import { updateServerLogin } from 'actions/update_server_login' import User from 'components/User' import { signInUserGql } from 'components/queries' describe('<User />', () => { describe('without Redux store', () => { let wrapper = shallow(<User/>) it('should render a component', () => { expect(toJson(wrapper)).toMatchSnapshot() }) }) describe('with Redux store and Apollo', () => { let gql_result = { data: { signInUser: { id: 1, provider: 'facebook', uid: 1234567890, name: 'my name', email: '[email protected]' } } } let mocks = [ { request: { query: signInUserGql, variable: { provider: 'facebook' } }, result: { gql_result } } ] let response = { accessToken: 'a1b2c3d4e5f6g7h8i9j0', userID: 1234567890, name: 'my name', email: '[email protected]' } let store = createStore(reducer) it('should have empty li', () => { let wrapper = mount( <MockedProvider mocks={mocks} > <Provider store={store}> <User /> </Provider> </MockedProvider> ) expect(toJson(wrapper)).toMatchSnapshot() expect(wrapper.find('li').text()).toEqual('') }) it('should have empty li after one dispatch', () => { store.dispatch(updateFacebookLogin(response)) let wrapper = mount( <MockedProvider mocks={mocks} > <Provider store={store}> <User /> </Provider> </MockedProvider> ) expect(wrapper.find('li').text()).toEqual('') }) it('should have user name in li after dispatches', () => { let data = { "signInUser": { "id": "1", "provider": "facebook", "uid": "104149837337889", "name": "my name", "email": "[email protected]" } } store.dispatch(updateFacebookLogin(response)) store.dispatch(updateServerLogin(data)) let wrapper = mount( <MockedProvider mocks={mocks} > <Provider store={store}> <User /> </Provider> </MockedProvider> ) expect(wrapper.find('li').text()).toEqual('my name') }) }) })
Above spec uses Apollo's
MockProvider
to avoid sending GraphQL request to the server during the test. The first spec tests the existence of component. The second test makes GraphQL mock query, but, that's it. So the name won't show up. The third test dispatches the first action, and that's it. So the name won't show up. The fourth test dispatches the second action. Now, Reux store gets another user info which is enough to turnisAuthenticated
to true. Finally, the name should show up. -
Run specs
Run the specs and see the result.
yarn spec # all specs run yarn spec spec/javascript/packs/components # all specs under components dir run yarn spec spec/javascript/packs/components/User.spec.js # only User.spec.js runs
-
For now, the textblog app got Redux and React component specs. Next topic is about Adding Error Handling