跳到主要內容

測試 Sagas

測試 Sagas 有兩種主要方式:逐步測試 Saga 產生器函式或執行整個 Saga,並聲明副作用。

測試 Saga 產生器函式

假設我們有下列動作

const CHOOSE_COLOR = 'CHOOSE_COLOR'
const CHANGE_UI = 'CHANGE_UI'

const chooseColor = color => ({
type: CHOOSE_COLOR,
payload: {
color,
},
})

const changeUI = color => ({
type: CHANGE_UI,
payload: {
color,
},
})

我們要測試 Saga

function* changeColorSaga() {
const action = yield take(CHOOSE_COLOR)
yield put(changeUI(action.payload.color))
}

由於 Sagas 始終會產生 Effect,且這些 Effect 具有基本工廠函數(例如 put、take 等),所以測試可能會檢查已產生的 Effect,並將其與預期的 Effect 進行比較。若要從 Saga 取得第一個產生的值,請呼叫其 next().value

const gen = changeColorSaga()

assert.deepEqual(gen.next().value, take(CHOOSE_COLOR), 'it should wait for a user to choose a color')

然後必須傳回一個值以指定至 action 常數,而該常數用於 put effect 的引數

const color = 'red'
assert.deepEqual(
gen.next(chooseColor(color)).value,
put(changeUI(color)),
'it should dispatch an action to change the ui',
)

由於沒有更多的 yield,因此下次呼叫 next() 時,將中斷產生器

assert.deepEqual(gen.next().done, true, 'it should be done')

分支 Saga

有時候您的 Saga 會有不同的結果。若要在不重複執行所有導致其結果的步驟的情況下測試不同的分支,您可以使用公用函式 cloneableGenerator

這次我們新增了兩個新的動作,CHOOSE_NUMBERDO_STUFF,以及相關的動作建立器

const CHOOSE_NUMBER = 'CHOOSE_NUMBER'
const DO_STUFF = 'DO_STUFF'

const chooseNumber = number => ({
type: CHOOSE_NUMBER,
payload: {
number,
},
})

const doStuff = () => ({
type: DO_STUFF,
})

現在受測的 Saga 將在等待 CHOOSE_NUMBER 動作之前,執行兩個 DO_STUFF 動作,然後會執行 changeUI('red')changeUI('blue'),這取決於數字是偶數還是奇數。

function* doStuffThenChangeColor() {
yield put(doStuff())
yield put(doStuff())
const action = yield take(CHOOSE_NUMBER)
if (action.payload.number % 2 === 0) {
yield put(changeUI('red'))
} else {
yield put(changeUI('blue'))
}
}

測試如下

import { put, take } from 'redux-saga/effects'
import { cloneableGenerator } from '@redux-saga/testing-utils'

test('doStuffThenChangeColor', assert => {
const gen = cloneableGenerator(doStuffThenChangeColor)()
gen.next() // DO_STUFF
gen.next() // DO_STUFF
gen.next() // CHOOSE_NUMBER

assert.test('user choose an even number', a => {
// cloning the generator before sending data
const clone = gen.clone()
a.deepEqual(clone.next(chooseNumber(2)).value, put(changeUI('red')), 'should change the color to red')

a.equal(clone.next().done, true, 'it should be done')

a.end()
})

assert.test('user choose an odd number', a => {
const clone = gen.clone()
a.deepEqual(clone.next(chooseNumber(3)).value, put(changeUI('blue')), 'should change the color to blue')

a.equal(clone.next().done, true, 'it should be done')

a.end()
})
})

另請參閱:任務取消,以測試 fork 效果

測試完整的 Saga

雖然逐一測試 Saga 的每個步驟可能會很有幫助,但在實務上會造成測試脆弱。相反地,執行整個 Saga 並斷言預期的效果已發生,可能更可取。

假設我們有一個基本的 Saga,它呼叫 HTTP API

function* callApi(url) {
const someValue = yield select(somethingFromState)
try {
const result = yield call(myApi, url, someValue)
yield put(success(result.json()))
return result.status
} catch (e) {
yield put(error(e))
return -1
}
}

我們可以使用模擬值執行 Saga

const dispatched = []

