React + Redux 앱 테스트


지난 튜토리얼에서 우리는, 리액트 앱을 유닛 테스팅을 통해 모든것이 잘 작동하는지 검증하는 방법을 알아보았습니다. 이번 튜토리얼에서는, 한발 더 나아가 조금 더 복잡한 구조의 앱을 테스팅 할 수 있도록, 리덕스를 사용한 리액트 앱을 테스팅 하는 방법을 알아보겠습니다.

이 프로젝트에 사용된 코드는 react-test-tutorial 레포지토리의 redux 브랜치에서 확인 할 수 있습니다.

이 강의는 FastCampus 오프라인 강의 에서 사용된 자료이며 부연설명이 생략되어있습니다.

1. 리덕스 테스트 흐름

리덕스 테스트는 어떻게 이뤄질까?

지난번에 우리가 리액트 앱을 테스팅 할 때는, 컴포넌트의 렌더링 결과물을 기록하고 이전과 지금 결과물을 비교하는 스냅샷 테스팅을 하는 방법도 알아보았습니다. 그리고, 컴포넌트 인스턴스의 메소드를 직접 호출을 해서 상태가 우리가 원하는 방식대로 바뀌었는지 검증하기도 했었고, 혹은 Enzyme 을 사용하여 DOM 이벤트를 시뮬레이트하여 특정 DOM 이벤트에 따라 우리가 지정한 함수가 제대로 호출되는지도 확인해보았습니다.

자, 그러면 리덕스 테스트는 어떻게 이뤄질까요? 일단 리덕스를 사용하게 된다면, 다음 항목들을 테스트 하게 됩니다:

  • 액션 생성 함수
  • 리듀서
  • 컨테이너 컴포넌트

주어진 파라미터에 따라 우리가 원하는 액션 객체가 잘 만들어지는지 검증을 하구요, 특정 액션이 디스패치 되었을 때, 리듀서가 상태를 우리가 원하는 방식대로 업데이트 하는지 검증해야 합니다. 추가적으로, 이 모든것이 컴포넌트와 연동됐을 때도 잘 작동하는지 확인도 해주어야하죠.

여러분이 리액트 컴포넌트에 내장된 state 만을 사용하다가, 리덕스를 사용하게 되고, 그리고 또 리덕스에 익숙해졌을 때 어떤 점을 느꼈나요? 복잡한 상태 관리의 짐을 좀 덜어준다는 느낌을 조금이라도, 받으셨겠지요? 리덕스를 사용하게 되면서, 우리는 컨테이너 / 프리젠테이셔널 컴포넌트 구조를 사용하게 되면서, 프리젠테이셔널 컴포넌트를 만들 땐 단순히 컴포넌트가 어떻게 생겨야 할 지 구현하는 것에 더욱 집중 할 수 있게 됐고, 컨테이너 컴포넌트와 리듀서에서 상태 업데이트에 관련한 로직을 구현하는것에 집중 할 수 있게 됐었습니다.

테스트 코드 또한 마찬가지입니다. 상태를 위한 테스트와, 뷰를 위한 테스트를 분리하여 작성 할 수 있습니다.

따라서, 우리가 컴포넌트 테스트 코드를 작성 할 땐, props 가 전달되었을 때 올바르게 그려주는지 테스팅하고, 리덕스 관련 테스트 코드를 작성 할 땐, 우리가 만든 액션이 디스패치 됐을 때 상태가 원하는 형태로 변경되는지를 테스팅 해준다면, 모든 것이 잘 작동 할 것이라고 추측 할 수 있습니다.

하지만… 오류는 우리가 예상치 못했던 부분에서 발생하기 마련입니다. 이전에 했던 홍차 끓이기 비유를 다시 해보자면:

  1. 물 끓이기
  2. 티포트와 찻잔에 뜨거운물을 부어 데우기
  3. 찻잎 놓기
  4. 차 우리기
  5. 찻잔에 차 따르기

여러분이 이 1~5 에 대한 작업을 모두 제대로 할 줄 안다고 해서, 무조건 좋은 홍차가 나올 것이라고 보장할 수 있을까요? 꼭 그렇지는 않습니다. 예를 들어서 홍차의 찻잎을 우릴 때는 5분이 적당합니다. 어쩌다가, 5분 넘게 우리게 된다면 떫은 맛이 나서 결코 맛있는 홍차를 우릴 수 없습니다.

