メインコンテンツにスキップ

宣言的エフェクト

redux-sagaでは、Sagaはジェネレーター関数を使用して実装されます。Sagaロジックを表現するために、ジェネレーターからプレーンなJavaScriptオブジェクトをyieldします。これらのオブジェクトをエフェクトと呼びます。エフェクトは、ミドルウェアによって解釈される情報を格納したオブジェクトです。エフェクトは、ミドルウェアに対する命令(例:非同期関数の呼び出し、ストアへのアクションのディスパッチなど)のように見ることができます。

エフェクトを作成するには、ライブラリが`redux-saga/effects`パッケージで提供する関数を使用します。

このセクションと次のセクションでは、いくつかの基本的なエフェクトを紹介します。そして、この概念によってSagaが簡単にテストできるようになる仕組みを見ていきます。

Sagaは複数の形式でエフェクトを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リクエストをトリガーし、解決されたレスポンスで解決されるPromiseを返します。AJAXリクエストはすぐに実行されます。シンプルで慣用的ですが...

上記のジェネレーターをテストしたいとします。

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

ジェネレーターによってyieldされた最初の値の結果を確認したいと思います。この場合、それは`Api.fetch('/products')`を実行した結果であるPromiseです。テスト中に実際のサービスを実行することは現実的でも実用的でもありません。そのため、`Api.fetch`関数を*モック*する必要があります。つまり、実際の関数を、実際にAJAXリクエストを実行するのではなく、正しい引数(この場合は`'/products'`)で`Api.fetch`を呼び出したことを確認するだけの偽物の関数に置き換える必要があります。

モックはテストをより困難で信頼性の低いものにします。一方、値を返す関数は、結果を確認するために単純な`equal()`を使用できるため、テストが容易です。これは最も信頼性の高いテストを書く方法です。

納得できませんか? Eric Elliottの記事を読むことをお勧めします

(...)`equal()`は、本質的に、すべての単体テストが答えなければならない2つの最も重要な質問に答えますが、ほとんどのテストは答えていません

  • 実際の出力は何ですか?
  • 期待される出力は何ですか?

これらの2つの質問に答えずにテストを終了した場合、実際の単体テストは行っていません。ずさんで中途半端なテストを行っています。

実際に必要なのは、`fetchProducts`タスクが正しい関数と正しい引数で呼び出しをyieldすることを確認することです。

ジェネレーター内から非同期関数を直接呼び出す代わりに、**関数呼び出しの説明のみをyieldできます**。つまり、次のようなオブジェクトをyieldします

// Effect -> call the function Api.fetch with `./products` as argument
{
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でアクションクリエーターを使用して、ストアによって実行されるアクションを記述するプレーンオブジェクトを作成するのと同じように、`call`は関数呼び出しを記述するプレーンオブジェクトを作成します。redux-sagaミドルウェアは、関数呼び出しを実行し、解決されたレスポンスでジェネレーターを再開する処理を行います。

これにより、Redux環境外でジェネレーターを簡単にテストできます。`call`はプレーンなオブジェクトを返すだけの関数だからです。

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

これで何もモックする必要がなくなり、基本的な等価テストで十分です。

これらの*宣言的呼び出し*の利点は、ジェネレーターを反復処理し、連続してyieldされた値に対して`deepEqual`テストを実行することで、Saga内のすべてのロジックをテストできることです。これは真のメリットです。複雑な非同期操作がブラックボックスではなくなり、どれほど複雑であっても、操作ロジックを詳細にテストできます。

`call`はオブジェクトメソッドの呼び出しもサポートしています。次の形式を使用して、呼び出される関数に`this`コンテキストを提供できます

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

`apply`はメソッド呼び出し形式のエイリアスです

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

`call`と`apply`は、Promise結果を返す関数に適しています。別の関数`cps`を使用して、Nodeスタイルの関数(例:`fn(...args, callback)`。ここで`callback`は`(error, result) => ()`の形式)を処理できます。`cps`は継続渡しスタイルの略です。

例えば

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

`cps`も`call`と同じメソッド呼び出し形式をサポートしています。

宣言的エフェクトの完全なリストは、APIリファレンスにあります。