React-Redux Application Unit Test

本文探讨了在React项目中实施单元测试的策略,强调了平衡团队任务与测试覆盖率的重要性。详细介绍了如何针对React应用的核心组件和状态进行测试,包括使用Jest、Enzyme等工具,以及如何处理异步代码、模拟依赖和Redux异步操作。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

Strategy

Before we start, we are going to talk about strategy first. If you just want to experience unit test for React app, strategy might not help too much. But if you really aim to lead a useful practice in your project, strategy determine whether you could carry it out. There are countless delightful cases of unit test practitioners, while a lot of teams fail and find it harder and harder to move on and even give it up finally.
The key for a successful unit test is the balance and tradeoff between your development team and tasks. If your development team has already been buried with feature delivery, we could cover unit test for the core and base code first. If stressing coverage for the whole project, it will lead to heavy load on unit test over functionality. On the other hand, if your development team has great capacity, it’s fine to push high-coverage on unit test.
Combined with React app, we could focus on data(state) changes first, since React follow MV* pattern. Validating app’s state means that its internal works properly in most cases. For a redux app, state validation is to test the store, namely reducers and actions. Reducers represent the app’s state while actions change app’s state with implementation of business logic. The second part is the representation of the app’s state, components. Components have a lower priority because they are related to UI view, which could be tested on system integration layer. Until now there are still no effective tools to validate rendered UI. And from my experience, UI view is the most frequently changed part on web. Therefore, it’s fine to get the job done in the final step.

Framework

Code blocks to cover

  • Redux reducer
  • Redux actions
  • Async code
  • Mock modules
  • Redux async action with redux-thunk
  • Selectors
  • Representational component
  • Container component

Test case example for code blocks

Redux reducers

Since reducers are just pure function, we could test reducers with different parameters passed in.

// reducers: answers.js
import {
  TOUR_UPDATE_ANSWERS,
} from '@/actions/types';

const answers = (state = [], { type, payload }) => {
  switch (type) {
    case TOUR_UPDATE_ANSWERS:
      return [...payload];
    default:
      return state;
  }
};

export { answers };

// reducers test cases
import { answers } from '@/reducers/tourClass/answer';
import * as types from '@/actions/types';

describe('answer reducer', () => {
  it('should return initial state', () => {
    expect(answers(undefined, {})).toEqual([]);
  });

  it('should handle action TOUR_UPDATE_ANSWERS', () => {
    const array = [{ id: 0, name: 'test' }];
    expect(
      answers([], {
        type: types.TOUR_UPDATE_ANSWERS,
        payload: [...array],
      })
    ).toEqual(array);
  });
});
Redux pure actions

Pure actions are simple function too, therefore they are easy to test with input-output validation

// actions: index.js
export const updateTeams = teams => {
  const action = {
    type: SET_TEAMS,
    payload: [...teams],
  };
  return action;
};

// test case
import * as types from '@/actions/types';
import {
  updateTeams,
} from './index';

describe('redux action creators: index', () => {
  it('action creator: updateTeams', () => {
    const teams = [{ id: 0, name: 'team' }];
    expect(updateTeams(teams)).toEqual({
      type: types.SET_TEAMS,
      payload: [...teams],
    });
  });
});
Test async code

Jest tests complete once they reach the end of their execution. Async code block will not work as you expect if you write it as sync test. To solve the issue, we could pass in argument done for the test such that Jest will wait for the test complete before calling done. @see Testing Asynchronous Code.

// this will not work
test('async code will not work as sync code block', () => {
  function callback(data) {
    expect(data).toBe('peanut butter');
  }

  fetchData(callback);
});

// pass in `done` instead
test('the async test will work with done passed in', done => {
  function callback(data) {
    expect(data).toBe('peanut butter');
    done();
  }

  fetchData(callback);
})

If your code use promise or async/await, then just return the promise in your test case or apply async in your test case.