이렇게, 여러분이 예상치 못한 곳에서 에러가 발생 할 수도 있습니다. 따라서 우리는 한가지 테스트를 더 해주어야합니다:

1 ~ 5 까지 모두 진행하고나니 맛이 괜찮더라..!

이를 통합 테스팅 이라고 부릅니다. 유닛 테스트로 쪼갰던 모든 작업을 함께 하게 됐을 때 정말 잘 되는지 체크를 해주는 것이죠.

테스트를 어느 수준으로 해줘야 할까?

이건, 정해진 답이 없습니다. 저는 개인적으로, 방어적 프로그래밍을 한다면 테스트가 무조건 필요하지는 않다고 생각합니다. 다만, 테스트를 한다면 코드를 더욱 확실하게, 탄탄하게 해줍니다. 리액트 + 리덕스 앱의 테스트 커버리지는, 다음과 같이 하면 이상적이라고 생각합니다.

  1. 프리젠테이셔널 컴포넌트, props 에 따라 제대로 보여지는지 테스트
  2. 액션 생성함수, 우리가 의도했던 대로 액션을 잘 만들어주는지 테스트
  3. 리듀서, 상태와 액션을 전달해주면, 의도했던 대로 업데이트를 해주는지 테스트
  4. 컨테이너 컴포넌트, 통합 테스팅을 통하여 모든게 잘 되는지 테스트

