How to test your react redux application

Bài viết này mình sẽ hướng dẫn cách test redux app với JestEnzyme.

Cài đặt Jest và Enzyme

  • Jest: yarn add --dev jest hoặc npm install --save-dev jest
  • Enzyme: tuỳ thuộc vào phiên bản react hiện tại, bạn cài đặt enzyme cùng với adapter thích hợp. Ví dụ phiên bản react trong project là 16: yarn add --dev enzyme enzyme-adapter-react-16 hoặc npm i --save-dev enzyme enzyme-adapter-react-16
  • Chú ý: bạn nên cài thêm package babel-jest để jest compile JS sử dụng babel (npm install --save-dev babel-jest babel-core hoặc yarn add --dev babel-jest babel-core)

Reducer test

Reducer là pure function, nó lấy state có sẵn và một action để trả về state mới sau khi action được gọi, vì thế sẽ k có side effect nên mocking ở đây là không cần thiết.

Jest mặc định sẽ tìm kiếm các file test trong thư mục __test__ và extension có dạng .spec.js hoặc .test.js. Ví dụ reducer được đặt trong thư mục src/store/topics/reducer.js thì ta có thể tạo thư mục test như sau src/__test__/store/topics/reducer.spec.js.

Case test đơn giản nhất với reducer là khi khởi tạo, lúc này ta chỉ cần khai báo state khởi tạo mà không liên quan gì đến action:

import uut from '../store/topics/reducer';

const initialState = {
  topicsByUrl: undefined,
  selectedTopicUrls: [],
  selectionFinalized: false,
};

describe('store/topics/reducer', () => {
  it('should have initial state', () => {
    expect(uut()).toEqual(initialState);
  });
});

Giả sử ta thêm một action và kiểm tra state được trả về có đúng hay không:

import Immutable from 'seamless-immutable';
import { Reducer } from 'redux-testkit'; // thư viện hỗ trợ verify immutable reducer
import uut from '../store/topics/reducer';
import * as actionTypes from '../constants/actionTypes';

const initialState = {
  topicsByUrl: undefined,
  selectedTopicUrls: [],
  selectionFinalized: false,
};

describe('store/topics/reducer', () => {

  it('should have initial state', () => {
    expect(uut()).toEqual(initialState);
  });

  it('should not affect state', () => {
    Reducer(uut).expect({type: 'NOT_EXISTING'}).toReturnState(initialState);
  });

  it('should store fetched topics', () => {
    const topicsByUrl = {url1: 'topic1', url2: 'topic2'};
    const action = {type: actionTypes.TOPICS_FETCHED, topicsByUrl};
    Reducer(uut).expect(action).toReturnState({...initialState, topicsByUrl});
  });

  it('should store fetched topics and override existing topics', () => {
    const existingState = Immutable({...initialState, topicsByUrl: {url3: 'topic3'}});
    const topicsByUrl = {url1: 'topic1', url2: 'topic2'};
    const action = {type: actionTypes.TOPICS_FETCHED, topicsByUrl};
    Reducer(uut).withState(existingState).expect(action).toReturnState({...initialState, topicsByUrl});
  });
  
});

Cá nhân mình thấy rspec và jest test khá giống nhau, dễ đọc và hiểu từng test case :man_detective: .

Nếu state object phức tạp, redux-testkit có các method cho nested state hoặc sử dụng custom expectations. (tham khảo thêm tại đây)

Action test (middleware mặc định: thunks)

Thunks wrap sync hoặc async function để thực hiện action. Nó có thể tạo ra các side effect như truy xuất dữ liệu từ server, vậy mock function ở đây là cần thiết. Ví dụ:

import reducer from '../store/topics/reducer';
import * as actions from '../actions/posts/getPost';
import { UPDATE_POST_SUCCESS } from '../actions/posts/updatePost';
import getPostMock from '../mocks/getPostMock';

describe('post reducer', () => {
  it('should return the initial state', () => {
    expect(reducer(undefined, {})).toEqual({});
  });

  it('should handle GET_POST_START', () => {
    const startAction = {
      type: actions.GET_POST_START
    };
    // it's empty on purpose because it's just starting to fetch posts
    expect(reducer({}, startAction)).toEqual({});
  });

  it('should handle GET_POST_SUCCESS', () => {
    const successAction = {
      type: actions.GET_POST_SUCCESS,
      post: getPostMock.data, // important to pass correct payload, that's what the tests are for ;)
    };
    expect(reducer({}, successAction)).toEqual(getPostMock.data);
  });

  it('should handle UPDATE_POST_SUCCESS', () => {
    const updateAction = {
      type: UPDATE_POST_SUCCESS,
      post: getPostMock.data,
    };
    expect(reducer({}, updateAction)).toEqual(getPostMock.data);
  });

  it('should handle GET_POST_FAIL', () => {
    const failAction = {
      type: actions.GET_POST_FAIL,
      error: { success: false },
    };
    expect(reducer({}, failAction)).toEqual({ error: { success: false } });
  });
});

