Testing React-Redux Applications with Jest

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