하지만, 이상적일 뿐입니다. 회사에서 진행하는 프로젝트의 경우, 현실적으로 생각했을 때, 일정과 레거시 코드 등이 존재한다면 이상적인 형태를 인지하고 있어도 그 이상에 다가가기 힘들 때가 있습니다. – 저도 그렇습니다… ;( , 이는 시간과 여유가 해결해주는 문제입니다.

테스트를 작성한다는 것은, 개발 프로세스에 결국 한 단계가 더 추가 되는 것이기 때문에 개발 진행도를 아주 조금은 늦출 수는 있습니다 – 하지만 나중에 오류 때문에 고생 할 시간을 아껴주긴 합니다. 인력과 시간이 충분하다면 모든걸 하는게 맞긴 하겠지만, 그렇지 않을 경우엔 저는 다음과 같은 수준이 현실적 이라고 생각합니다:

  1. 프리젠테이셔널 컴포넌트 테스트, 이것은 사실 그렇게 공수가 크지 않습니다. (대부분의 경우)
  2. 액션 생성 함수, 저는 이 부분은 거의 테스트를 진행하지 않습니다. 조금 불필요하다고 생각합니다. 특히 FSA 를 따른다면 더더욱 그렇죠. (redux-actions 의 createAction 을 통해 액션을 만들게 되면 형태는 다 같습니다. 다양한 key 가 있는 것이 아니라, payload, error, meta 등의 키로 통일되어있죠.) 그 대신에, 비동기 액션 함수들은 테스팅을 해줍니다.
  3. 리듀서, 모든 액션이 다 제대로 작동하는지 확인해줍니다. 리듀서를 테스트하는것은 그렇게 복잡하지 않기 때문에 금방 할 수 있습니다.
  4. 시간이 많이 없다면, 그리고 1, 2, 3 번이 제대로 되어있다면, 통합 테스트의 경우 조금 간소하게 해줄 수도 있습니다. 예를 들어서, 컨테이너 컴포넌트의 버튼이 클릭 됐을 때, 스토어에 값이 제대로 바뀌었는지까지는 확인하지 않고, 그저 우리가 예상한 액션이 디스패치 되었는지만 확인해도 됩니다. 어짜피, 상태 관리의 영역은 리덕스 부분이니까요. 컴포넌트의 경우엔 주어진 값만 제대로 렌더링 하게 하면 됩니다. 물론 더욱 더 확실하게 하고 싶다면 상태값까지 모두 잘 바뀌는지 검증하는것도 나쁘지는 않습니다. 하지만 공수가 조금 더 들어갈뿐이죠.

테스트를 작성하게 될 때는, 진행 방향이 각자 다를 수 있겠지만 저는 일정이 타이트 할 경우 중요한 로직만 미리 테스트 해두고, 틈틈히 채워 나가는 것을 선호한답니다.

결국엔, 여러분, 그리고 여러분이 협업하게 될 팀의 재량 껏 하시면 됩니다 🙂

자, 앞으로 이어질 튜토리얼에서는, 다양한 방식으로 리액트 + 리덕스 앱을 테스팅 하는 방법을 다뤄보겠습니다.

2. 프로젝트 준비하기

우선, 우리가 테스트를 진행 할 프로젝트가 필요하겠죠. 테스트용 프로젝트를 만드는 시간이 아까우니, 제가 미리 준비해놓은, 이전의 튜토리얼 테스트에서 진행했던 프로젝트에 리덕스를 적용시킨 코드를 사용하여 구현해보겠습니다.

$ git clone https://github.com/vlpt-playground/react-test-tutorial.git
$ git checkout redux-testless

튜토리얼이 진행되기 전에 이전에 프로젝트에서 어떠한 변화가 있었는지 간단하게 짚고 넘어가겠습니다.

  • NameForm, Counter, App 컴포넌트에서 더 이 상 state 를 사용하지 않게 됐습니다.
  • 위에 따라, 해당 컴포넌트들의 테스트 코드들 또한 스냅샷만 찍도록, 수정해주었습니다.
  • API 요청을 통해 받아온 데이터를 보여줄 Post 컴포넌트를 만들었습니다.
  • 리덕스 모듈 세가지: counter, names, post 를 만들었습니다.
  • 리덕스 스토어 생성 할 땐, thunk 미들웨어를 적용해주었습니다.
  • 이에 따른 세가지 컨테이너 컴포넌트: CounterContainer, NamesContainer, PostContainer 가 만들어졌습니다.

우리가, 상태 관련 로직을 리듀서쪽으로 올길 것이기에, 기존에 작성했던 테스트 함수들을 대부분 날려주었습니다.

계속 진행하기전에, 각 리덕스 모듈들이 어떠한 역할을 하는지 알아보겠습니다.

store/modules/counter.js

const INCREASE = 'counter/INCREASE';
const DECREASE = 'counter/DECREASE';

export const increase = () => ({ type: INCREASE });
export const decrease = () => ({ type: DECREASE });

const initialState = {
  number: 0
};

export default function counter(state = initialState, action) {
  switch (action.type) {
    case INCREASE:
      return { number: state.number + 1 };
    case DECREASE:
      return { number: state.number - 1 };
    default:
      return state;
  }
}

단순히, INCREASE, ACTION 액션에 따라 number 값을 더하고, 빼줍니다. 이 모듈에서는, createAction 을 사용하지 않고 액션 생성함수를 직접 작성해주었습니다.

store/modules/names.js

import { createAction, handleActions } from 'redux-actions';

const CHANGE_INPUT = 'names/CHANGE_INPUT';
const INSERT = 'names/INSERT';

export const changeInput = createAction(CHANGE_INPUT, input => input);
export const insert = createAction(INSERT, name => name);

const initialState = {
  input: '',
  names: []
};

export default handleActions({
  [CHANGE_INPUT]: (state, { payload: input }) => ({
    ...state,
    input
  }),
  [INSERT]: (state, { payload: name }) => ({
    ...state,
    names: state.names.concat(name),
  })
}, initialState);

이 모듈에는 두가지 액션이 있습니다. 하나는, 인풋 값을 수정해주는 것이고, 또 하나는 names 배열에 새 아이템을 넣어주는 것 입니다. 불변성을 유지해야 하기에, concat 을 사용해주었습니다.

store/modules/post.js

import { createAction, handleActions } from 'redux-actions';
import axios from 'axios';

function getPostAPI(postId) {
  return axios.get(`http://jsonplaceholder.typicode.com/posts/${postId}`);
}

const GET_POST_PENDING = 'post/GET_POST_PENDING';
const GET_POST_SUCCESS = 'post/GET_POST_SUCCESS';
const GET_POST_FAILURE = 'post/GET_POST_FAILURE';

export const getPost = (postId) => async (dispatch) => {
  dispatch({ type: GET_POST_PENDING });
  try {
    const response = await getPostAPI(postId);
    dispatch({ type: GET_POST_SUCCESS, payload: response });
    return response;
  } catch (e) {
    dispatch({ type: GET_POST_FAILURE, payload: e });
  }
}

const initialState = {
  fetching: false,
  error: false,
  title: '',
  body: '',
};

export default handleActions({
  [GET_POST_PENDING]: (state) => ({ ...state, fetching: true, error: false }),
  [GET_POST_SUCCESS]: (state, { payload: { data } }) => ({ ...state, fetching: false, title: data.title, body: data.body }),
  [GET_POST_FAILURE]: (state) => ({ ...state, fetching: false, error: true })
}, initialState);

비동기 액션을 관리하기 위해서 여러가지 미들웨어들이 있습니다. 그 중 가장 기본적이라고 볼 수 있는 thunk 를 사용하여 jsonplaceholder 에 API 요청을 처리해주었습니다. 우리는 나중에, Post 컴포넌트 내부에 있는 LoadMe 가 적혀있는 버튼을 누르면 이 API 를 요청하고, 화면에 뿌려주도록 하겠습니다.

각 모듈들이 어떤 형식으로 작성하는지 감이 잡히시나요? (잘 모르겠다면, 아마, 리덕스쪽 코드를 조금 복습해야 할 것 같습니다.)

그럼, 슬슬 각 기능들을 위한 테스트 코드를 작성해주겠습니다.

3. 카운터 테스팅

카운터는, 숫자가 올라가고, 내려가죠. 정말로 간단한 로직입니다.

액션 생성 함수 테스팅

우선, 테스트 파일을 생성하고, 액션 생성 함수들이 모두 제대로 작동하는지 확인해줍니다.

src/store/modules/counter.test.js

import counter, * as counterActions from './counter';

describe('counter', () => {
  describe('actions', () => {
    it('should create actions', () => {
      const expectedActions =[
        { type: 'counter/INCREASE' },
        { type: 'counter/DECREASE' },
      ];
      const actions = [
        counterActions.increase(),
        counterActions.decrease(),
      ];
      expect(actions).toEqual(expectedActions);
    });
  });
});

리듀서 함수와, 액션 생성 함수를 import 해온 다음에, 우선 액션 생성 함수부터 검증해주었습니다. 여기서 하단의 toEqual 의 경우엔, expect 에 전달해준 객체와 actions 와, 우측의 expectedActions 객체의 내부 값들이 모두 일치하는지를 확인해줍니다.

리듀서 테스팅

그럼, 리듀서도 테스팅 해줍시다. 리듀서의 경우엔, 초기 상태 설정이 잘 되어있는지 확인하고, 각 액션이 디스패치 됐을 때의 결과물을 검증합니다.

src/store/modules/conter.test.js

import counter, * as counterActions from './counter';

describe('counter', () => {
  describe('actions', () => {
    it('should create actions', () => {
      const expectedActions =[
        { type: 'counter/INCREASE' },
        { type: 'counter/DECREASE' },
      ];
      const actions = [
        counterActions.increase(),
        counterActions.decrease(),
      ];
      expect(actions).toEqual(expectedActions);
    });
  });
  describe('reducer', () => {
    let state = counter(undefined, {});
    it('should return the initialState', () => {
      expect(state).toHaveProperty('number', 0);
    });

    it('should increase', () => {
      state = counter(state, counterActions.increase());
      expect(state).toHaveProperty('number', 1);
    });

    it('should decrease', () => {
      state = counter(state, counterActions.decrease());
      expect(state).toHaveProperty('number', 0);
    });
  })
});

프리젠테이셔널 컴포넌트 테스팅

이제는 Counter.js 컴포넌트를 테스팅 해주겠습니다. 이미 스냅샷 테스팅은 되어있는데요, props 가 전달 됐을 때, 정말 잘 나타나는지 한번 더 검증을 해주겠습니다.

src/components/Counter.test.js

import React from 'react';
import { shallow } from 'enzyme';
import Counter from './Counter';

describe('Counter', () => {
  let component = null;

  it('renders correctly', () => {
    component = shallow(<Counter value={700}/>);
  });

  it('matches snapshot', () => {
    expect(component).toMatchSnapshot();
  });

  it('is 700', () => {
    expect(component.find('h2').at(0).text(), '700');
  })
});

value props 를 700 으로 건네주고, 렌더링 결과물의 h2 안에 700이 있는지 검증했습니다.

두번째로 테스팅 해주어햐 하는것은, props 로 전달해줄 onIncrease 와 onDecrease 가 제대로 작동하는지 입니다.

우리가 함수를 임의로 따로 만들어서 전달해준다음에 확인할 수도 있겠지만, Jest 에는 함수가 호출됐는지 확인하기 위한 fn 이라는 도구가 있는데 매우 유용합니다.

src/components/Counter.test.js

import React from 'react';
import { shallow } from 'enzyme';
import Counter from './Counter';

describe('Counter', () => {
  let component = null;
  const mockIncrease = jest.fn();
  const mockDecrease = jest.fn();

  it('renders correctly', () => {
    component = shallow(
      <Counter value={700} onIncrease={mockIncrease} onDecrease={mockDecrease}/>
    );
  });

  it('matches snapshot', () => {
    expect(component).toMatchSnapshot();
  });

  it('is 700', () => {
    expect(component.find('h2').at(0).text(), '700');
  });

  it('calls functions', () => {
    const buttons = component.find('button');
    buttons.at(0).simulate('click');
    buttons.at(1).simulate('click');
    expect(mockIncrease.mock.calls.length).toBe(1);
    expect(mockDecrease.mock.calls.length).toBe(1);
  });
});

각 버튼을 클릭하면, mockIncrease 와 mockDecrease 가 호출 될 것이고, 해당 함수는 호출이 되면 함수명.mock.calls.length) 의 값이 1씽 올라가게 된답니다.

