redux-saga-test-plan - 간편한 Redux Saga 테스트 - Jeremy Fairbank 인터뷰


원문
Juho Vepsäläinen, https://survivejs.com/blog/redux-saga-test-plan-interview/

Redux Saga는 테스트가 쉽기로 유명하다. 하지만, 더 간단해질 수 있다면 어떨까? Jeremy Fairbank는 더 간단한 테스트를 위해 redux-saga-test-plan을 디자인했다.

본인 소개를 부탁한다.

Test Double의 소프트웨어 엔지니어이자 컨설턴트다. 소프트웨어가 고장 나면 그것을 고치는 일을 하고, 전 세계의 소프트웨어 개발 방식을 개선시키는 것을 목표로 한다.

약 10년 동안 프론트엔드 개발을 해왔고, React와 Redux가 프론트엔드 세상에 도움을 준 패러다임을 즐기고 있다. revalidate, redux-saga-router, 그리고 이 인터뷰의 주제인 redux-saga-test-plan과 같은 React, Redux 생태계에서 잘 동작하는 오픈 소스 프로젝트를 개발했다.

함수형 프로그래밍과 Elm을 정말 좋아한다. 현재는 The Pragmatic Programmers에서 Programming Elm: Build Safe and Maintainable Front-End Applications. 라는 책을 작성하고 있다. 절반 이상 완성했으며 2018년 봄 즈음 보실 수 있을 것 같다.

redux-saga-test-plan을 전혀 들어본 적이 없는 사람에게 설명한다면?

redux-saga-test-planredux-saga를 더 쉽게 테스트하기 위한 라이브러리다.

만약 redux-saga가 익숙지 않다면 redux-saga의 개발자 Yassine Elouafi 인터뷰를 확인해보자.

redux-saga-test-plan은 사가(Saga) 제너레이터 함수를 테스트할 때, 실제 구현 로직과 테스트 코드가 갖는 커플링, 그리고 매뉴얼 한 테스트에 대한 문제를 해결해준다. 테스트에 선언적이고, 체이닝(chainable) API를 제공해서 실제 구현체인 사가에서 원하는 이펙트만을 테스트할 수 있도록 도와준다. 이외 어떤 이펙트들을 나타내는지, 이펙트들의 순서는 어떻게 되는지 신경쓰지 않고 걱정하지 않도록 한다. redux-saga의 런타임을 함께 사용하므로, 통합 테스트를 할 수도 있고, redux-saga-test-plan에 내장된 이펙트 목킹(mocking)을 활용해 유닛 테스트도 작성할 수 있다.

redux-saga-test-plan는 어떻게 동작하는지?

간단한 saga 예시를 보고 redux-saga-test-plan이 그 사가들을 어떻게 쉽게 테스트하는지 소개하겠다.

간단한 API Saga

다음은 유저 목록을 요청하는 간단한 사가다.

import { call, put } from "redux-saga/effects";

function* fetchUsersSaga(api) {
  const users = yield call(api.getUsers);
  yield put({ type: "FETCH_USERS_SUCCESS", payload: users });
}

redux-saga-test-plan을 사용해 다음과 같은 테스트를 작성할 수 있다.

import { expectSaga } from "redux-saga-test-plan";

it("fetches users", () => {
  const users = ["Jeremy", "Tucker"];

  const api = {
    getUsers: () => users,
  };

  return expectSaga(fetchUsersSaga, api)
    .put({ type: "FETCH_USERS_SUCCESS", payload: users })
    .run();
});

expectSaga는 사가와 그 arguments를 받는 함수다. 위에선 fetchUsersSagaapi를 목킹해서 가짜 응답을 받도록 했다.

expectSaga는 체이닝이 가능한 API를 반환한다. 여기에는 여러 유용한 메서드들이 있다. 위의 put메서드는 사가가 put 이펙트를 확인하기 위한 확인(assertion) 메서드로, FETCH_USERS_SUCCESS 액션이 발생했는지 테스트한다.

run 메서드는 사가를 실행시킨다. redux-saga-test-plan은 redux-saga의 run-saga 함수를 사용한다. 따라서 사가는 실제 애플리케이션에서 실행되는 것과 같이 처리된다. expectSaga는 사가가 yield하는 모든 이펙트들을 추적하기 때문에 위의 put 메서드처럼 모두 테스트할 수 있다.

일반적으로 사가는 비동기이기 때문에 redux-saga-test-planrun 메서드에서 프로미스(promise)를 반환한다. 단, 프로미스를 사용하기 때문에 언제 테스트가 완료되는지 알아야 한다. 위의 예시에서는 Jest를 사용하기 때문에 프로미스를 바로 반환하면 된다.

