宣告 Effects

redux-saga 中,Saga 都是使用 Generator function 實作的。我們從 Generator yield 純 JavaScript 物件來表達 Saga 的邏輯。我們稱這些物件為 Effects。一個 Effect 是一個簡單的物件,它包含了一些由 middleware 解譯的資訊。你可以把 Effect 看作是給 middleware 執行一些操作的說明(也就是說,調用一些非同步的 function,dispatch 一個 action 到 store)。

你可以使用在 library 內提供的 redux-saga/effects package 的 function 來建立 Effect。

在這個部份和接下來的部份,我們將介紹一些基礎的 Effect,而且可以看到這些概念讓 Saga 變得更容易測試。

Saga 可以 yield 多種形式的 Effect。最簡單的方式就是 yield 一個 Promise。

例如,假設我們有一個 Saga 觀察一個 PRODUCTS_REQUESTED action。在每次發出的 action 符合 takeEvery 的 action 時,它啟動一個 task 來從伺服器取得一些產品。

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)
}

在上面的範例中,我們從 Generator 內直接調用了 Api.fetch(在 Generator function 中,任何在 yield 右邊的表達式都會被求值,然後結果被 yield 到 caller)。

Api.fetch('/products') 觸發一個 AJAX 請求並回傳一個 Promise,Promise 將被 resolve,被 resolve 的 Promise 會取得 response。簡單且直覺的,但是...

假設我們要測試上面的 generator:

const iterator = fetchProducts()
assert.deepEqual(iterator.next().value, ??) // 我們期望都到的是?

我們想要檢查 generator yield 後第一個結果的值。在我們這個情況執行 Api.fetch('/products') 的結果是一個 Promise。在測試時,執行真正的服務不是一個實際可行的方法,所以我們需要 mock Api.fetch function,也就是說,我們將有一個替換真實 function 而不實際執行 AJAX 請求,確認我們呼叫 Api.fetch 與它的參數 (在這情況中,這裡的參數是 '/products')。

Mock 讓測試更加困難而且不可靠。另一方面,那些只回傳數值的 function 更容易的測試,因此我們可以簡單的使用 equal() 來測試結果。這種方式可以撰寫更加可靠的測試。

不相信嗎?我建議你去閱讀 Eric Elliott 的文章

(...)equal(),自然的回答這兩個重要的問題,是每個單元測試必須回答的, 但大多數不是這樣:

  • 實際的輸出是什麼?
  • 期望的輸出是什麼?

如果你完成一個測試沒有回答這兩個問題,就不是一個真實的單元測試。你的測試只是一個馬虎、不成熟的測試。

實際上我們需要確保 fetchProducts task yield 一個呼叫正確的 function 和正確的參數。

不是從 Generator 內直接調用非同步 function,我們可以只 yield 一個 function 調用的描述。也就是說我們簡單 yield 一個物件,看起來像是:

// Effect -> 呼叫 function Api.fetch 與 `./products` 作為參數
{
  CALL: {
    fn: Api.fetch,
    args: ['./products']
  }
}

另一種方式,Generator 將 yield 包含純物件的說明redux-saga middleware 將確保執行那些指令並將它們執行的結果給到 Generator。透過這種方式,在測試 Generator 的時候,我們只需要將 yield 後的物件透過簡單的 deepEqual 確認 yield 是否為期望的指令。

根據這樣的原因,library 提供了一個不同的執行非同步呼叫方式。

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

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

我們現在使用 call(fn, ...args) function。不同於前面的範例,我們現在不直接執行 fetch 呼叫,相反的,透過 call 建立一個 effect 的描述。就像在 Redux 你可以使用 action creator 來建立一個純物件描述 action,透過 Store 接收後被執行,call 建立一個純物件來描述 function 的呼叫。redux-saga middleware 確認執行 function 的呼叫,並在 resolve response 時恢復 generator。

這讓我們在 Redux 外的環境更容易的測試 Generator。因為 call 只是一個 function,回傳一個純物件。

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

const iterator = fetchProducts()

// 期望一個 call 的指令
assert.deepEqual(
  iterator.next().value,
  call(Api.fetch, '/products'),
  "fetchProducts should yield an Effect call(Api.fetch, './products')"
)

現在我們不需要 mock 任何東西,一個簡單的相等測試就足夠了。

這些宣告的呼叫的優點是,我們透過簡單的迭代 Generator,並在 yield 成功後得到的值做 deepEqual 測試,就可以測試所有在 Saga 的邏輯。這是真實的好處,你複雜的非同步操作不再是黑盒,不管它多麼複雜,你都可以測試每一個項目的操作邏輯。

call 也支援調用物件的方法,使用以下的形式,你可以提供一個 this context 到調用的 function:

yield call([obj, obj.method], arg1, arg2, ...) // 如同我們使用 obj.method(arg1, arg2 ...)

apply 是這個調用方法形式的別名:

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

callapply 非常適合回傳 Promise 結果的 function。另一個 function cps 可以備用來處理 Node 風格的 function(例如:fn(...args, callback),這個 callback(error, result) => () 的形式)。cps 是代表 Continuation Passing Style。

例如:

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 ""