컨테이너

이제 컨테이너 컴포넌트의 테스트 코드를 작성해봅시다. 우선, 이를 진행하기 위해선 redux-mock-store 를 설치해주어야 합니다. 이 라이브러리는, 가짜 스토어를 만들어서, 특정 액션이 디스패치됐는지 안됐는지 판별하는것을 쉽게 해줍니다.

$ yarn add redux-mock-store

그 다음에는, 컨테이너 컴포넌트를 렌더링해주겠습니다.

src/containers/CounterContainer.js

import React from 'react';
import { mount } from 'enzyme';
import CounterContainer from './CounterContainer';
import configureMockStore from 'redux-mock-store';
import * as counterActions from '../store/modules/counter';

describe('CounterContainer', () => {
  let component = null;
  let buttons = null;
  const mockStore = configureMockStore();

  // 데이터들을 받아올 가짜 스토어 만들기
  let store = mockStore({
    counter: {
      number: 0
    }
  });

  it('renders properly', () => {
    const context = { store };
    component = mount(<CounterContainer />, { context });
    // 혹은 component = mount(<CounterContainer store={store} />);
  });

  it('matches snapshot', () => {
    expect(component).toMatchSnapshot();
  });
});

컨테이너 컴포넌트를 렌더링 할 때에는, 를 사용하는 대신, 위 처럼 context 에 store 를 넣어주거나, props 에 store 를 넣어주시면 됩니다.