test('fetch data', () => {
  return fetchData()
  	.then(data => expect(data).toEqual({ erro: 0 })
  	.catch(error => expect(error).toMatch('test'));
})

// or use async/await
test('fetch data', async () => {
  const data = await fetchData();
  expect(data).toEqual({ error: 0 });
})
Test timer

Jest provide timer mock for testing code blocks with timers, e.g. window.setTimeout, window.setInterval, see Timer mocks. With these util function we could easily test these code blocks.

// throttle.js
export default function throttle(fn, delay = 500, debounce) {
  let timer = null;
  let lastExecute = 0;

  const callback = function(...args) {
    const context = this;

    const exec = function exec() {
      if (timer) {
        window.clearTimeout(timer);
        timer = null;
      }
      fn.apply(context, args);
    };

    if (debounce) {
      if (timer) {
        window.clearTimeout(timer);
      }
      timer = window.setTimeout(exec, delay);
    } else {
      const now = Date.now();
      if (now - lastExecute > delay) {
        exec();
        lastExecute = now;
      } else if (!timer) {
        timer = window.setTimeout(exec, delay);
      }
    }
  };

  return callback;
}

// throttle.spec.js
import throttle from './throttle';

describe('util function: throttle', () => {
  let testFunc = jest.fn();

  beforeEach(() => {
    jest.clearAllTimers();
    jest.useFakeTimers();
    testFunc.mockReset();
  });

  it('should execute callback for the first time', () => {
    const func = throttle(testFunc, 100);
    func();
    expect(testFunc).toHaveBeenCalledTimes(1);
    jest.advanceTimersByTime(100);
    expect(testFunc).toHaveBeenCalledTimes(1);
  });

  it('should throttle execution frequency', () => {
    const func = throttle(testFunc, 100);
    func();
    jest.advanceTimersByTime(50);
    func();
    expect(testFunc).toHaveBeenCalledTimes(1);
    jest.advanceTimersByTime(100);
    expect(testFunc).toHaveBeenCalledTimes(2);
  });
});
Mock dependencies

It’s common that a module depends on other modules. We always encapsulate logic in another file and import it when needed. Jest free us from mocking dependencies with jest.mock. For more detail about how Jest mock functions and modules, see mock functions.

// index.js
import { getTeams } from '@/request/tourClazz';
export const fetchTeams = () => {
  return getTeams().then(data => data);
}

// index.spec.js
import { fetchTeams } from './index';
jest.mock('@/requests/tourClazz');
import { getTeams, } from '@/requests/tourClazz';

describe('mock dependency', () => {
  it('should module name imported', async () => {
  	getTeams.mockResolvedValue({
  	  teams: [],
  	});
  	const data = fetchTeams();
  	expect(data).toEqual({ teams: [] });
  })
});
Manual mock

Automatic module mocks in jest will just mock exported function by default, other exports like object, jest will bypass them and refer to the exported values. If we need to mock exported objects, then we have to make manual mock. For manual mock, see manual mock on jest doc. In detail we could achieve it by add __mocks__ folders and mocking files next to the target module, which could export anything we need to test the cases.

// clientPush.js
// we need to mock client.obsPushMsg.connect
import client from './client';

export const CLIENT_EVENTS = {
  MIC_ERROR: 0,
  MIC_VOL: 1,
  LIVE_STATUS: 2,
  SCREENSHOT: 3,
  MIC_ACTION: 4,
  MIC_QUALITY: 5,
  MIC_INFO: 6,
};

const CLIENT_EVENT_TYPES = Object.values(CLIENT_EVENTS);

class ClientPush {
  static CLIENT_EVENTS = CLIENT_EVENTS;

  constructor() {
    this._listeners = {};

    CLIENT_EVENT_TYPES.forEach(event => {
      this._listeners[event] = [];
    });

    if (client.obsPushMsg && client.obsPushMsg.connect) {
      client.obsPushMsg.connect(msg => {
        try {
          const { type: event, content } = JSON.parse(msg);
          if (event in this._listeners) {
            const listeners = this._listeners[event];
            for (const listener of listeners) {
              if (typeof listener === 'function') {
                listener(content);
              }
            }
          }
        } catch (error) {
          console.log(error);
        }
      });
    }
  }
  ...
}
const clientPush = new ClientPush();
export default clientPush;

// __mocks__/client.js
const client = {
  obsPushMsg: {
    connect: jest.fn().mockImplementation(func => func),
  },
};

export default client;

// clientPush.spec.js
jest.mock('./client');
import client from './client';

import clientPush, { CLIENT_EVENTS } from './clientPush';

const dispatchClientMsg = client.obsPushMsg.connect.mock.calls[0][0];

describe('clientPush', () => {
  beforeEach(() => {
    const listeners = {};
    Object.values(CLIENT_EVENTS).forEach(event => {
      listeners[event] = [];
    });
    clientPush._listeners = listeners;
  });

  it('should init', () => {
    const listeners = clientPush._listeners;
    const target = {};
    Object.values(CLIENT_EVENTS).forEach(event => {
      target[event] = [];
    });
    expect(listeners).toEqual(target);
    expect(client.obsPushMsg.connect).toHaveBeenCalledTimes(1);
  });
});
Redux async action with redux-thunk

There are a few points to be clear for async actions:

  • Async actions return another function to be executed, namely thunk function.
  • Async actions could dispatch actions with dispatch().
  • Async actions could access store with getState()
  • Async actions could return value

Since async actions are thunk functions with dispatch and getState passed in, it’s difficult to mock these functions by hand. Here we apply redux-store-mock to solve the problem. For redux-store-mock, see https://github.com/dmitry-zaets/redux-mock-store.
We create a mocked store to test test the async action by dispatching it. The mocked store will execute the thunk function, with dispatch and getState injected. We mock the store state with data the thunk function needs by invoking createStore(). And we use the mocked store to dispatch the action such that dispatch will be injected into the thunk function. Finally, we inspect the store to validate the actions dispatched to the store. Here we don’t have to mock the reducer to handle actions, since we are testing the actions and we just need to do the least job to validate whether the thunk function works, we don’t have to do the work after the action is dispatched.

import { getFeedbackCount } from '@/request/tourClass';
// action creator
export const fetchFeedbackCount = () => async (dispatch, getStore) => {
  const {
    tourClass: {
      clazz: { voteId },
      selectedTeam: { id: teamId },
    },
  } = getStore();
  const data = await getFeedbackCount(voteId, teamId);
  dispatch({
  	type: TOUR_UPDATE_FEEDBACK_MAP,
	payload: {
	  ...data,
	},
  });
};

// test case
import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk';
jest.mock('@/requests/tourClazz');
import { getFeedbackCount, } from '@/requests/tourClazz';

const mockStore = configureMockStore([thunk]);
describe('action creator: fetchFeedbackCount', () => {
  it('should fetch and dispatch feedback', async () => {
    const store = mockStore({
      tourClass: {
        clazz: { voteId: 1 },
        selectedTeam: { id: 1 },
      }
    });
    getFeedbackCount.mockResolvedValue({});
    return store.dispatch(fetchFeedbackCount()).then(() => {
      const actions = store.getActions().map(action => action);
      expect(actions).toEqual([{
        type: types.TOUR_UPDATE_FEEDBACK_MAP,
        payload: {}
      }]);
    })
  })
});
Selectors

Selectors and memorized selectors are pure functions similar to reducer, therefore we could test it with input-output validation.

// selector.js
export const selectStudentMap = state => state.live.studentMap;
export const selectStudentList = createSelector(
  selectStudentMap,
  studentMap => Object.values(studentMap)
);
export const selectOnlineStudentCount = createSelector(
  selectStudentList,
  studentList => studentList.filter(student => student.isOnline).length
);

// selector.spec.js
describe('selector module: live', () => {
  it('selector: selectStudentMap', () => {
    const state = {
      live: { studentMap: {} }
    };
    expect(selectStudentMap(state)).toEqual({});
  });
  it('selector: selectStudentList', () => {
    const studentMap = { id: 'studentId' };
    const state = {
      live: { studentMap }
    };
    const newState = {
      live: { studentMap }
    };
    const list = selectStudentList(state);
    expect(list).toEqual(['studentId']);
    const newList = selectStudentList(newState);
    expect(newList).toBe(list);
  });
  it('selector: selectOnlineStudentCount', () => {
    const state = {
      live: {
        studentMap: {
          '1': { id: '1', isOnline: true },
          '2': { id: '2', isOnline: false }
        }
      }
    };

    const num  = selectOnlineStudentCount(state);
    expect(num).toEqual(1);
  });
})
Test representational component

Testing representational component could be annoying or comfortable, depending on what strategy you adopt. Some teams just validate the component’s data state, checking the component’s state and props and ignoring whether it’s rendered correctly, rendering result is counting on ui testing, a higher level testing than unit testing. In such way the development team feel stressless from testing and swallow view changes quickly. Other teams may believe that component’s testing should reach to the rendered react element tree or DOM tree level, therefore they will implement a lot of test cases and assertions on the output tree. Today Jest snapshot testing and test utils save a lot of efforts for development team which adopt rendered tree validation. We could adopt the suitable strategy based on the team’s capacity.
No matter what kind of strategy we adopt, we must solve problems in component testing:

  • Children components
  • styled components
  • Component lifecycle hooks testing
  • Event handler testing
  • Mocking other dependencies

For children components, shallow rendering come to rescue. With shallow rendering we could just render one-level deep of the component tree and ignore how its children components are rendered, focusing on the current component being test. Shallow rendering doesn’t really render HTML DOM in the memory, it just simulate what the current component’s render method return without interpreting descendant components.

// representational component
const RowContext = React.createContext({ gutter: 1 });
export const Layout = props => (
  <RowContext.Provider value={{ gutter: props.gutter }}>
    <LayoutStyle {...props}>{props.children}</LayoutStyle>
  </RowContext.Provider>
);

// test case
import React from 'react';
import Enzyme, { shallow, mount, render } from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
import 'jest-styled-components'
import { Layout } from './index';

Enzyme.configure({ adapter: new Adapter() });

test('component Layout should render with default props', () => {
	const wrapper = shallow(<Layout><span /></Layout>);
    const tree = wrapper.children();
    const props = wrapper.props();
    expect(props.value.gutter).toEqual(1);
    expect(props.children).not.toBeUndefined();
    expect(wrapper.find('span').length).toEqual(1);
});

For styled components, we apply jest-styled-component test utility to check the output style on the component.

import React from 'react';
import Enzyme, { shallow } from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
import 'jest-styled-components'
import { Layout } from './index';

Enzyme.configure({ adapter: new Adapter() });

test('component Layout should render with default props', () => {
	const wrapper = shallow(<Layout><span /></Layout>);
	const tree = wrapper.children();
	expect(tree).toHaveStyleRule('display', 'flex');
	expect(tree).toHaveStyleRule('margin-left', '-0.25rem');
	expect(tree).toHaveStyleRule('margin-right', '-0.25rem');
});

Limitations

  • When tested function depends on another function in the same module, we can not mock it with Jest since Jest mock the whole module. It might work if separating test cases.
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值