redux-saga-test-plan은 비동기로 동작하기 때문에, 일정 시간에 따라 사가를 타임아웃 시킨다. 타임아웃 시간은 설정할 수 있다.

내장된 목킹

위의 api 객체처럼 의존성을 주입하지 않는다면, expectSaga에 내장된 목킹 메커니즘을 사용할 수 있다. providers라 부른다. 다른 파일에서 api를 import 하여 사용한다 가정해보자.

import { call, put } from "redux-saga/effects";
import api from "./api";

function* fetchUsersSaga() {
  const users = yield call(api.getUsers);
  yield put({ type: "FETCH_USERS_SUCCESS", payload: users });
}

provide 메서드를 사용해 다음과 같은 목킹을 할 수 있다.

import { expectSaga } from "redux-saga-test-plan";
import api from "./api";

it("fetches users", () => {
  const users = ["Jeremy", "Tucker"];

  return expectSaga(fetchUsersSaga)
    .provide([[call(api.getUsers), users]])
    .put({ type: "FETCH_USERS_SUCCESS", payload: users })
    .run();
});

provide 메서드는 매처(matcher)-값(value) 쌍을 배열로 받는다. 각 매처-값 쌍은 매칭할 이펙트와 이에 반환할 가짜 값을 엘리먼트로 가진 배열이다. redux-saga-test-plan은 이펙트를 가로채고, 매칭을 확인한 후 redux-saga에 이펙트 처리를 넘기지 않고 바로 가짜 값을 반환하도록 한다. 이 예시에서는 모든 call 이펙트에 대해서 api.gerUsers를 처리하는지 확인하고, 맞는다면 가짜 유저 목록을 반환하도록 한다.

이펙트 디스패치(Dispatching)와 포크(Fork)된 사가

redux-saga-test-plan은 다음과 같은 복잡한 사가를 처리할 수 있다.

import { call, put, takeLatest } from "redux-saga/effects";
import api from "./api";

function* fetchUserSaga(action) {
  const id = action.payload;
  const user = yield call(api.getUser, id);

  yield put({ type: "FETCH_USER_SUCCESS", payload: user });
}

function* watchFetchUserSaga() {
  yield takeLatest("FETCH_USER_REQUEST", fetchUserSaga);
}

watchFetchUserSagaFETCH_USER_REQUEST의 가장 마지막 액션을 처리하기 위해 takeLatest를 사용하고 있다. 만약 FETCH_USER_REQUEST를 디스패치하면 redux-saga는 fetchUserSaga를 포크하고 액션을 넘긴다. fetchUserSaga는 액션에 담긴(payload) 아이디로 유저 정보를 요청한다. 이 처리를 redux-saga-test-plan을 이용해 다음과 같이 테스트할 수 있다.

import { expectSaga } from "redux-saga-test-plan";
import api from "./api";

it("fetches a user", () => {
  const id = 42;
  const user = { id, name: "Jeremy" };

  return expectSaga(watchFetchUserSaga)
    .provide([[call(api.getUser, id), user]])
    .put({ type: "FETCH_USER_SUCCESS", payload: user })
    .dispatch({ type: "FETCH_USER_REQUEST", payload: id })
    .silentRun();
});

redux-saga-test-plan는 포크된 사가의 이펙트들도 모두 추적한다. 위 예시에서 expectSaga는 단지 watchFetchUserSaga만을 받았지만, fetchUserSaga가 yield하는 put 이펙트도 테스트한다는 점을 알아두자.

dispatch 메서드를 사용해 FETCH_USER_REQUEST 액션을 watchFetchUserSaga에 dispatch했다. 액션에는 payloadid42를 지정했다. redux-saga는 이 액션을 받아서 fetchUserSaga를 포크하고 실행했다.

takeLatest는 루프(loop)로 동작히기 때문에 redux-saga-test-plan은 경고 메시지와 함께 사가를 타임아웃 시킨다. 하지만 이미 타임아웃을 예상하기 때문에 경고 메시지를 나타내지 않도록 silentRun 메서드를 사용했다.

역주: 일반적인 사가 테스트에는 run을 사용해서 경고메시지를 확인해야 함이 옳다. 하지만 위의 예시에서 watchFetchUserSagatakeLatest는 무한 루프이고, 종료가 없다. 따라서 타임아웃처리를 하는 것이고, 경고 메시지를 생략하도록 slientRun을 사용한다.

에러 처리

providers를 사가의 에러 처리 테스트를 위해 사용할 수도 있다. try-catch를 사용하는 새로운 fetchUsersSaga를 보자.