그 다음에는, 각 버튼 클릭을 시뮬레이트 해보고, 액션이 잘 디스패치 됐는지 확인해보겠습니다.

src/containers/CounterContainer.js

import React from 'react';
import { mount } from 'enzyme';
import CounterContainer from './CounterContainer';
import configureMockStore from 'redux-mock-store';
import * as counterActions from '../store/modules/counter';

describe('CounterContainer', () => {
  let component = null;
  let buttons = null;
  const mockStore = configureMockStore();

  // 데이터들을 받아올 가짜 스토어 만들기
  let store = mockStore({
    counter: {
      number: 0
    }
  });

  it('renders properly', () => {
    const context = { store };
    component = mount(<CounterContainer />, { context });
    // 혹은 component = mount(<CounterContainer store={store} />);
  });

  it('matches snapshot', () => {
    expect(component).toMatchSnapshot();
  });

  it('dispatches INCREASE action', () => {
    component.find('button').at(0).simulate('click');
    expect(store.getActions()[0]).toEqual(counterActions.increase());
  });

  it('dispatches DECREASE action', () => {
    component.find('button').at(1).simulate('click');
    expect(store.getActions()[1]).toEqual(counterActions.decrease());
  });
});

스토어에 액션이 디스패치 되면, 디스패치된 액션들의 목록을 store.getActions() 를 통하여 조회 할 수 있습니다. store.getActions() 의 반환되는 형태가 궁금하신가요? 마지막 테스트코드를 다음과 같이 수정해보세요:

  it('dispatches DECREASE action', () => {
    component.find('button').at(1).simulate('click');
    expect(store.getActions()[1]).toEqual(counterActions.decrease());
    console.log(store.getActions());
  });

