[웹개발] Redux-saga 테스트 코드 작성하기

in #kr-dev6 years ago

redux-saga.png

Redux-sagaRedux에서 비동기 액션을 핸들링하기 위한 다양한 라이브러리 중 하나이다. 전형적인 Callback 스타일, Observable을 활용한 라이브러리 등이 있지만 Redux-saga는 ES2015의 Generator Function를 적극적으로 활용했다. 덕분에 훌륭한 테스트 용이성 제공하고 있다.

*이 포스팅은 Redux-saga에 대한 기본적인 지식을 전제한다

Redux-saga의 무엇이 테스트를 쉽게 만드나

실제적인 테스트 코드 작성 방법을 다루기 전에, Redux-saga의 어떤 요소가 테스트를 용이하게 만드는지 간략하게 정리해보겠다.
이 내용은 공식 문서에도 정리가 되어있으므로, 큰 개념만 설명하고 넘어가도록 한다.

Saga는 기본적으로 비동기 액션을 모니터링하는 Watcher Saga와 실제로 비동기 작업을 수행하는 Worker Saga로 구성된다.
그리고 Saga들을 실제적으로 핸들링하여 Redux(Store)와 연결하는 Saga Middleware(이하 미들웨어)가 있다.

그 중 Worker Saga는 yield 표현을 이용하여 비동기로 특정 객체를 반환한다.
미들웨어는 (Worker) Saga가 반환하는 Promise나 단순 Object를 핸들링 할 수 있다. 여기서 Promise는 axios.get(...) 이나 $.get(...) 등과 같은 비동기 작업을 하는 API를 호출한 결과로 반환되는 Promise를 의미한다.
문제는 Promise 객체는 테스트가 어렵다는 점이다.

여기서 미들웨어가 핸들링하는 두 번째 타입인 단순 Object가 등장한다.
해당 Object는 미들웨어로 하여금 특정 작업을 수행하도록 가이드의 역할을 한다. 위에 Promise의 예시와 비교하면 아래와 유사한 객체를 반환하는 셈이다.

{ 
  CALL: {
    fn: axios.get,
    args: [...]
  }
}


첫 번째 시나리오와 대조적으로 Promise를 반환하는 실제 API 호출이 미들웨어로 위임되는 것이다. 미들웨어는 해당 객체를 받아서 적절한 작업을 수행하겠지만, 이 단계를 사용자(개발자)가 신경쓰지 않아도 된다.
이것을 공식 문서에서는 (사이드)이펙트 생성과 (사이드)이펙트 실행의 분리라고 표현했다.

Separation between Effect creation and Effect excution

사용자는 이러한 분리 덕분에 이펙트가 제대로 생성되었는지(가이드 객체가 제대로 반환되었는지)만 테스트하면 되며, 단순 객체의 테스트는 Promise 객체 테스트보다 훨씬 용이하다.

무엇을 테스트 하는가

위에서 언급한 Worker Saga는 경우에 따라 상이하나 일반적으로는 아래와 같은 형태를 가진다.

export function* handleFetchFoo(action) {
  const { id, otherOption } = action.payload;

  try {
    yield put(fooActions.setIsFetching(true));
    const foo = yield call(api.fetchFoo, {
      id: id,
      ...otherOption
    });

    const fooObject = {
      wrappedWithObject: true,
      foo: foo
    }; // foo를 조작하는 로직

    yield put(fooActions.fetchFooSuccess(fooObject));
  } catch (error) {
    yield put(fooActions.fetchFooFailure(error));
  }
  yield put(fooActions.setIsFetching(false));
}

위의 코드를 테스트 가능한(해야할) 작은 조각으로 하나씩 풀어보도록 하자.

yield put(fooActions.setIsFetching(true));

비동기 작업(HTTP Request)을 시작하기 전에 동기 액션을 Dispatch 한다. 여기서는 로딩 인디케이터 따위를 표시하기 위한 정보를 업데이트 한다.

const foo = yield call(api.fetchFoo, {
  id: id,
  {...otherOption}
});

fetchFoo를 위한 (비동기)이펙트를 생성한다. 두 번째 인자로 fetchFoo 함수와 함께 호출할 인자를 넘긴다.

yield put(fooActions.fetchFooSuccess(fooObject));

예외가 발생하지 않는다면 fetchFoo가 성공했다는 동기 액션을 Dispatch 한다.

catch(error) {
  yield put(fooActions.fetchFooFailure(error));
}

try 구문 안의 블록을 실행하다 예외가 발생한 경우 즉시 fetchFoo가 실패했다는 동기 액션을 Dispatch 한다.

yield put(fooActions.setIsFetching(false));

상기 조건과 관계없이 비동기 작업이 종료되었다는 정보를 업데이트 한다.

이 흐름이 바로 우리가 Redux-saga를 테스트 할 때 검사해야 할 주요 지점이다.
이 지점들의 공통점으로는 모두 yield 표현을 사용했다는 점이며, yield 표현을 통해서 반환된 값을 테스트 하는 방식으로 진행하게 된다.

테스트 코드

describe('Foo Saga', () => {
  describe('handleFetchFoo', () => {
    it('should fetch and set foo successfully', () => {
      const id = 'id1';
      const otherOption = {};
      const iterator = handleFetchFoo(
        fetchFoo(id, otherOption)
      );

      expect(iterator.next().value).toEqual(
        put(setIsFetching(true))
      );

      expect(iterator.next().value).toEqual(
        call(api.fetchFoo, {
          id: id,
          ...otherOption
        })
      );

      expect(iterator.next({ value: 'foo' }).value).toEqual(
        put(fetchFooSuccess({ wrappedWithObject: true , foo: { value: 'foo' } }))
      );

      expect(iterator.next().value).toEqual(
        put(setIsFetching(false))
      );

      expect(iterator.next().done).toBeTruthy();
    });
  });
});

