서술적 이펙트

redux-saga 에서, Saga들은 제너레이터 함수들을 사용해서 구현되었습니다. Saga 로직을 표현하기 위해서 우리는 제너레이터로부터 온 순수 자바스크립트 객체를 yield 합니다. 이런 오브젝트들을 이펙트 라고 부릅니다. 이펙트는 미들웨어에 의해 해석되는 몇몇 정보들을 담고 있는 간단한 객체입니다. 어떤 기능을 수행하기 위해 미들웨어에 전해지는 명령(스토어에 액션을 dispatch 하는 행위나 비동기 함수를 호출하는 등)이라고 볼 수 있습니다.

이펙트들을 만들기 위해서, redux-saga/effects 패키지에 있는 라이브러리들이 제공하는 함수들을 사용합니다.

이 섹션에서, 몇몇 기본 이펙트들을 소개하겠습니다. 어떻게 Saga 가 테스트 되기 쉬운지 관찰해봅시다.

Sagas 는 여러 형식으로 이펙트들을 yield 할 수 있습니다. 가장 쉬운 방법은 Promise 를 yield 하는 것입니다.

예를 들어, PRODUCTS_REQUESTED 라는 액션을 보고 있는 Saga 가 있다고 가정해봅시다. 액션이 매칭될 때 마다, 서버로부터 온 상품 리스트를 가지고 오는 태스크를 실행합니다.

import { takeEvery } from "redux-saga/effects"
import Api from "./path/to/api"

function* watchFetchProducts() {
  yield takeEvery("PRODUCTS_REQUESTED", fetchProducts)
}

function* fetchProducts() {
  const products = yield Api.fetch("/products")
  console.log(products)
}

예제는 제너레이터 내부에서 Api.fetch 를 직접적으로 호출하고 있습니다. (제너레이터 함수에선, yield 의 오른편에 있는 모든 구문이 실행되고, 그 결과는 호출자로 yield 됩니다.)

Api.fetch('/products') 는 AJAX 요청을 트리거 하고, resolve 된 응답을 resolve 할 Promise 를 리턴합니다. 이 AJAX 요청은 즉시, 간단하게 그리고 일반적으로 실행될 겁니다. 그러나...

위의 제너레이터를 테스트하고 싶다고 가정해봅시다:

const iterator = fetchProducts()
assert.deepEqual(iterator.next().value, ??) // what do we expect ?

제너레이터에 의해 yield 된 첫 번째 결과를 체크하고 싶습니다. 이 경우에선 Api.fetch('/products') 의 실행 결과인 Promise 가 됩니다. 하지만 테스트에 실제 서비스를 호출하는 건 현실적이지도, 실용적이지도 못합니다. 그래서 실제 AJAX 요청을 실행하지 않고 진짜 함수 대신 함수와 인자들을 올바르게 호출했다는 사실만 확인하는 가짜함수를 사용해서 Api.fetch 함수를 흉내 내봅시다.

이런 가짜함수들은 테스팅을 더 어렵게 만들 수도 있습니다, 하지만 다른 관점에서 보면, 결과를 체크하기 위해 equal() 을 사용할 수 있기 때문에 값들을 간단히 리턴하는 함수들은 테스트하기 더 쉽습니다. 이것이 가장 믿을만한 테스트를 작성하는 방법입니다.

아직 헷갈리십니까? Eric Elliott의 글을 읽어봅시다!:

(...)equal(), 은 본질적으로 모든 단위테스트가 대답해야 할 중요한 두 가지 질문에 대답합니다. 하지만 대부분은 그러지 않는 것 입니다:

  • 실제 출력값은 무엇입니까?
  • 예상된 출력값은 무엇입니까?

    만약 이 두 가지 질문에 대답하지 않고 테스트를 마쳤다면, 진짜 단위 테스트가 아닌 반쪽짜리 날림 테스트가 될 것입니다.

사실 우리는 그저 fetchProducts 태스크가 정상적인 함수와 인자를 가진 call 을 yield 하는지 확실하게 만들고 싶을 뿐입니다.