확인 했다면, 방금 작성했던 console.log 코드를 지워주세요

자, 이제 카운터를 위한 모든 테스트 코드가 작성됐습니다. 이번 테스트 코드들은, 다음 흐름으로 진행되었습니다.

  1. 액션 생성 함수들이 액션을 잘 만드는가?
  2. 리듀서가 상태 변화를 제대로 일으키는가?
  3. 컴포넌트는 제대로 렌더링 되는가?
  4. 버튼이 클릭 됐을 때, 실제로 액션이 디스패치 되는가?

—> 이게 모두 다 된다면, 잘 되는 것이다!

이제, 다음으로 넘어가봅시다!

4. 이름 목록 테스팅

이번에는, Names 관련 기능을 위한 테스트 코드를 작성해보겠습니다.

리듀서 작성

아까와 같은 원리로, 리듀서를 위한 테스트 코드를 작성해보겠습니다. 이번에 작성 할 코드의 차이점은, 액션 생성 함수 테스트 부분을 그냥 toMatchSnapshot 을 통하여 비교해주었습니다. 스냅샷은, 꼭 컴포넌트 결과물이 아니여도, 뭐든지 비교 할 수 있습니다. 당연히, 리듀서의 상태 또한 비교하고 싶다면 할 수 있죠.

src/store/modules/names.test.js

import names, * as namesActions from './names';

describe('names', () => {
  describe('actions', () => {
    const actions = [
      namesActions.changeInput('input'),
      namesActions.insert('name')
    ];
    it('should create actions', () => {
      expect(actions).toMatchSnapshot();
    })
  });

  describe('reducer', () => {
    let state = names(undefined, {});
    it('should return the initialState', () => {
      expect(state).toEqual({
        input: '',
        names: []
      });
    });

    it('should change input', () => { 
      state = names(state, namesActions.changeInput('hello'));
      expect(state).toHaveProperty('input', 'hello');
    });

    it('should insert', () => {
      state = names(state, namesActions.insert('hello'));
      expect(state.names).toEqual(['hello']);
      state = names(state, namesActions.insert('world'));
      expect(state.names).toEqual(['hello', 'world']);
    });
  });
});

코드가 아까보다 조금 더 많긴 하지만, 원리는 똑같으니, 새로 보는 코드는 딱히 없지요?

NameForm 테스트 작성하기

이번에는, NameForm 을 위한 테스트 코드를 작성해주겠습니다. 이 또한 이전에 했던 것과 비슷합니다. 클릭 대신 change 와 submit 이벤트를 발생시킬 뿐이죠.

src/components/NameForm.test.js

import React from 'react';
import { shallow } from 'enzyme';
import NameForm from './NameForm';

describe('NameForm', () => {
  let component = null;
  const mockChange = jest.fn();
  const mockSubmit = jest.fn();

  it('renders correctly', () => {
    component = shallow(
      <NameForm
        onChange={mockChange}
        onSubmit={mockSubmit}
        value="hello"
      />
    );
  });

  it('matches snapshot', () => {
    expect(component).toMatchSnapshot();
  });

  it('shows valid input value', () => {
    expect(component.find('input').props().value).toBe('hello');
  });

  it('calls onChange', () => {
    const mockedEvent = {
      target: {
        value: 'world'
      }
    };
    component.find('input').simulate('change', mockedEvent);
    expect(mockChange.mock.calls.length).toBe(1);
  })

  it('calls onSubmit', () => {
    component.find('form').simulate('submit');
    expect(mockSubmit.mock.calls.length).toBe(1);
  });
});

NameList 의 경우엔, 따로 기능이 있진 않으니, 그대로 두셔도 됩니다. 이미 스냅샷 테스팅이 되고있으니까요.

컨테이너 컴포넌트 만들기

이번에는, 한번 통합테스트를 해보겠습니다. 가짜 스토어가 아닌, 실제 스토어릁 넣어서, 액션이 디스패치 되고, 또 상태 값까지 제대로 변경시켜주는지 확인을 해보겠습니다.

src/containers/NameContainer.test.js

import React from 'react';
import { mount } from 'enzyme';
import NamesContainer from './NamesContainer';
import configureStore from '../store/configureStore';