Component test

  1. Snapshot test Jest có chức năng snapshot testing được rất nhiều công ty ưa chuộng, ban đầu jest sẽ capture cấu trúc của component, cấu trúc này sẽ được lưu vào thư mục __snapshot__. Khi có bất kì thay đổi trong component đã được snapshot trước đó, test case với component được capture sẽ lỗi. Ví dụ ta có component Link:

    // Link.react.js
    import React from 'react';
    
    const STATUS = {
      HOVERED: 'hovered',
      NORMAL: 'normal',
    };
    
    export default class Link extends React.Component {
      constructor(props) {
        super(props);
    
        this._onMouseEnter = this._onMouseEnter.bind(this);
        this._onMouseLeave = this._onMouseLeave.bind(this);
    
        this.state = {
          class: STATUS.NORMAL,
        };
      }
    
      _onMouseEnter() {
        this.setState({class: STATUS.HOVERED});
      }
    
      _onMouseLeave() {
        this.setState({class: STATUS.NORMAL});
      }
    
      render() {
        return (
          <a
            className={this.state.class}
            href={this.props.page || '#'}
            onMouseEnter={this._onMouseEnter}
            onMouseLeave={this._onMouseLeave}
          >
            {this.props.children}
          </a>
        );
      }
    }
    

    Ta sẽ tạo snapshot bằng method toMatchSnapshot() :

    import React from 'react';
    import Link from '../Link.react';
    import renderer from 'react-test-renderer';
    
    test('Link changes the class when hovered', () => {
      const component = renderer.create(
        <Link page="http://www.facebook.com">Facebook</Link>,
      );
      let tree = component.toJSON();
      expect(tree).toMatchSnapshot();
    
      // manually trigger the callback
      tree.props.onMouseEnter();
      // re-rendering
      tree = component.toJSON();
      expect(tree).toMatchSnapshot();
    
      // manually trigger the callback
      tree.props.onMouseLeave();
      // re-rendering
      tree = component.toJSON();
      expect(tree).toMatchSnapshot();
    });
    

    Nếu bạn chủ động cho phép thay đổi cấu trúc trong component, bạn cần update snapshot cho component bằng câu lệnh jest -u .

  2. DOM test sử dụng enzyme shallow renderer : Được sử dụng khi bạn muốn test các element trong DOM tree. Ví dụ bạn có component sau:

    // CheckboxWithLabel.js
    
    import React from 'react';
    
    export default class CheckboxWithLabel extends React.Component {
      constructor(props) {
        super(props);
        this.state = {isChecked: false};
    
        // bind manually because React class components don't auto-bind
        // http://facebook.github.io/react/blog/2015/01/27/react-v0.13.0-beta-1.html#autobinding
        this.onChange = this.onChange.bind(this);
      }
    
      onChange() {
        this.setState({isChecked: !this.state.isChecked});
      }
    
      render() {
        return (
          <label>
            <input
              type="checkbox"
              checked={this.state.isChecked}
              onChange={this.onChange}
            />
            {this.state.isChecked ? this.props.labelOn : this.props.labelOff}
          </label>
        );
      }
    }
    

    Component sẽ được test như sau:

    // __tests__/CheckboxWithLabel-test.js
    
    import React from 'react';
    import {shallow} from 'enzyme';
    import CheckboxWithLabel from '../CheckboxWithLabel';
    
    test('CheckboxWithLabel changes the text after click', () => {
      // Render a checkbox with label in the document
      const checkbox = shallow(<CheckboxWithLabel labelOn="On" labelOff="Off" />);
    
      expect(checkbox.text()).toEqual('Off');
    
      checkbox.find('input').simulate('change');
    
      expect(checkbox.text()).toEqual('On');
    });
    

Kết

Hy vọng bài viết một phần giúp bạn hình dung ra unit test của react redux. Nếu có bất kì thắc mắc, hãy comment bên dưới 😙