Sagaのテスト
Sagaをテストする主な方法は2つあります。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))
}
Sagaは常にEffectをyieldし、これらのeffectには基本的なファクトリー関数(例:put、takeなど)があるため、テストではyieldされたeffectを検査し、期待されるeffectと比較できます。sagaから最初にyieldされた値を取得するには、その`next().value`を呼び出します。
const gen = changeColorSaga()
assert.deepEqual(gen.next().value, take(CHOOSE_COLOR), 'it should wait for a user to choose a color')
`put` effectの引数として使用される`action`定数に割り当てるために、値を返す必要があります。
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_NUMBER`と`DO_STUFF`という2つの新しいアクションを追加します。
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`アクションを待機する前に2つの`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 effectのテストについてはタスクのキャンセルを参照してください。
完全なSagaのテスト
sagaの各ステップをテストすることは便利かもしれませんが、実際にはこれは壊れやすいテストになります。代わりに、saga全体を実行し、期待されるeffectが発生したことをアサートする方が好ましい場合があります。
HTTP APIを呼び出す基本的なsagaがあるとします。
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
テストライブラリ
上記のテスト方法はどちらもネイティブに記述できますが、両方の方法を容易にするためのいくつかのライブラリが存在します。さらに、一部のライブラリは、3番目の方法でsagaをテストするために使用できます。特定の副作用(すべてではない)を記録することです。
Sam Hogarth (@sh1989)の記事は、さまざまなオプションをうまくまとめています。
ジェネレーターの各yieldステップをステップバイステップでテストするには、redux-saga-test
とredux-saga-testing
があります。redux-saga-test-engine
は、特定の副作用の記録とテスト用です。統合テストにはredux-saga-tester
。そしてredux-saga-test-plan
は、実際には3つのすべてのベースをカバーできます。
ステップバイステップテストのためのredux-saga-test
とredux-saga-testing
redux-saga-test
ライブラリは、ステップバイステップテストの構文糖を提供します。fromGenerator
関数は、.next()
で手動で反復でき、関連するsaga effectメソッドを使用してアサーションを行うことができる値を返します。
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
ライブラリは、ジェネレーターを取得して、Jestのit()
関数と非常によく似た動作をする値を返し、テスト対象のジェネレーターも進めるsagaHelper
メソッドを提供します。コールバックに渡される`result`パラメーターは、ジェネレーターによってyieldされた値です。
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
の設定と非常によく似ていますが、効果を記録するために最適です。createSagaTestEngine
関数によって監視されるsagaジェネリック効果のコレクションを提供し、その関数は関数を返します。次に、sagaと特定の効果とその引数を指定します。
const collectedEffects = createSagaTestEngine(['SELECT', 'CALL', 'PUT']);
const actualEffects = collectEffects(mySaga, [ [myEffect(arg), value], ... ], argsToMySaga);
actualEffects
の値は、発生順にすべての収集された効果からyieldされた値に等しい要素を含む配列です。
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
統合テストを検討するための最後のライブラリ。このライブラリは、ストアの初期状態とreducerでインスタンス化するsagaTester
クラスを提供します。
sagaをテストするには、sagaとその引数を指定してsagaTester
インスタンスの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))
})