Skip to content

Instantly share code, notes, and snippets.

@sriramrudraraju
Last active November 11, 2023 02:00
Show Gist options
  • Save sriramrudraraju/cc3cc5a20be6bed5df391d49d540a3a4 to your computer and use it in GitHub Desktop.
Save sriramrudraraju/cc3cc5a20be6bed5df391d49d540a3a4 to your computer and use it in GitHub Desktop.
React Redux Unit Tests: Full App

Unit Tests Guide

Unit tests for react-redux using Jest, Enzyme.

In-depth explanations for synchronus action creators, reducers and asynchronus action creators

Note : I think .md file doesnt support JSX syntaxs and rendering rest of js code wierdly, so i used open and close tags for the JSX elements. In real code we used single tag syntax.

Contents

Action Creator

A redux action creator generally tested for 2 cases:

  • type
  • payload
// Action.js

/*
Action for setting H1 element
*/
export const setH1 = text => {
  return { type: "SET_H1", payload: text };
};

Simple way (unit tests for one action file)

// Action.spec.js

import { setH1 } from "./Action.js";

/*
test suite for setH1 action creator
*/
describe("setH1", () => {
  /**
  * checking for its type
  */
  it('should have type of "SET_H1"', () => {
    expect(setH1().type).toBe("SET_H1");
  });
  /**
  * checking for parameter passing as payload
  */
  it("should pass on to payload as we pass in params", () => {
    let text = { text: "text" };
    expect(setH1(text).payload).toBe(text);
  });
});