const saga = runSaga(
{
dispatch: action => dispatched.push(action),
getState: () => ({ value: 'test' }),
},
callApi,
'http://url',
)

接著,可以寫一個測試來斷言已發送的動作和模擬呼叫

import sinon from 'sinon'
import * as api from './api'

test('callApi', async assert => {
const dispatched = []
sinon.stub(api, 'myApi').callsFake(() => ({
json: () => ({
some: 'value',
}),
}))
const url = 'http://url'
const result = await runSaga(
{
dispatch: action => dispatched.push(action),
getState: () => ({ state: 'test' }),
},
callApi,
url,
).toPromise()

assert.true(myApi.calledWith(url, somethingFromState({ state: 'test' })))
assert.deepEqual(dispatched, [success({ some: 'value' })])
})

另請參閱:存放庫範例

https://github.com/redux-saga/redux-saga/blob/main/examples/counter/test/sagas.js

https://github.com/redux-saga/redux-saga/blob/main/examples/shopping-cart/test/sagas.js

測試程式庫

雖然上述兩種測試方法都可以本機撰寫,但有許多程式庫可以讓這兩種方法更容易。此外,有些程式庫可以用第三種方式測試 Saga:記錄特定的副作用(但不是全部)。

Sam Hogarth (@sh1989) 的文章很好地總結了不同的選項。

若要逐一測試每個產生器讓步,可以使用 redux-saga-testredux-saga-testingredux-saga-test-engine 用於記錄和測試特定副作用。若要進行整合測試,可以使用 redux-saga-tester。而且,redux-saga-test-plan 實際上可以涵蓋所有三種基礎。

redux-saga-testredux-saga-testing 用於逐一測試

redux-saga-test 函式庫為您的循序漸進的測試提供語法糖。 fromGenerator 函式會傳回一個值,可以透過 .next() 手動反覆運算,並使用相關 saga 效果方法進行斷言。

import fromGenerator from 'redux-saga-test'

test('with redux-saga-test', () => {
const generator = callApi('url')
/*
* The assertions passed to fromGenerator
* requires a `deepEqual` method
*/
const expect = fromGenerator(assertions, generator)

expect.next().select(somethingFromState)
expect.next(selectedData).call(myApi, 'url', selectedData)
expect.next(result).put(success(result.json))
})

redux-saga-testing 函式庫提供一個 sagaHelper 方法,它會接受產生器並傳回一個值,其作用很類似 Jest 的 it() 函式,但也同時推進了正在測試的產生器。 傳遞至回呼的 result 參數是產生器產生的值

import sagaHelper from 'redux-saga-testing'

test('with redux-saga-testing', () => {
const it = sagaHelper(callApi())

it('should select from state', selectResult => {
// with Jest's `expect`
expect(selectResult).toBe(value)
})

it('should select from state', apiResponse => {
// without tape's `test`
assert.deepEqual(apiResponse.json(), jsonResponse)
})

// an empty call to `it` can be used to skip an effect
it('', () => {})
})

redux-saga-test-plan

這是應用最廣的函式庫。 testSaga API 用於執行精確順序測試,而 expectSaga 則用於同時記錄副作用與整合測試。

import { expectSaga, testSaga } from 'redux-saga-test-plan';

test('exact order with redux-saga-test-plan', () => {
return testSaga(callApi, 'url')
.next()
.select(selectFromState)
.next()
.call(myApi, 'url', valueFromSelect);

...
});

test('recorded effects with redux-saga-test-plan', () => {
/*
* With expectSaga, you can assert that any yield from
* your saga occurs as expected, *regardless of order*.
* You must call .run() at the end.
*/
return expectSaga(callApi, 'url')
.put(success(value)) // last effect from our saga, first one tested

.call(myApi, 'url', value)
.run();
/* notice no assertion for the select call */
});

test('test only final effect with .provide()', () => {
/*
* With the .provide() method from expectSaga
* you can by pass in all expected values
* and test only your saga's final effect.
*/
return expectSaga(callApi, 'url')
.provide([
[select(selectFromState), selectedValue],
[call(myApi, 'url', selectedValue), response]
])
.put(success(response))
.run();
});