function* fetchUsersSaga() {
  try {
    const users = yield call(api.getUsers);
    yield put({ type: "FETCH_USERS_SUCCESS", payload: users });
  } catch (e) {
    yield put({ type: "FETCH_USERS_FAIL", payload: e });
  }
}

redux-saga-test-plan/providers에서 throwError를 import하여 provide메서드에서 에러를 시뮬레이션할 수 있다.

import { expectSaga } from "redux-saga-test-plan";
import { throwError } from "redux-saga-test-plan/providers";

it("handles errors", () => {
  const error = new Error("Whoops");

  return expectSaga(fetchUsersSaga)
    .provide([[call(api.getUsers), throwError(error)]])
    .put({ type: "FETCH_USERS_FAIL", payload: error })
    .run();
});

Redux의 상태(State)

Redux의 리듀서(reducer)를 Saga와 함께 테스트할 수도 있다. 유저 목록을 받아 스토어의 상태를 업데이트하는 리듀서를 같이 테스트해보자.

const INITIAL_STATE = { users: [] };

function reducer(state = INITIAL_STATE, action) {
  switch (action.type) {
    case "FETCH_USERS_SUCCESS":
      return { ...state, users: action.payload };
    default:
      return state;
  }
}

withReducer 메서드를 사용해서 리듀서에 연결한 후 hasFinalState를 사용해 최종 상태를 확인할 수 있다. 다음 예시를 보자.

import { expectSaga } from "redux-saga-test-plan";

it("fetches the users into the store state", () => {
  const users = ["Jeremy", "Tucker"];

  return expectSaga(fetchUsersSaga)
    .withReducer(reducer)
    .provide([[call(api.getUsers), users]])
    .hasFinalState({ users })
    .run();
});

테스트 가능한 이펙트 목록

다음은 이펙트 테스트를 위한 메서드 목록이다.

  • take(pattern)
  • take.maybe(pattern)
  • put(action)
  • put.resolve(action)
  • call(fn, ...args)
  • call([context, fn], ...args)
  • apply(context, fn, args)
  • cps(fn, ...args)
  • cps([context, fn], ...args)
  • fork(fn, ...args)
  • fork([context, fn], ...args)
  • spawn(fn, ...args)
  • spawn([context, fn], ...args)
  • join(task)
  • select(selector, ...args)
  • actionChannel(pattern, [buffer])
  • race(effects)

추가 기능들

redux-saga-test-plan은 다른 솔루션과 무엇이 다른가?

  • expectSaga에서 오직 관심이 있는 이펙트만 테스트할 수 있다. 매뉴얼하게 사가의 이펙트들을 모두 확인할 필요가 없고, 구현 로직과의 커플링을 없앨 수 있다.
  • 선언적이고 체이닝이 가능한 API로 사가를 테스트하는데 간단한 준비만 하면 된다. 지금까지 본 다른 솔루션들은 명령형의 API와 더 많은 준비 단계가 필요했고, 특정 API만 테스트할 수 있었다.
  • 사가를 리듀서와 함께 테스트할 수 있는 몇 안 되는 라이브러리 중 하나다.
  • 여러 레이어로 깊게 포크된 사가를 테스트할 수 있다.
  • 내장된 목킹으로 정적/동적 providers를 사용할 수 있다.
  • 부정 확인. 사가가 특정 이펙트를 yield하지 않았는지로 확인할 수 있다.
  • 부분 확인. 예를 들어, 특정 type의 액션의 put 이펙트를 그 액션의 패이로드와 관계없이 테스트할 수 있다.

redux-saga-test-plan을 왜 개발했는지?

다음과 같이 사가를 매뉴얼하게 반복하며 테스트하는 것에 지쳤었다.

function* fetchUsersSaga() {
  const users = yield call(api.getUsers);
  yield put({ type: "FETCH_USERS_SUCCESS", payload: users });
}

it("fetches users", () => {
  const users = ["Jeremy", "Tucker"];
  const iter = fetchUsersSaga();

  expect(iter.next().value).toEqual(call(api.getUsers));

  expect(iter.next(users).value).toEqual(
    put({ type: "FETCH_USERS_SUCCESS", payload: users })
  );
});

이런 테스트는 작성하는 데 오래 걸리고, 실제 구현과 커플링 된다. 사가의 전체적인 동작과 관계없는 이펙트 순서의 작은 변화도 항상 테스트를 실패시킨다. 역설적으로, testSaga API도 만들었다. 몇 가지 boilerplate를 제거했지만, 여전히 실제 구현과 커플링은 있다.