Generic way (can add all action's tests in 1 file)

// Action.spec.js

import * as acts from "./Action";
/**
 * @param actionCreator: action creator that needs to be tested
 * @param type: type of action from action creator
 * @param payload: payload of action from action creator
 * @param ...prarams: any params that need to be passed to action creator
 */
const testActionCreator = (actionCreator, type, payload, ...params) => {
  describe(actionCreator.name, () => {
    const a = actionCreator(...params);
    it("type", () => {
      expect(a.type).toBe(type);
    });
    it("payload", () => {
      if (typeof a.payload !== "undefined") {
        expect(a.payload).toBe(payload);
      }
    });
  });
};

/**
 * test suite for all the action creators (synchronus)
 */
describe("Action Creators", () => {
  const sampleText = { text: "text" };
  /**
   * test suite for setH1 action creators
   */
  testActionCreator(acts.setH1, "SET_H1", sampleText, sampleText);
  /**
   * rest of the action creators test cases follw the same
   */
});

Asynchronus Action Creator

An asynchronus action creator tested for 2 cases

  • On promise success
  • On promise failure
// Async-action-creator.js

/**
* generic function that creates async action creators
*/

import "whatwg-fetch";

export default (url, body, request, receive, error, cont) => {
  const receiveCallback = receive;
  const errorCallback = error;

  return (dispatch, getState) => {
    /**
   * If we have a pre-condition to fetching, 
   * check if we should continue.
   */
    if (cont) {
      if (!cont(getState())) {
        return Promise.resolve();
      }
    }

    /**
   * Action: Requesting data..
   */
    if (request) {
      dispatch(request());
    }

    /**
   * Fetch
   */
    let f = fetch(url, Object.assign({}, body)).then(response =>
      response.json()
    );

    /**
   * Action: Receiving data.
   */
    if (receiveCallback !== null) {
      f = f.then(data => {
        dispatch(receiveCallback(data ? data : {}));
      });
    }

    /**
   * Action: Error receiving data.
   */
    if (errorCallback) {
      f = f.catch(e => {
        dispatch(errorCallback(e.toString()));
      });
    }
    return f;
  };
};
// Action.js

import asyncActionCreator from "./Async-action-creator";

/**
* action creator for request type
*/
export const requestSurveyList = () => {
  return { type: "REQUEST_SURVEY_LIST" };
};

/**
* action creator for success receive type
*/
export const receiveSurveyList = payload => {
  return { type: "RECEIVE_SURVEY_LIST", payload: payload };
};

/**
* action creator for receive error type
*/
export const surveyListError = error => {
  return { type: "SURVEY_LIST_ERROR", payload: error };
};

export const fetchSurveyList = () => {
  return asyncActionCreator(
    "http://placeyoururlhere.com",
    {
      method: "POST",
      headers: {
        "Content-Type": "application/json; charset=utf-8"
      }
    },
    requestSurveyList,
    receiveSurveyList,
    surveyListError,
    state => {
      return (
        !!state.surveys &&
        typeof state.surveys === "object" &&
        !!state.surveys.list &&
        typeof state.surveys.list === "object" &&
        !state.surveys.list.length &&
        !state.surveys.listLoading
      );
    }
  );
};
//Async-test.js

import configureMockStore from "redux-mock-store";
import thunk from "redux-thunk";

/**
 * Mocks a valid HTTP response to an Ajax request.
 * @param {any} status 
 * @param {any} statusText 
 * @param {any} response 
 * @returns 
 */
export const mockResponse = (status, statusText, response) => {
  return new window.Response(response, {
    status: status,
    statusText: statusText,
    headers: {
      "Content-type": "application/json"
    }
  });
};

/**
 * Produces a fake fetch request, then performs tests on the Asynchronous Action.
 * @param {Function} asyncAction
 * @param {Object} store 
 * @param {Array<string>} actionTypes
 * @param {any} fetch 
 * @returns 
 */
export const testFetch = (asyncAction, store, actionTypes, mockFetch) => {
  const globalFetch = global.fetch;
  global.fetch = mockFetch;

  /**
  * Dispatch the asyncAction, then...
  */ 
  store = configureMockStore([thunk])(store);
  return store.dispatch(asyncAction).then(() => {
    /**
    * Count the generated Actions.
    */
    const actions = store.getActions();
    expect(actions.length).toBe(actionTypes.length);

    /**
    * Compare each Action type with the expected results.
    */ 
    for (let x = 0; x < actions.length; x++) {
      expect(actions[x].type).toBe(actionTypes[x]);
    }
    global.fetch = globalFetch;
  });
};

/**
 * Test a dispatched asynchronous action creator against an error response.
 * 
 * @param {Function} asyncAction 
 * @param {Object} store 
 * @param {Array<string>} actionTypes 
 * @param {string} [response='{ one: "two" }'] 
 * @returns 
 */
export const testError = (
  asyncAction,
  store,
  actionTypes = [],
  response = '{"one":"two"}'
) => {
  return testFetch(asyncAction, store, actionTypes, () =>
    Promise.reject(mockResponse(500, null, response))
  );
};

/**
 * Test a dispatched asynchronous action creator against a success response.
 * 
 * @param {Function} asyncAction 
 * @param {Object} store 
 * @param {Array<string>} actionTypes 
 * @param {string} [response='{ one: "two" }'] 
 * @returns 
 */
export const testSuccess = (
  asyncAction,
  store,
  actionTypes = [],
  response = '{"one":"two"}'
) => {
  return testFetch(asyncAction, store, actionTypes, () =>
    Promise.resolve(mockResponse(200, null, response))
  );
};
//Action.spec.js

import { fetchSurveyList } from "./Action";
import { testError, testSuccess } from "./Async-test";

/**
 * initial state taken from its reducer
 */
let INITIAL_SURVEYS_STATE = {
  data: {},
  list: [],
  listError: null,
  listLoading: false
};

/**
*SURVEY_LIST
*/ 
describe("Async Action: SURVEY_LIST", () => {
  /**
  *Test Success and Error
  */
  it("should dispatch the Receive Action on success", () =>
    testSuccess(fetchSurveyList(), { surveys: INITIAL_SURVEYS_STATE }, [
      "REQUEST_SURVEY_LIST",
      "RECEIVE_SURVEY_LIST"
    ]));
  it("should dispatch the Error Action on error", () =>
    testError(fetchSurveyList(), { surveys: INITIAL_SURVEYS_STATE }, [
      "REQUEST_SURVEY_LIST",
      "SURVEY_LIST_ERROR"
    ]));

  /**
  * Test Pre-Condition
  */ 
  it("should not dispatch when cached", () =>
    testError(fetchSurveyList(), {
      surveys: { ...INITIAL_SURVEYS_STATE, list: [1] }
    }));
  it("should not dispatch when loading", () =>
    testError(fetchSurveyList(), {
      surveys: { ...INITIAL_SURVEYS_STATE, listLoading: true }
    }));
});

Reducer

We need to test all the cases the reducer returns

// Reducer.js

/**
 * always heve a default state oresle reducers 
 * return undefined, which is a pain to decode
 */
const INITIAL_STATE = {
  h1: "",
  title: ""
};

/**
 * dont  mutate the state. create new state and return it.. 
 * rule of reducer
 */
export default function Dom_Reducer(state = INITIAL_STATE, action) {
  switch (action.type) {
    case "SET_H1":
      /**
       * change H1
       */
      return { ...state, h1: action.payload };
    case "SET_TITLE":
      /**
       * change title
       */
      return { ...state, title: action.payload };
    default:
      return state;
  }
}
// Reducer.spec.js

import Dom_Reducer from "./Reducer";

/**
   * test suite fro dom reducer
   */
describe("dom_reduecer", () => {
  /**
     * checking for case: default
     */
  it("case: default", () => {
    expect(Dom_Reducer(undefined, {})).toEqual({ h1: "", title: "" });
  });
  /**
     * checking for case: SET_H1
     */
  it("case: SET_H1", () => {
    let h1 = "some random header";
    expect(Dom_Reducer(undefined, { type: "SET_H1", payload: h1 })).toEqual({
      h1: h1,
      title: ""
    });
  });
  /**
     * checking for case: SET_TITLE
     */
  it("case: SET_TITLE", () => {
    let title = "some random title";
    expect(
      Dom_Reducer(undefined, { type: "SET_TITLE", payload: title })
    ).toEqual({ h1: "", title: title });
  });
});

Component

There are lot of ways testing a component. Below tests are few examples on testing a component.

Import

Ways of importing a component

  • As function for statetless components and statefull components.
  • As prop for containers with redux states.
// Component1.js

/**
*stateless component
*/

const component1 = (props) => (
  /**
  *some jsx
  */
)

export default component1;
// Component2.js

import React from 'react';
/**
*stateful component
*/

class component2 extends React.Component {
  render() {
    /**
    * some jsx
    */
  }
}

export default component2;
// Component3.js

import React from 'react';
import { connect } from 'react-redux';

/**
*need to add export before class,
*so that we can import only component part
*/
//||||||
//vvvvvv
  export class component3 extends React.Component {
    render() {
      /**
      * some jsx
      */
    }
  }
export default connet()(component3);
// Component.spec.js

import component1 from './Component1';
import component2 from './Component2';
import { Component3 } from './Component3';

/**
* test cases goes here
*/

Snapshot

Documentation

// Component.js

import React from 'react';

const component = (props) => (
  /**
  * some jsx
  */
)

export default component;

Note : Dont forget to pass mock props to the component.

// Component.spec.js

import React from 'react';
import renderer from 'react-test-renderer';

import component from './Component.js';

/**
* NOTE:
* this example component doesnt have any props passed to it.
* if a component need props, we need to mock them or atleast define them
* and pass as props to the component. if we sont dont pass the props
* jest will throw errors saying 'not defined' or 'undefined'
*/

/**
* example on how to pass props for this component
* let props = {
*     testString: "test",
*     testMethod: ()=> {},
*     testObject: {},
*     testNumber: 1  
* };
*
* it('renders correctly', () => {
*    let snapshot = renderer.create(<component {...props}/>).toJSON();
*    expect(snapshot).toMatchSnapshot();
*  });
*/
describe('<component />', () => {
  //test spec for comparing with existing snapshot or create one
  it('renders correctly', () => {
    let snapshot = renderer.create(<component></component>).toJSON();
    expect(snapshot).toMatchSnapshot();
  });
});

Mock

Documentation

// Component.js

import React from "react";

const SearchComponent = props => {
  return (
    <div>
      <input
        onChange={e => props.onChange(e.target.value)}
        placeholder={props.placeholder || "Search"}
      />
    </div>
  );
};

export default SearchComponent;
// Component.spec.js

import React from "react";
/**
 * Shallow rendering is useful to constrain yourself to testing a 
 * component as a unit, and to ensure that your tests aren't 
 * indirectly asserting on behavior of child components.
 */
import { shallow } from "enzyme";

import SearchComponent from "./Component";

describe("<SearchComponent />", () => {
  /**
   * we generally create / compare a snapshot here
   */

  it("input value change", () => {
    /**
     * creating a mock function
     */
    const myMock = jest.fn();
    /**
     * passing this mock function to the component as props
     * here onChange is a prop for the component
     */
    const wrapper = shallow(<SearchComponent onChange={myMock} ></SearchComponent>);
    expect(myMock.mock.calls.length).toBe(0);

    /**
     * simulate a change event so that onChange on input triggers
     * and myMock will be called.
     * will see about 'simulate' later
     */

    /**
    * after simulate, onchange triggers and 
    * myMock will be called once
    */
    expect(myMock.mock.calls.length).toBe(1);

    /**
     * we can also test on what params it was called.
     * will do it later
     */
  });
});

Simulate

Documentation

// Component.js

import React from "react";

const SearchComponent = props => {
  return (
    <div>
      <input
        onChange={e => props.onChange(e.target.value)}
        placeholder={props.placeholder || "Search"}
      />
    </div>
  );
};

export default SearchComponent;
// Component.spec.js

import React from "react";
/**
 * Shallow rendering is useful to constrain yourself to testing a 
 * component as a unit, and to ensure that your tests aren't 
 * indirectly asserting on behavior of child components.
 */
import { shallow } from "enzyme";

import SearchComponent from "./Component";

describe("<SearchComponent />", () => {
  /**
   * we generally create / compare a snapshot here
   */

  it("input value change", () => {
    /**
     * creating a mock function
     */
    const myMock = jest.fn();
    /**
     * passing this mock function to the component as props
     * here onChange is a prop for the component
     */
    const wrapper = shallow(<SearchComponent onChange={myMock}></SearchComponent>);

    /**
     * initially myMock is not called
     */
    expect(myMock.mock.calls.length).toBe(0);

    /**
     * simulate the change event on input and pass the value 
     * to it
     */
    wrapper.find("input").simulate("change", {
      target: { value: "test" }
    });

    /**
    * after simulate, onchange triggers and 
    * myMock will be called once
    */
    expect(myMock.mock.calls.length).toBe(1);

    /**
     * we simulated the change event with value = 'test'
     * so the mock function will be called with 'test' param
     */
    expect(myMock.mock.calls[0][0]).toBe("test");
  });
});

Spy

// Component.js

import React from "react";

class LoginComponent extends React.Component {
  onFormSubmit(event) {
    console.log("onFormSubmit is called");
  }
  render() {
    return (
      <div>
        <form onSubmit={this.onFormSubmit.bind(this)}>
        </form>
      </div>
    );
  }
}
export default LoginComponent;
// Component.spec.js

import React from "react";
/**
 * Shallow rendering is useful to constrain yourself to testing a 
 * component as a unit, and to ensure that your tests aren't 
 * indirectly asserting on behavior of child components.
 */
import { shallow } from "enzyme";

import LoginComponent from "./Component";

describe("<LoginComponent />", () => {
  /**
   * we generally create / compare a snapshot here
   */

  it("form submit", () => {
    /**
     * create a spy on onFormSubmit method
     */
    const onFormSubmitSpy = jest.spyOn(
      LoginComponent.prototype,
      "onFormSubmit"
    );

    let wrapper = shallow(<LoginComponent></LoginComponent>);

    /**
     * simulate form submit, so that onFormSubmit will invoke
     */
    wrapper.find("form").simulate("submit");

    /**
     * check whether onFormSubmit invokde or not with spy.
     */
    expect(onFormSubmitSpy).toHaveBeenCalledTimes(1);
  });
});

Instance

Documentation

// Component.js

import React from "react";

class LoginComponent extends React.Component {
  onUserKeyPress(event) {
    /**
     * If the first character is numeric or 
     * the character is non-alphanumeric, prevent it.
     */
    if (
      (event.target.value.length === 0 && event.key.match(/\d/)) ||
      event.key.match(/[^\da-zA-Z]/)
    ) {
      event.preventDefault();
      return false;
    }
    return true;
  }
  render() {
    return (
      <div>
        <form></form>
      </div>
    );
  }
}
export default LoginComponent;
// Component.spec.js

import React from "react";
/**
 * Shallow rendering is useful to constrain yourself to testing a 
 * component as a unit, and to ensure that your tests aren't 
 * indirectly asserting on behavior of child components.
 */
import { shallow } from "enzyme";

import LoginComponent from "./Component";

describe("<LoginComponent />", () => {
  /**
   * we generally create / compare a snapshot here
   */
  
  it("doesnt accept numbers as first char", () => {
    const wrapper = shallow(<LoginComponent></LoginComponent>);
    expect(
      wrapper.instance().onUserKeyPress({
        target: { value: "" },
        key: "1",
        preventDefault: () => {}
      })
    ).toBe(false);
  });
});

Child Parent Callbacks

In depth explanation

// partent.js

class Parent extends React.Component {
  constructor(props){
  super(props);
  }
  
  render() {
    return (
      <div>
        <Child childCallbackProp ={ x => {this.props.parentCallbackProp(x)}}>
      </div>
    )
  }
}

export default Parent
// parent.spec.js

import { shallow } from "enzyme";
import Parent from "./parent.js";

describe("Parent Component", ()=> {
  it("testing child-parent callback", ()=> {
    const parentCallbackProp_mock = jest.fn();
    const prop = {/*All the props the parent depends on*/};
    const wrapper = shallow(<Parent {...prop} parentCallbackProp= {parentCallbackProp_mock}/>);
    wrapper.find('Child').prop('childCallbackProp')({x: 1, y:2});
    expect(parentCallbackProp_mock.mock.calls[0][0]).toEqual({x: 1, y:2});
  )
})

Mocking Functions

import that function
		import { uuid } from '../uuid';
	mock the file
		jest.mock('../uuid');
	mock the return values. in this example it has to responsd with 2 unique ids
		(uuid as any).mockReturnValueOnce(10)
             		     .mockReturnValueOnce(20)

Observations

  • All the props used in the component should be either mocked or defined.
  • Mock or Spy the functions before shallow or full Dom Rendering (mount) .
  • We used mocks for tracking redux actions, Spies for component methods tracking.
  • Be careful when .instance() and .simulate() in same test spec.
  • To test component lifecycle hooks use mount instead shallow. shallow allows few lifecycles in latest version.
  • Some times we get errors beacuse 3rd party library components. In this case mock that library in that test file
    • like jest.mock("library-name")
  • when using renderer for snapshot testing,
    • component has <Link> which requires <Router> as dependency so i wrapped the component in <Router>
    • <Router> needs "history" prop. Imported createHistory from "history/createBrowserHistory" and used for history
  • if spying on class methods reset and restore them before each test case,
    • if not reset, calls get satcked.
    • if not restored, it messed with instance method invokings.
  • If you want to test refs you have to mount.
  • Spying on instance methods needs forceUpdate(). More info
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment