Testing React-Redux Applications with Jest
Avi Sharvit
QE CAMP
The Foreman is moving to React
Why React?
- It is simple
- It is popular
- It is composable
WE CAN REPLACE SMALL UI PARTS AND GORW OVER TIME
GROW OVER TIME
login.js
/* login.js */
import authenticateUserPassword from './authenticateUserPassword';
import getUser from './getUser';
const login = (username, password) => {
const user = getUser(username);
if (!authenticateUserPassword(user, password)) {
throw new Error('Wrong password');
}
return user;
};
export default login;
login.test.js
/* login.test.js */
import login from './login';
import authenticateUserPassword from './authenticateUserPassword';
import getUser from './getUser';
jest.mock('./authenticateUserPassword');
jest.mock('./getUser');
In unit-testing, we mock every single imported module
login.test.js
describe('Auth - login', () => {
it('should login with username and password', () => {
});
it('should throw error when login with wrong password', () => {
});
});
login.test.js
describe('Auth - login', () => {
const username = 'some-username';
const user = 'some-user';
const password = 'some-password';
getUser.mockImplementation(() => user);
it('should login with username and password', () => {
});
it('should throw error when login with wrong password', () => {
});
});
login.test.js
it('should login with username and password', () => {
authenticateUserPassword.mockImplementation(() => true);
const results = login(username, password);
expect(results).toEqual(user);
});
login.test.js
it('should throw error when login with wrong password', () => {
authenticateUserPassword.mockImplementation(() => false);
const doLogin = () => login(username, password);
expect(doLogin).toThrow(new Error('Wrong password'));
});
login.test.js
/* login.test.js */
import login from './login';
import authenticateUserPassword from './authenticateUserPassword';
import getUser from './getUser';
jest.mock('./authenticateUserPassword');
jest.mock('./getUser');
describe('Auth - login', () => {
const username = 'some-username';
const user = 'some-user';
const password = 'some-password';
getUser.mockImplementation(() => user);
it('should login with username and password', () => {
authenticateUserPassword.mockImplementation(() => true);
const results = login(username, password);
expect(results).toEqual(user);
});
it('should throw error when login with wrong password', () => {
authenticateUserPassword.mockImplementation(() => false);
const doLogin = () => login(username, password);
expect(doLogin).toThrow(new Error('Wrong password'));
});
});
/* UserProfile.js */
import React from 'react';
import UserAvatar from './components/UserAvatar';
import UserDetailsBox from './components/UserDetailsBox';
import UserPhotos from './components/UserPhotos';
import UserPosts from './components/UserPosts';
const UserProfile = ({ user, showAvatar, showPosts, showPhotos }) => (
<div className="user-profile">
<UserDetailsBox user={user} />
{showAvatar && <UserAvatar user={user} size="sm" />}
{showPosts && <UserPhotos user={user} count={5} sort="DESC" />}
{showPhotos && <UserPosts user={user} count={5} sort="DESC" />}
</div>
);
export default UserProfile;
UserProfile.js
/* UserProfile.test.js */
import React from 'react';
import toJson from 'enzyme-to-json';
import { mount } from 'enzyme';
import UserProfile from './UserProfile';
describe('UserProfile - component', () => {
it('should render UserProfile', () => {
const component = mount(<UserProfile user="some-user" />);
expect(toJson(component)).toMatchSnapshot();
});
});
UserProfile.test.js
<UserProfile
user="some-user"
>
<div
className="user-profile"
>
<UserDetailsBox
user="some-user"
>
<div
className="user-details-box"
>
Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum.
</div>
</UserDetailsBox>
</div>
</UserProfile>
The Snapshot
/* UserProfile.test.js */
import React from 'react';
import toJson from 'enzyme-to-json';
import { shallow } from 'enzyme';
import UserProfile from './UserProfile';
describe('UserProfile - component', () => {
it('should render UserProfile', () => {
const component = shallow(<UserProfile user="some-user" />);
expect(toJson(component)).toMatchSnapshot();
});
});
UserProfile.test.js
<div
className="user-profile"
>
<UserDetailsBox
user="some-user"
/>
</div>
The Snapshot
Why snapshots?
- Jest manage them for me
- Easy to understand them
- Easy to review them
It is much easier and faster to write tests that use snapshots!
/* UserProfile.test.js */
import React from 'react';
import toJson from 'enzyme-to-json';
import { shallow } from 'enzyme';
import UserProfile from './UserProfile';
describe('UserProfile - component', () => {
it('should render UserProfile', () => {
const component = shallow(<UserProfile user="some-user" />);
expect(toJson(component)).toMatchSnapshot();
});
it('should render UserProfile with avatar', () => {
const component = shallow(<UserProfile user="some-user" showAvatar />);
expect(toJson(component)).toMatchSnapshot();
});
it('should render UserProfile with posts and photos', () => {
const component = shallow(
<UserProfile user="some-user" showPosts showPhotos />
);
expect(toJson(component)).toMatchSnapshot();
});
});
UserProfile.test.js
Too much of the same code
Data Driven Testing
/* UserProfile.test.js */
import { testComponentSnapshotsWithFixtures } from 'react-redux-test-utils';
import UserProfile from './UserProfile';
const fixtures = {
'should render UserProfile': {
user: 'some-user',
},
'should render UserProfile with avatar': {
user: 'some-user',
showAvatar: true,
},
'should render UserProfile with posts and photos': {
user: 'some-user',
showPosts: true,
showPhotos: true,
},
};
describe('UserProfile - component', () =>
testComponentSnapshotsWithFixtures(UserProfile, fixtures));
UserProfile.test.js
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`UserProfile - component should render UserProfile 1`] = `
<div
className="user-profile"
>
<UserDetailsBox
user="some-user"
/>
</div>
`;
exports[`UserProfile - component should render UserProfile with avatar 1`] = `
<div
className="user-profile"
>
<UserDetailsBox
user="some-user"
/>
<UserAvatar
size="sm"
user="some-user"
/>
</div>
`;
exports[`UserProfile - component should render UserProfile with posts and photos 1`] = `
<div
className="user-profile"
>
<UserDetailsBox
user="some-user"
/>
<UserPhotos
count={5}
sort="DESC"
user="some-user"
/>
<UserPosts
count={5}
sort="DESC"
user="some-user"
/>
</div>
`;
UserProfile.test.js
Questions?
Testing Redux
Redux Lifecycle
const UserProfile = ({
user,
showAvatar,
showPosts,
showPhotos,
updateShowAvatar,
updateShowPhotos,
updateShowPosts,
}) => (
<div className="user-profile">
<UserDetailsBox user={user} />
{showAvatar && <UserAvatar user={user} size="sm" />}
{showPosts && <UserPhotos user={user} count={5} sort="DESC" />}
{showPhotos && <UserPosts user={user} count={5} sort="DESC" />}
<input
id="show-avatar-toggler"
type="checkbox"
checked={showAvatar}
onChange={e => updateShowAvatar(e.target.checked)}
/>
<input
id="show-photos-toggler"
type="checkbox"
checked={showPhotos}
onChange={e => updateShowPhotos(e.target.checked)}
/>
<input
id="show-posts-toggler"
type="checkbox"
checked={showPosts}
onChange={e => updateShowPosts(e.target.checked)}
/>
</div>
);
UserProfile.js
const UserProfile = (props) => { ... };
// map state to props
const mapStateToProps = state => ({
showAvatar: selectShowAvatar(state),
showPosts: selectShowPosts(state),
showPhotos: selectShowPhotos(state),
});
// export default UserProfile;
export default connect(mapStateToProps)(UserProfile);
UserProfile.js with Redux
components/UserProfile
├── index.js
├── UserProfileActions.js
├── UserProfileConstants.js
├── UserProfile.js
├── UserProfileReducer.js
├── UserProfileSelectors.js
└── __tests__
├── integration.test.js
├── UserProfileActions.test.js
├── UserProfileReducer.test.js
├── UserProfileSelectors.test.js
├── UserProfile.test.js
└── __snapshots__
├── integration.test.js.snap
├── UserProfileActions.test.js.snap
├── UserProfileReducer.test.js.snap
├── UserProfileSelectors.test.js.snap
└── UserProfile.test.js.snap
Managing our folders structure
/* UserProfileActions.js */
import {
USER_PROFILE_UPDATE_AVATAR,
USER_PROFILE_UPDATE_POSTS,
USER_PROFILE_UPDATE_PHOTOS,
} from './UserProfileConstants';
export const updateShowAvatar = showAvatar => ({
type: USER_PROFILE_UPDATE_AVATAR,
payload: showAvatar,
});
export const updateShowPosts = showPosts => ({
type: USER_PROFILE_UPDATE_POSTS,
payload: showPosts,
});
export const updateShowPhotos = showPhotos => ({
type: USER_PROFILE_UPDATE_PHOTOS,
payload: showPhotos,
});
UserProfileActions.js
/* UserProfileActions.test.js */
import { testActionSnapshotWithFixtures } from 'react-redux-test-utils';
import {
updateShowAvatar,
updateShowPosts,
updateShowPhotos,
} from '../UserProfileActions';
const fixtures = {
'should update-show-avatar': () => updateShowAvatar(true),
'should update-show-posts': () => updateShowPosts(true),
'should update-show-photos': () => updateShowPhotos(true),
};
describe('UserProfile - Actions', () =>
testActionSnapshotWithFixtures(fixtures));
UserProfileActions.test.js
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`UserProfile - Actions should update-show-avatar 1`] = `
Object {
"payload": true,
"type": "USER_PROFILE_UPDATE_AVATAR",
}
`;
exports[`UserProfile - Actions should update-show-photos 1`] = `
Object {
"payload": true,
"type": "USER_PROFILE_UPDATE_PHOTOS",
}
`;
exports[`UserProfile - Actions should update-show-posts 1`] = `
Object {
"payload": true,
"type": "USER_PROFILE_UPDATE_POSTS",
}
`;
The Snapshot
const initialState = Immutable({
showAvatar: false,
showPosts: false,
showPhotos: false,
});
export default (state = initialState, action) => {
const { payload } = action;
switch (action.type) {
case USER_PROFILE_UPDATE_AVATAR:
return state.set('showAvatar', payload);
case USER_PROFILE_UPDATE_POSTS:
return state.set('showPosts', payload);
case USER_PROFILE_UPDATE_PHOTOS:
return state.set('showPhotos', payload);
default:
return state;
}
};
UserProfileReducer.js
const fixtures = {
'should return the initial state': {},
'should handle USER_PROFILE_UPDATE_AVATAR': {
action: {
type: USER_PROFILE_UPDATE_AVATAR,
payload: true,
},
},
'should handle USER_PROFILE_UPDATE_POSTS': {
action: {
type: USER_PROFILE_UPDATE_POSTS,
payload: true,
},
},
'should handle USER_PROFILE_UPDATE_PHOTOS': {
action: {
type: USER_PROFILE_UPDATE_PHOTOS,
payload: true,
},
},
};
describe('UserProfile - Reducer', () =>
testReducerSnapshotWithFixtures(reducer, fixtures));
UserProfileReducer.test.js
exports[`UserProfile - Reducer should return the initial state 1`] = `
Object {
"showAvatar": false,
"showPhotos": false,
"showPosts": false,
}
`;
exports[`UserProfile - Reducer should handle USER_PROFILE_UPDATE_AVATAR 1`] = `
Object {
"showAvatar": true,
"showPhotos": false,
"showPosts": false,
}
`;
exports[`UserProfile - Reducer should handle USER_PROFILE_UPDATE_PHOTOS 1`] = `
Object {
"showAvatar": false,
"showPhotos": true,
"showPosts": false,
}
`;
exports[`UserProfile - Reducer should handle USER_PROFILE_UPDATE_POSTS 1`] = `
Object {
"showAvatar": false,
"showPhotos": false,
"showPosts": true,
}
`;
The Snapshot
const selectState = state => state.userProfile;
export const selectShowAvatar = state => selectState(state).showAvatar;
export const selectShowPosts = state => selectState(state).showPosts;
export const selectShowPhotos = state => selectState(state).showPhotos;
UserProfileSelectors.js
const state = {
userProfile: {
showAvatar: 'showAvatar',
showPosts: 'showPosts',
showPhotos: 'showPhotos',
},
};
const fixtures = {
'should select show-avatar': () => selectShowAvatar(state),
'should select show-posts': () => selectShowPosts(state),
'should select show-photos': () => selectShowPhotos(state),
};
describe('UserProfile - Selectors', () =>
testSelectorsSnapshotWithFixtures(fixtures));
UserProfileSelectors.js
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`UserProfile - Selectors should select show-avatar 1`] = `"showAvatar"`;
exports[`UserProfile - Selectors should select show-photos 1`] = `"showPhotos"`;
exports[`UserProfile - Selectors should select show-posts 1`] = `"showPosts"`;
The Snapshot
Questions?
Complete the Puzzle
connect the dots
import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import * as actions from './UserProfileActions';
import reducer from './UserProfileReducer';
import {
selectShowAvatar,
selectShowPosts,
selectShowPhotos,
} from './UserProfileSelectors';
import UserProfile from './UserProfile';
index.js
// map state to props
const mapStateToProps = state => ({
showAvatar: selectShowAvatar(state),
showPosts: selectShowPosts(state),
showPhotos: selectShowPhotos(state),
});
// map action dispatchers to props
const mapDispatchToProps = dispatch =>
bindActionCreators(actions, dispatch);
// export reducers
export const reducers = { userProfile: reducer };
// export connected component
export default connect(
mapStateToProps,
mapDispatchToProps
)(UserProfile);
index.js
Use the component
import { combineReducers } from 'redux';
import { reducers as userProfileReducers } from './UserProfile';
import { reducers as passwordStrengthReducers } from './PasswordStrength';
import { reducers as breadcrumbBarReducers } from './BreadcrumbBar';
import { reducers as searchBarReducers } from './SearchBar';
import { reducers as layoutReducers } from './Layout';
const reducers = combineReducers({
...userProfileReducers,
...passwordStrengthReducers,
...breadcrumbBarReducers,
...searchBarReducers,
...layoutReducers,
});
export default reducers;
reducers.js
import React from 'react';
import Page from '../Page';
import UserProfile from '../UserProfile';
const UserPage = ({ user }) => (
<Page title={user.name}>
<UserProfile user={user} />
</Page>
);
export default UserPage;
Then use it
The Integration Test
const integrationTestHelper = new IntegrationTestHelper(reducers);
const component = integrationTestHelper.mount(
<UserProfile user="some-user" />
);
integration.test.js
// The user-avatar should not be shown
expect(component.exists('UserAvatar')).toEqual(false);
integrationTestHelper.takeStoreSnapshot('initial state');
integration.test.js
exports[`UserProfile - Integration Test should flow: initial state 1`] = `
Object {
"userProfile": Object {
"showAvatar": false,
"showPhotos": false,
"showPosts": false,
},
}
`;
// trigger checkbox change
component
.find('input#show-avatar-toggler')
.simulate('change', { target: { checked: true } });
integration.test.js
// The user-avatar should be shown now
expect(component.exists('UserAvatar')).toEqual(true);
integrationTestHelper.takeStoreAndLastActionSnapshot(
'Update to show the user-avatar'
);
integration.test.js
exports[`UserProfile - Integration Test should flow: Update to show the user-avatar 1`] = `
Object {
"action": Array [
Array [
Object {
"payload": true,
"type": "USER_PROFILE_UPDATE_AVATAR",
},
],
],
"state": Object {
"userProfile": Object {
"showAvatar": true,
"showPhotos": false,
"showPosts": false,
},
},
}
`;
import React from 'react';
import { IntegrationTestHelper } from 'react-redux-test-utils';
import UserProfile, { reducers } from '../index';
describe('UserProfile - Integration Test', () => {
it('should flow', () => {
const integrationTestHelper = new IntegrationTestHelper(reducers);
const component = integrationTestHelper.mount(
<UserProfile user="some-user" />
);
// The user-avatar should not be shown
expect(component.exists('UserAvatar')).toEqual(false);
integrationTestHelper.takeStoreSnapshot('initial state');
// trigger checkbox change
component
.find('input#show-avatar-toggler')
.simulate('change', { target: { checked: true } });
// The user-avatar should be shown now
expect(component.exists('UserAvatar')).toEqual(true);
integrationTestHelper.takeStoreAndLastActionSnapshot(
'Update to show the user-avatar'
);
});
});
integration.test.js
Questions?
E2E Testing
Foreman E2E Goals
- We want it to be as lean as possible $$$
- We want it to be apart of our source-code
- We want it to be able to run everywhere
- We want it to be apart of our CI
- We want it to run for each pull-request
- We want it to be written with javascript
It must be an
open-source project
Fast, easy and reliable testing for anything that runs in a browser.
A node.js tool to automate
end-to-end web testing.
Write tests in JS or TypeScript, run them and view results
What have we found
Cypress
- It is open-source!
- Can run everywhere
- Easy to install it on
travis - It gives detailed errors and warnings
- It can save screenshots and videos
- It has a SASS dashboard which is free for open-source
npm install --save-dev cypress
Install it locally
"scripts": {
...
"deploy-storybook": "storybook-to-ghpages",
"postinstall": "node ./script/npm_install_plugins.js",
"analyze": "./script/webpack-analyze",
+ "cypress:open": "cypress open"
},
package.json
language: node_js
node_js:
- 9.11.2 # choose your version
addons:
postgresql: "9.4" # choose your version
services: # redis is not necessary, https://redis.io/
- postgresql
cache:
bundler: true # cache for bundler
npm: true # cache for npm
directories:
- /home/travis/.cache/Cypress # cache for bundler in node environement # cache travis
- node_modules
before_install:
- sudo apt-get install libsystemd-journal-dev
- sudo apt-get build-dep vagrant ruby-libvirt
- sudo apt-get install qemu libvirt-bin ebtables dnsmasq
- sudo apt-get install libxslt-dev libxml2-dev libvirt-dev zlib1g-dev ruby-dev
- gem install bundler # install bundler
- bundle install # install dependancies in your gemfile
install:
- npm install # install npm
before_script:
- psql -c 'create database travis_ci_test;' -U postgres # create database test
- cp config/database.yml.travis config/database.yml
- RAILS_ENV=test bundle exec rake db:migrate # create database with table
- SEED_ADMIN_PASSWORD=1234 RAILS_ENV=test bundle exec rake db:seed # launch your seed
- RAILS_ENV=test bundle exec rake assets:precompile # compile assets js
- RAILS_ENV=test bundle exec rake webpack:compile
- RAILS_ENV=test bundle exec foreman start rails & # launch server rails
- sleep 12
script:
- cypress run --record --key 9de1d57d-763b-4a9e-a5a6-c7eee4983ec7 # launch your frontend test with Cypress
Install it on travis
describe('Layout', () => {
beforeEach(() => {
cy.visit('http://localhost:5000');
});
it('should hover all menu items', () => { });
it('should contain taxonomies dropdown', () => { });
it('should contain notfication drawer', () => { });
it('should contain user dropdwon', () => { });
it('should switch to mobile-view', () => { });
});
Writing tests
it('should hover all menu items', () => {
cy.get('.fa-tachometer').trigger('mouseover');
cy.contains('Dashboard').should('be.visible');
cy.get('.fa-server').trigger('mouseover');
cy.contains('All Hosts').should('be.visible');
cy.get('.fa-wrench').trigger('mouseover');
cy.contains('Host Group').should('be.visible');
cy.get('.pficon-network').trigger('mouseover');
cy.contains('Smart Proxies').should('be.visible');
cy.get('.fa-cog').trigger('mouseover');
cy.contains('Users').should('be.visible');
});
Hover all the menu items
it('should contain user dropdwon', () => {
cy.get('#account_menu').click();
cy.contains('My Account').should('be.visible');
});
Find the user dropdown
it('should switch to mobile-view', () => {
cy.viewport(550, 750);
cy.contains('Toggle navigation').click();
cy.get('.visible-xs-block .fa-user').click();
cy.contains('My Account').should('be.visible');
});
Switch to mobile-view
Run it locally
Screenshots and Videos
Travis and the Cypress dashboard
View all past test
View your last test
See the failures
Coveralls
How it looks in Github
Questions?
Thank you for participating
Testing React-Redux Applications with Jest
By Avi Sharvit
Testing React-Redux Applications with Jest
- 830