describe('NamesContainer', () => {
  let component = null;
  let buttons = null;

  // 이번엔 실제 store 로 테스팅을 합니다.
  let store = configureStore();

  const context = { store };

  it('renders properly', () => {
    component = mount(<NamesContainer />, { context });
  });

  it('matches snapshot', () => {
    expect(component).toMatchSnapshot();
  });

  it('dispatches CHANGE_INPUT action', () => {
    const mockedEvent = {
      target: {
        value: 'world'
      }
    };
    component.find('input').simulate('change', mockedEvent);
    expect(store.getState().names.input).toBe('world');
  });

  it('dispatches INSERT action', () => {
    component.find('form').simulate('submit');
    expect(store.getState().names.names).toEqual(['world']);
  });

});

NamesContainer 도 테스팅을 모두 마쳤습니다! 유닛 테스트도 잘 되고, 통합 테스트도 잘 되니, 이 컴포넌트는 지금으로선 가히 완벽하다고 볼 수 있겠지요.

5. 비동기 작업 테스트

대부분의 웹어플리케이션은 Ajax 요청을 합니다. 이러한 비동기 작업 또한 테스트를 해줄 수 있습니다. 기본적인 방법으로는, 테스트 과정에서 로직에서 사용하는 실제 주소에 HTTP 요청을 날렸다가, 기다린다음에 잘 됐는지 확인하는 방법이 있는데, 딱히 효율적이진 않습니다. – 네트워크를 통해서 데이터를 가져오게 된다면, 상황에 따라 서버의 값이 바뀔 수도 있고, 딜레이도 있습니다. 그러면, API 를 요청하는 테스트가 늘어날수록, 테스트도 오래 걸리게 되겠죠.

그 대신에, 우리는 nock 이라는 (혹은 비슷한 류의) 라이브러리를 사용합니다. 이 라이브러리는, 우리가 사전에 정해준 주소로 요청을 하게 되었을 때, HTTP 요청을 가로채서, 네트워크 요청을 실제로 넣지 않고 우리가 원하는 데이터가 바로 나오도록 설정해줄수있습니다.

우선 이 라이브러리를 설치해주세요.

$ yarn add nock

그리고, axios 의 어댑터를 http 로 설정해주어야 합니다. setupTests.js 를 다음과 같이 수정하세요. (그래야 nock이 가로챌수있습니다)

src/setupTests.js

import { configure } from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
import http from 'axios/lib/adapters/http';
import axios from 'axios';

axios.defaults.adapter = http;
configure({ adapter: new Adapter() });

액션 테스트

우선, thunk 가 제대로 작동하는지 (요청이 시작했을때, 실패했을 때, 성공했을때 모든게 잘 되는지) 확인을 해보겠습니다.

src/store/modules/post.test.js

import post, { getPost } from './post';
import nock from 'nock';
import thunk from 'redux-thunk';
import configureMockStore from 'redux-mock-store';

describe('post', () => {
  describe('actions', () => {
    const store = configureMockStore([thunk])();
    it('getPost dispatches proper actions', async () => {
      nock('http://jsonplaceholder.typicode.com')
        .get('/posts/1').once().reply(200, {
          title: 'hello',
          body: 'world'
        });
      await store.dispatch(getPost(1));
      expect(store.getActions()[0]).toHaveProperty('type', 'post/GET_POST_PENDING');
      expect(store.getActions()[1]).toHaveProperty('type', 'post/GET_POST_SUCCESS');
      expect(store.getActions()).toMatchSnapshot();
    });
    it('fails', async () => {
      store.clearActions(); // 기존 액션 비우기
      nock('http://jsonplaceholder.typicode.com')
      .get('/posts/0').once().reply(400);
      try {
        await store.dispatch(getPost(0));
      } catch (e) {

      }
      expect(store.getActions()).toMatchSnapshot();
    });
  });
});

리듀서 테스트

리듀서가 제대로 작동하는지 테스트 하기 위하여, 리듀서 함수를 직접 호출하는 대신, 스토어를 만들어서 실제로 변화가 제대로 이뤄지는지 검증해보겠습니다.

src/modules/post.test.js

import post, { getPost } from './post';
import nock from 'nock';
import thunk from 'redux-thunk';
import configureMockStore from 'redux-mock-store';
import configureStore from '../configureStore';