사용자 친화적인 API와 대부분의 boilerplate를 제거하고 관심 있는 동작만을 테스트하는 데 집중하길 원했고, 그래서 expectSaga가 만들어졌다.

다음 계획은?

Elm 책을 작성하는데 대부분 시간을 보내고 있어서 redux-saga-test-plan 개발은 조금 쉬고 있다. 하지만 다음 큰 계획은 redux-saga 버전 1을 지원하는 것으로, 이펙트 미들웨어에 대한 기능이 추가된다. 이펙트 미들웨어는 이펙트를 가로채고 가짜 값을 반환할 수 있게 한다. expectSagaproviders 구현을 이펙트 미들웨어로 더 단순화하고자 한다.

역주: 번역하는 현재 redux-saga는 아직 v1.0.0-beta.1 릴리스 상태다.

Redux 스토어에 대한 전체적인 통합, 새로운 단언문(assertions) 등 또한 계획에 있다.

컨트리뷰터를 환영한다!

redux-saga-test-plan이나 웹 개발의 미래는 전반적으로 어떻게 보는지? 앞으로의 트렌드는?

redux-saga에 의존하고 있어서 확신할 수는 없다. Mateusz Burzyński와 모든 컨트리뷰터들은 유지보수를 훌륭히 하고 있다. redux-saga가 v1으로 향하는데 좋은 신호다. 하지만 프론트엔드 개발은 빠르게 변하고 움직인다. 예를 들어, RxJSredux-observable의 인기가 크게 상승했다.

프론트엔드 애플리케이션에서 redux-saga에 대한 많은 지원이 있는 한, redux-saga-test-plan은 테스트 부분에서 많은 도움을 주도록 유지될 것으로 생각한다. 사가 제너레이터를 테스트하는 것은 어렵고, redux-saga-test-plan은 계속해서 이를 쉽게 만들어 주길 희망한다. 즉, 나는 고객의 프로젝트에 항상 redux-saga를 사용하진 않지만, 다른 컨트리뷰터들의 지원으로 redux-saga-test-plan을 테스팅에 최선의 방법이 되도록 할 수 있다.

트렌드에 대해서, 프론트엔드 개발은 정적 타입으로 유지 보수와 안전성을 향상시키는 방향으로 가고 있다고 생각한다. Elm, TypeScript, 그리고 Flow는 단단한 프론트엔드 애플리케이션을 더 쉽게 만들 수 있도록 한다. 정적 타입은 많은 단순한 버그와 실수를 잡을 수 있고, 코드를 더 자신 있게 리팩토링하도록 도와준다.

웹 개발을 시작하는 프로그래머들에게 조언한다면?

새로 나오는 모든 라이브러리와 프레임워크를 따라갈 필요는 없다. 당신이 만드는, 좋아하는 소프트웨어 개발에 집중하라. 최신 자바스크립트 프레임워크를 사용하지 않는다고 해서 다른 개발자들로부터 자신이 진짜 개발자가 아니라 여겨진다고 생각하지 말라. 가장 중요한 것은 당신이 사용하는 개발 언어를 이해하고, 올바른 소프트웨어 엔지니어링 방법을 지키는 것이다. 당신을 공감하고 돕고 싶어 하는 멘토를 찾아라.

또한 컨퍼런스, 밋업에 참여하라. 때때로 얼마나 많은 사람이 그들이 공유하는 주제에 대해 전문가가 아닌지(본인도 물론이다)에도 놀랄 것이다. 당신이 기술을 경험하고 학습한 것에 힘들었던 점을 공유할 수도 있고, 그 기술을 왜 좋아하는지에 대한 자신의 고유한 관점을 제공할 수도 있다. 그리고 새로운 사람들에게 영감을 주고 힘을 줄 수 있다.

다음 인터뷰는 누가 좋을까요?

본인은 Test Double에서 일하기 때문에 약간 편견이 있을 수 있지만, Justin Searls를 인터뷰하면 좋겠다. 테스팅에 대한 많은 발표를 했고, 그의 통찰력이 자바스크립트 세계에 큰 도움이 될 것이다. 그는 우리의 Test Doulbe의 라이브러리인 testdouble.js를 개발하고 있다. testdouble.js는 테스트에서의 목킹에 대한 내 생각을 변화시켰다.

결론

인터뷰에 응해주어 고맙다. Jeremy! redux-saga-test-plan은 redux-saga를 잘 보완해주는 것으로 보인다.

더 많은 내용들은 redux-saga-test-plan 사이트redux-saga-test-plan Github 페이지에서 배울 수 있다.

2017년 12월 20일 Juho Vepsäläinen


이민규, FE Development Lab2018.05.14Back to list