제너레이터 안에서 직접적으로 비동기 함수를 호출하는 대신, 함수 호출에 관한 설명만 yield 할 수 있습니다. 이제 다음과 같이 생긴 오브젝트를 간단히 yield 할 겁니다.

// 이펙트 -> Api.fetch 함수를 './products' 인자와 함께 호출
{
  CALL: {
    fn: Api.fetch,
    args: ['./products']
  }
}

다른 식으로 보자면, 제너레이터는 명령 을 담고 있는 순수한 객체를 yield 할 것이고, redux-saga 미들웨어는 이런 명령의 실행을 처리하고, 결과를 제너레이터에 돌려줄 것입니다. 제너레이터를 테스트 할 때 이 방법을 사용하면, yield 된 객체에 간단한 deepEqual 을 사용해 비교해서, 올바른 명령을 yield 하는지 확인하기만 하면 됩니다.

이러한 이유로 이 라이브러리는 비동기 요청을 수행할 다른 방법을 제공합니다.

import { call } from "redux-saga/effects"

function* fetchProducts() {
  const products = yield call(Api.fetch, "/products")
  // ...
}

이제 우린 call(fn, ...args) 함수를 쓸 겁니다. 앞의 예제와 다른 점은 이제 우린 더는 fetch 요청을 즉시 하지 않는다는 것입니다. 대신, call 은 이펙트에 대한 설명을 생성합니다. Redux 에서 와 마찬가지로, 스토어에 의해 실행될 액션을 설명하는 순수 객체를 만들기 위해 액션 생성자(action creator)들을 사용하고, call 은 함수 호출을 설명하는 순수 객체를 생성합니다. redux-saga 미들웨어는 함수 호출과 제너레이터를 resolve 된 응답과 함께 재가동 시킵니다.

call 은 그저 순수 객체만 리턴하는 함수기 때문에 제너레이터를 Redux 환경 바깥에서 쉽게 테스트하게 만듭니다.

import { call } from "redux-saga/effects"
import Api from "..."

const iterator = fetchProducts()

// expects a call instruction
assert.deepEqual(
  iterator.next().value,
  call(Api.fetch, "/products"),
  "fetchProducts should yield an Effect call(Api.fetch, './products')"
)

이제 아무것도 흉내 낼 필요가 없어졌습니다. 간단한 비교 테스트로 충분할 것입니다.

이런 서술적 호출을 을 함으로써 Saga 내부에서 간단히 제너레이터를 반복하고, 연속적으로 yield 된 값들에 deepEqual 테스트를 하는 것 만으로 모든 로직을 테스트 할 수 있습니다. This is a real benefit, as your complex asynchronous operations are no longer black boxes, and you can test in detail their operational logic no matter how complex it is.

call 은 또한 오브젝트 메소드 호출을 지원합니다. 다음과 같은 방식을 사용하여 호출된 함수에 this 컨텍스트를 사용할 수 있습니다.

yield call([obj, obj.method], arg1, arg2, ...) // as if we did obj.method(arg1, arg2 ...)

똑같은 기능을 하는 apply alias 함수도 있습니다.

yield apply(obj, obj.method, [arg1, arg2, ...])

callapply 는 Promise 들을 리턴하는 함수들에 적당합니다. 또 다른 함수 cps(Continuation Passing Style) 는 노드 스타일의 함수들을 다루기 위해 쓰일 수도 있습니다. (예: fn(...args, callback), callback => (error, result) => ()).

예:

import { cps } from 'redux-saga/effects'

const content = yield cps(readFile, '/path/to/file')

그리고 당연히 call 로 테스트 했던 것과 비슷하게 테스트 할 수 있습니다:

import { cps } from "redux-saga/effects"

const iterator = fetchSaga()
assert.deepEqual(iterator.next().value, cps(readFile, "/path/to/file"))

또, cpscall 과 똑같은 메소드 호출 형식을 지원합니다.

results matching ""

    No results matching ""