describe('post', () => {
  describe('actions', () => {
    const store = configureMockStore([thunk])();
    it('getPost dispatches proper actions', async () => {
      nock('http://jsonplaceholder.typicode.com')
        .get('/posts/1').once().reply(200, {
          title: 'hello',
          body: 'world'
        });
      await store.dispatch(getPost(1));
      expect(store.getActions()[0]).toHaveProperty('type', 'post/GET_POST_PENDING');
      expect(store.getActions()[1]).toHaveProperty('type', 'post/GET_POST_SUCCESS');
      expect(store.getActions()).toMatchSnapshot();
    });
    it('fails', async () => {
      store.clearActions(); // 기존 액션 비우기
      nock('http://jsonplaceholder.typicode.com')
      .get('/posts/0').once().reply(400);
      try {
        await store.dispatch(getPost(0));
      } catch (e) {

      }
      expect(store.getActions()).toMatchSnapshot();
    });
  });

  describe('reducer', () => {
    const store = configureStore();
    it('should process getPost', async () => {
      nock('http://jsonplaceholder.typicode.com')
        .get('/posts/1').once().reply(200, {
          title: 'hello',
          body: 'world'
        });
        await store.dispatch(getPost(1));
        expect(store.getState().post.title).toBe('hello');
    });
  });
});

컨테이너 컴포넌트 테스트

자! 마지막 테스트 코드를 작성해보겠습니다. 이번 테스트 코드에서도 실제 스토어를 사용하구요, 컴포넌트를 렌더링하고, 내부에 있는 버튼을 클릭하겠습니다.

src/containers/PostContainer.js

import React from 'react';
import { mount } from 'enzyme';
import PostContainer from './PostContainer';
import configureStore from '../store/configureStore';
import nock from 'nock';
import { Provider } from 'react-redux';

describe('PostContainer', () => {
  let component = null;
  const store = configureStore();
  const context = { store };

  it('renders correctly', () => {
    component = mount(
      <Provider store={store}>
        <PostContainer />
      </Provider>
    );
  });

  it('fetches and updates', async () => {
    nock('http://jsonplaceholder.typicode.com')
      .get('/posts/1').once().reply(200, {
        title: 'hello',
        body: 'world'
      });
    component.find('button').simulate('click');
  });
});

우리가 이전에 리듀서를 테스트 할 땐, getPost 를 직접 호출했었기 때문에 await 을 할 수있었는데요,
지금의 경우, 버튼에 클릭 이벤트를 시뮬레이트를 했을 때, 딱히 getPost 가 반환하는 Promise 에 접근 할 방법이 없습니다.

그 대신에, 스토어의 subscribe 기능을 활용해서, 새로운 Promise 를 만들고, subscribe 를 통하여 새 액션이 디스패치 됐을 때 resolve 를 하도록 합니다. (subscribe 함수의 파라미터에 우리가 만든 함수를 정해주면, 새 액션이 디스패치 될 때마다 파라미터로 넣어준 함수가 호출됩니다.)

src/containers/PostContainer.js

import React from 'react';
import { mount } from 'enzyme';
import PostContainer from './PostContainer';
import configureStore from '../store/configureStore';
import nock from 'nock';
import { Provider } from 'react-redux';

describe('PostContainer', () => {
  let component = null;
  const store = configureStore();
  const context = { store };

  it('renders correctly', () => {
    component = mount(
      <Provider store={store}>
        <PostContainer />
      </Provider>
    );
  });

  it('fetches and updates', async () => {
    nock('http://jsonplaceholder.typicode.com')
      .get('/posts/1').once().reply(200, {
        title: 'hello',
        body: 'world'
      });
    component.find('button').simulate('click');
    const waitForNextAction = new Promise(resolve => {
      const unsubscribe = store.subscribe(() => {
        resolve();
        unsubscribe();
      });
    });
    await waitForNextAction;
    expect(component.find('h2').text()).toBe('hello');
    expect(component.find('p').text()).toBe('world');
  });
});

다 끝났습니다! 축하합니다. 이제 여러분들도, 튼튼한 코드를 작성 할 준비가 끝났습니다 🙂

Reference

  • Web Dev

    Hi Kim.

    This blog got a typo in the middle.

    src/containers/CounterContainer.js should be src/containers/CounterContainer.test.js

  • Web Dev

    And PostContainer.js, too.