기본적으로 Saga의 테스트는 Generator Function으로부터 생성된 Iterator가 순차적으로 yield 구문을 만나서 반환하는 값을 확인하는 방법으로 진행된다.

expect(iterator.next().value).toEqual(
  put(setIsFetching(true))
);

첫 번째 yieldsetIsFetching 액션을 true 값으로 제대로 반환하는지 테스트 한다. (엄밀하게 말하면 해당 액션을 처리하도록 가이드하는 객체)

expect(iterator.next().value).toEqual(
  call(api.fetchFoo, {
    id: id,
    ...otherOption
  })
);

두 번째 yield가 비동기 작업에 대한 이펙트를 제대로 생성/반환하였는지 테스트 한다.

expect(iterator.next({ value: 'foo' }).value).toEqual(
  put(fetchFooSuccess({ wrappedWithObject: true , foo: { value: 'foo' } }))
);

비동기 작업이 성공한 경우, next 메소드에 특정 값을 전달하여 호출하고 그 값이 fetchFooSuccess를 통해 제대로 처리되었는지 테스트 한다.

특히 이 부분이 Redux-saga 테스트 용이성을 단적으로 드러내는 부분이다.
Redux-thunk같은 라이브러리를 사용할 경우, 일반적인 비동기 작업을 테스트하기 위해서는 Sinon.js등을 활용하여 비동기 작업을 수행하는 메소드가 반환할 값을 Stubbing 해주거나 해당 메소드 자체를 Mock으로 만들어야한다.

하지만 Redux-saga는 Generator Function의 yield 구문을 통해서 Stub 없이 원하는 값을 주입할 수가 있다.
이 테스트 코드는 해당 값을 주입하고, Worker Saga 안에서 그 값이 다른 객체로 한번 더 감싸졌는지 검사한다.

expect(iterator.next().value).toEqual(
  put(setIsFetching(false))
);

첫 번째 테스트와 마찬가지로 setIsFetching 이펙트를 제대로 생성하였는지 테스트 한다.

expect(iterator.next().done).toBeTruthy();

해당 Worker Saga로 부터 생성된 Iterator가 종결되었는지 테스트 한다.

Saga의 세부적인 과정들을 쪼개어 테스트하는 이 방법은 유용하지만, 개발자가 임의로 값을 주입할 수 있는 지점이 많아 질 경우 테스트 자체가 불안정(Brittle)해질 가능성이 있다. 테스트 코드에서 임의로 주입한 값으로 인해서 실제로 소스 코드가 변경되었음에도 테스트가 실패하지 않는 경우가 그런 것이다.

이에 대한 보완책으로 공식 문서에서는 하나의 Saga를 처음부터 끝까지 실행시킨 후, 수반되는 (사이드)이펙트들을 한번에 검사하는 방법을 제시한다.
이 경우 외부 API를 호출하는 부분만 Sinon 등으로 Stub을 만들고, 나머지 부분은 소스 코드 원형 그대로 테스트에 통과시키는 것이다.

describe('whole saga', () => {
  afterEach(() => {
    sinon.restore();
  });

  it('should handle success case', async () => {
    sinon.stub(api, 'fetchFoo').callsFake(() => ({
      value: 'foo'
    }));
    const dispatched = [] as any;
    const id = 'id1';
    const otherOption = {};

    await runSaga(
      {
        dispatch: (action) => dispatched.push(action),
        getState: () => ({})
      },
      handleFetchFoo,
      fetchFoo(id, otherOption)
    ).done;

    expect(dispatched).toEqual([
      {
        isFetching: true,
        type: 'SET_IS_FETCHING'
      },
      {
        id: 'id1',
        foo: {
          wrappedWithObject: true,
          foo: {
            value: 'foo'
          }
        },
        type: 'FETCH_FOO_SUCCESS'
      },
      {
        isFetching: false,
        type: 'SET_IS_FETCHING'
      }
    ]);
  });
});

이 방법은 원형 그대로의 소스 코드를 최대한 보존한 채 테스트를 진행할 수 있다는 장점이 있으나, 역시 Saga 내부의 새부적인 로직에 대한 테스트는 어려울 수 있다.
상황에 따라 두가지 방법을 취사 선택하거나, 전체적인 Flow를 두 번째 방법을 활용하여 테스트하고 세부적인 로직을 첫 번째 방법으로 테스트하는 등 개발자의 재량껏 조합해서 사용하면 된다.

여기까지 Redux-saga의 실제적인 테스트 코드 작성 방법에 대해 정리해보았다. 실제 제품에서 사용되는 Saga는 예시의 것보다 훨씬 복잡한 경우가 많을 것이나, 기본적인 방법은 큰 틀을 벗어나지 않는다.

이 글은 필자의 Medium에도 게시되었습니다.

Sort:  

Excellent post!

Congratulations @echo304! You received a personal award!

Happy Birthday! - You are on the Steem blockchain for 1 year!

You can view your badges on your Steem Board and compare to others on the Steem Ranking

Do not miss the last post from @steemitboard:

Are you a DrugWars early adopter? Benvenuto in famiglia!
Vote for @Steemitboard as a witness to get one more award and increased upvotes!

Congratulations @echo304! You received a personal award!

Happy Steem Birthday! - You are on the Steem blockchain for 2 years!

You can view your badges on your Steem Board and compare to others on the Steem Ranking

Do not miss the last post from @steemitboard:

Downvote challenge - Add up to 3 funny badges to your board
Vote for @Steemitboard as a witness to get one more award and increased upvotes!