test('integration test with withReducer', () => {
/*
* Using `withReducer` allows you to test
* the state shape upon completion of your reducer -
* a true integration test for your Redux store management.
*/

return expectSaga(callApi, 'url')
.withReducer(myReducer)
.provide([
[call(myApi, 'url', value), response]
])
.hasFinalState({
data: response
})
.run();
});

redux-saga-test-engine

此函式庫在設定上運作的方式與 redux-saga-test-plan 十分相似,但最適合用來記錄效果。 提供一個通用的 saga 效果集合,讓 createSagaTestEngine 函式監視這些效果,接著傳回一個函式。 然後提供您的 saga、特定效果,以及其引數。

const collectedEffects  = createSagaTestEngine(['SELECT', 'CALL', 'PUT']);
const actualEffects = collectEffects(mySaga, [ [myEffect(arg), value], ... ], argsToMySaga);

actualEffects 的值是一個陣列,包含所有已收集效果產生的值,依據發生順序排序。

import createSagaTestEngine from 'redux-saga-test-engine'

test('testing with redux-saga-test-engine', () => {
const collectEffects = createSagaTestEngine(['CALL', 'PUT'])

const actualEffects = collectEffects(
callApi,
[[select(selectFromState), selectedValue], [call(myApi, 'url', selectedValue), response]],
// Any further args are passed to the saga
// Here it is our URL, but typically would be the dispatched action
'url',
)

// assert that the effects you care about occurred as expected, in order
assert.equal(actualEffects[0], call(myApi, 'url', selectedValue))
assert.equal(actualEffects[1], put(success, response))

// assert that your saga does nothing unexpected
assert.true(actualEffects.length === 2)
})

redux-saga-tester

一個考慮用於整合測試的最後一個函式庫。 此函式庫提供一個 sagaTester 類別,您可以使用儲存體的初始化狀態及還原器來實例化此類別。

若要測試您的 saga,sagaTester 實例會使用您的 saga 及其引數來執行 start() 方法。 這會執行您的 saga 直到結束。 接著您可以斷言效果已發生、動作已傳送,以及狀態已如預期更新。

import SagaTester from 'redux-saga-tester';

test('with redux-saga-tester', () => {
const sagaTester = new SagaTester({
initialState: defaultState,
reducers: reducer
});

sagaTester.start(callApi);

sagaTester.dispatch(actionToTriggerSaga());

await sagaTester.waitFor(success);

assert.true(sagaTester.wasCalled(success(response)));

assert.deepEqual(sagaTester.getState(), { data: response });
});

effectMiddlewares

提供原生的整合方式,用於在不使用上述函式庫的情況下進行測試。

構想是您可以在測試檔案中使用 saga 中介軟體創建真正的 redux 儲存體。 saga 中介軟體會將物件接收為引數。 那個物件會有一個 effectMiddlewares 值:一個函式,您可以在其中攔截/劫持任何效果並自行解析,接著以極為 redux 的風格將其傳遞至下一個中介軟體。

在您的測試中,您會啟動一個 saga、使用 effectMiddlewares 攔截/解析非同步效果,並斷言狀態更新等項目,以測試 saga 和儲存體之間的整合。

以下是文件中的一個範例

test('effectMiddleware', assert => {
assert.plan(1)

let actual = []

function rootReducer(state = {}, action) {
return action
}

const effectMiddleware = next => effect => {
if (effect === apiCall) {
Promise.resolve().then(() => next('injected value'))
return
}
return next(effect)
}

const middleware = sagaMiddleware({ effectMiddlewares: [effectMiddleware] })
const store = createStore(rootReducer, {}, applyMiddleware(middleware))

const apiCall = call(() => new Promise(() => {}))

function* root() {
actual.push(yield all([call(fnA), apiCall]))
}

function* fnA() {
const result = []
result.push((yield take('ACTION-1')).val)
result.push((yield take('ACTION-2')).val)
return result
}

const task = middleware.run(root)

Promise.resolve()
.then(() => store.dispatch({ type: 'ACTION-1', val: 1 }))
.then(() => store.dispatch({ type: 'ACTION-2', val: 2 }))

const expected = [[[1, 2], 'injected value']]

task
.toPromise()
.then(() => {
assert.deepEqual(
actual,
expected,
'effectMiddleware must be able to intercept and resolve effect in a custom way',
)
})
.catch(err => assert.fail(err))
})