- Remove deprecated core-js [#388]#388
- Fix the typescript 4.* type definition issue [#387]#387
- Fix peer dependencies [#370]#370
- Add peer dependencies [#369]#369
- Remove package-lock.json
- Retry helper support #354
- Drop support on Node 8 downward
- Fix typescript issues #357 #358 #361 #354 #350 #343 #313
- Update babel 7
- Fix typescript issues with redux-saga 1.1.1
- Fix typescript issue #308
- Fix typo by #287 Many thanks to @torpeyp
- fix: typesafe testSaga & expectSaga by #270 Many thanks to @TheryFouchter
This beta release adds support for redux-saga v1.0.1
This release doesn't include any testing API changes. Only fixing the compatibility with redux-saga v1.0.1.
Please report any bugs with detailed information to the issues page.
This beta release adds support for redux-saga v1.0.0-beta.1.
The changes in redux-saga weren't too drastic, but they did warrant some internal changes inside redux-saga-test-plan. So there might be unanticipated bugs in redux-saga-test-plan. Please report any bugs with detailed information to the issues page.
Overall, certain deprecated APIs have been removed and support for new effects
have been added. This release won't work with previous versions (v0.x.x) of
redux-saga. The full list of changes to expectSaga
and testSaga
are below.
- Renamed
put.resolve
assertion and matcher toputResolve
to mirror redux-saga. - Renamed
take.maybe
assertion and matcher totakeMaybe
to mirror redux-saga. - Removed support for yielding parallel effects via arrays.
- Added
delay
assertion for the newdelay
effect. - Added
takeLeading
assertion for the newtakeLeading
effect.
- Removed deprecated
takeEvery
,takeLatest
,throttle
,takeEveryFork
,takeLatestFork
, andthrottleFork
assertions because redux-saga removed support for yielding/delegating to saga helpers in favor of yielding effect creators instead. - Renamed
takeEveryEffect
totakeEvery
to support thetakeEvery
effect. - Renamed
takeLatestEffect
totakeLatest
to support thetakeLatest
effect. - Renamed
throttleEffect
tothrottle
to support thethrottle
effect. - Removed deprecated
takem
. - Renamed
put.resolve
assertion toputResolve
to mirror redux-saga. - Renamed
take.maybe
assertion totakeMaybe
to mirror redux-saga. - Removed support for asserting parallel effects via yielded arrays.
- The TypeScript typings have not been updated, mainly due to my lack of experience with TypeScript. If you'd like to help update the typings, please comment on #201.
If you yield an iterator inside of a saga, then Redux Saga Test Plan will now ensure that provided values works inside the yielded iterator. See the example below.
import { put, select } from 'redux-saga/effects';
import { expectSaga } from 'redux-saga-test-plan';
const selector = state => state.test;
function* mainSaga() {
function* innerSaga() {
const result = yield select(selector);
return !!result;
}
const result = yield innerSaga();
if (result) {
yield put({ type: 'DATA', payload: 42 });
}
}
test('provides value for yielded iterators', () => {
return expectSaga(mainSaga)
.provide([
[select(selector), true]
])
.put({ type: 'DATA', payload: 42 })
.run();
});
Credit @matoilic for adding support.
- You can now use
getContext
andsetContext
assertions, providers, and matchers withexpectSaga
. - You can now use
getContext
andsetContext
assertions withtestSaga
.
- (#183) You can now provide mock values for nested forks and spawns.
Credit @jessjenk.
- Fix incorrect TypeScript typing for
silentRun
(#154, credit @mrijke)
function* saga() {
const { name, age } = yield all({
name: select(selectors.getName),
age: select(selectors.getAge),
});
yield put({ type: 'USER', payload: { name, age } });
}
function* saga(id) {
const [user] = yield race([
call(getUser, id),
call(delay, 1000),
]);
if (user) {
yield put({ type: 'USER', payload: user });
} else {
yield put({ type: 'TIMEOUT' });
}
}
- Include
*.d.ts
files in package.jsonfiles
property.
Thanks to @sharkBiscuit for adding TypeScript typings support in #159.
Match redux-saga support for action creators that have a toString
function defined. If you're using something like redux-actions, then you can take
your action creators directly. This was sort of working before, but redux-saga-test-plan treated it like a function to call and check if it should match an action. Because the action creator returned a truthy object, it took the action anyway. Now, redux-saga-test-plan uses toString
to convert the action creator into a matchable string pattern like redux-saga.
import { call, put, take } from 'redux-saga/effects';
import { expectSaga } from 'redux-saga-test-plan';
const actionCreatorWithToString = payload => ({ type: 'TO_STRING_ACTION', payload });
actionCreatorWithToString.toString = () => 'TO_STRING_ACTION';
function* saga(fn) {
const action = yield take(actionCreatorWithToString);
yield put({ type: 'DONE', payload: action.payload });
}
test('takes action creators with toString defined', () => {
return expectSaga(sagaTakeActionCreatorWithToString, spy)
.put({ type: 'DONE', payload: 42 })
.dispatch(actionCreatorWithToString(42))
.run();
});
- Remove unnecessary npm package dependency on GitBook plugins
- Ensure that actions dispatched inside sagas are immediately handled by reducers.
Related PR: #154
(credit @gjdenhertog)
Update peerDependencies
to support redux-saga 0.16.
The object resolved by the Promise
returned from the run
method now includes
an array called allEffects
that contains all of the effects yielded by your
saga. You can use this array to test specific ordering of effects.
(credit @sbycrosz)
function* saga() {
yield call(identity, 42);
yield put({ type: 'HELLO' });
}
it('exposes all yielded effects in order', () => {
return expectSaga(saga)
.run()
.then((result) => {
const { allEffects } = result;
expect(allEffects).toEqual([
call(identity, 42),
put({ type: 'HELLO' }),
]);
});
});
Flow types available in decls/index.js
in the source
repo are now available in
the npm package.
- Rejected
Promises
inside sagas no longer causeUnhandledPromiseRejectionWarning
.
Related issue: #123
(credit @MehdiZonjy)
The Promise
returned by the run
method now resolves with an object that
contains any effects yielded by your saga and sagas that it forked. You can use
this for finer-grained control over testing exact number of effects. Be careful
when testing top-level sagas that fork other sagas because the effects object
will include yielded effects from all forked sagas too. There currently is no
way to distinguish between effects yielded by the top-level saga and forked
sagas.
function* userSaga(id) {
const user = yield call(fetchUser, id);
const pet = yield call(fetchPet, user.petId);
yield put({ type: 'DONE', payload: { user, pet } });
}
it('exposes effects', () => {
const id = 42;
const petId = 20;
const user = { id, petId, name: 'Jeremy' };
const pet = { name: 'Tucker' };
return expectSaga(saga, id)
.provide([
[call(fetchUser, id), user],
[call(fetchPet, petId), pet],
])
.run()
.then((result) => {
const { effects } = result;
expect(effects.call).toHaveLength(2);
expect(effects.put).toHaveLength(1);
expect(effects.call[0]).toEqual(call(fetchUser, id));
expect(effects.call[1]).toEqual(call(fetchPet, petId));
expect(effects.put[0]).toEqual(
put({ type: 'DONE', payload: { user, pet } })
);
});
});
Of course, async
functions work nicely with this feature too.
it('exposes effects using async functions', async () => {
const id = 42;
const petId = 20;
const user = { id, petId, name: 'Jeremy' };
const pet = { name: 'Tucker' };
const { effects } = await expectSaga(saga, id)
.provide([
[call(fetchUser, id), user],
[call(fetchPet, petId), pet],
])
.run();
expect(effects.call).toHaveLength(2);
expect(effects.put).toHaveLength(1);
expect(effects.call[0]).toEqual(call(fetchUser, id));
expect(effects.call[1]).toEqual(call(fetchPet, petId));
expect(effects.put[0]).toEqual(
put({ type: 'DONE', payload: { user, pet } })
);
});
The available effects are:
actionChannel
call
cps
fork
join
put
race
select
take
The returned Promise
also resolves with a toJSON
method that serializes the
effects to a form appropriate for snapshot testing.
it('can be used with snapshot testing', () => {
return expectSaga(saga)
.run()
.then((result) => {
expect(result.toJSON()).toMatchSnapshot();
});
});
If you're using the withReducer
function, you can test the final store state
after your saga finishes in two ways.
You can use the hasFinalState
method assertion in your expectSaga
method
chain.
const initialDog = {
name: 'Tucker',
age: 11,
};
function dogReducer(state = initialDog, action) {
if (action.type === 'HAVE_BIRTHDAY') {
return {
...state,
age: state.age + 1,
};
}
return state;
}
function* saga() {
yield put({ type: HAVE_BIRTHDAY });
}
it('tests final store state', () => {
return expectSaga(saga)
.withReducer(dogReducer)
.hasFinalState({
name: 'Tucker',
age: 12,
})
.run();
});
Or the returned Promise
resolves with a storeState
object to test.
it('tests final store state', () => {
return expectSaga(saga)
.withReducer(dogReducer)
.run()
.then((result) => {
expect(result.storeState).toEqual({
name: 'Tucker',
age: 12,
});
});
});
The returned Promise
also resolves with the top-level saga's return value.
function* saga() {
const data = yield call(someApi);
return data;
}
it('exposes the return value', () => {
return expectSaga(saga)
.provide([
[call(someApi), 42],
])
.run()
.then((result) => {
expect(result.returnValue).toEqual(42);
});
});
You can silence timeout warnings easier with the silentRun
method. The
silentRun
method returns the same Promise
as the run
method. (credit
@Bebersohl)
.silentRun() // same as .run({ silenceTimeout: true })
.silentRun(500) // same as .run({ silenceTimeout: true, timeout: 500 })
- Update the docs about supported Redux Saga versions.
- Update the docs about supported Redux Saga versions.
Redux Saga introduced some new features that merited a major bump in Redux Saga Test Plan. The upgrade path is minimal but involves a couple breaking changes. To learn more about Redux Saga v0.15.x, please visit the release page.
Please make sure to thoroughly read the release notes below for all the new features and breaking changes.
Added support for a dynamic all
provider to handle parallel effects. This is
now the preferred pattern for parallel effects, meaning yielding arrays is
deprecated in Redux Saga.
You can still provide values for effects inside of an all
effect just like you
could do with effects yielded inside an array.
import { all, call, put } from 'redux-saga/effects';
import { expectSaga } from 'redux-saga';
import * as matchers from 'redux-saga/matchers';
const identity = value => value;
function* saga() {
const results = yield all([
put({ type: 'HELLO' }),
call(identity, 42),
]);
yield put({ type: 'RESULTS', payload: results });
}
it('works with `all`', () => {
return expectSaga(saga)
.provide({
all: () => ['foo', 'bar'],
})
.put({ type: 'RESULTS', payload: ['foo', 'bar'] })
.run();
});
it('internal effects can be provided', () => {
return expectSaga(saga)
.provide([
[matches.put.actionType('HELLO'), 'foo'],
[matches.call.fn(identity), 'bar'],
])
.put({ type: 'RESULTS', payload: ['foo', 'bar'] })
.run();
});
This works out of the box with Redux Saga Test Plan.
const context = { foo: () => 1 };
function* saga() {
const value = yield call([context, 'foo']);
yield put({ type: 'VALUE', payload: value });
}
it('works with call', () => {
return expectSaga(saga)
.call([context, 'foo'])
.run();
});
it('works with partial assertions', () => {
return expectSaga(saga)
.call.fn(context.foo)
.run();
});
it('works with providers', () => {
return expectSaga(saga)
.provide([
[call([context, 'foo']), 42],
])
.put({ type: 'VALUE', payload: 42 })
.run();
});
- Officially removed the
provideInForkedTasks
option for dynamic providers. - Removed the dynamic
parallel
provider. Please use the dynamicall
provider mentioned above in the new features.
Added support for an all
assertion. This is mentioned in the breaking changes
below, but all
can be used in place of the old parallel
assertion if you're
still yielding arrays.
import { all, call, put } from 'redux-saga/effects';
import { expectSaga } from 'redux-saga';
import * as matchers from 'redux-saga/matchers';
const identity = value => value;
function* saga() {
const results = yield all([
put({ type: 'HELLO' }),
call(identity, 42),
]);
yield put({ type: 'RESULTS', payload: results });
}
it('works with `all`', () => {
testSaga(saga)
.next()
.all([
put({ type: 'HELLO' }),
call(identity, 42),
])
.next(['foo', 'bar'])
.put({ type: 'RESULTS', payload: ['foo', 'bar'] });
});
This works out of the box with Redux Saga Test Plan.
const context = { foo: () => 1 };
function* saga() {
const value = yield call([context, 'foo']);
yield put({ type: 'VALUE', payload: value });
}
it('works with call', () => {
testSaga(saga)
.next()
.call([context, 'foo']);
});
- Removed the
parallel
assertion. Please use theall
assertion mentioned above in the new features. Theall
assertion works with yieldedall
effects and deprecated yielded arrays.
-
Removed the default
testSaga
export. Use named imports forexpectSaga
andtestSaga
.import testSaga from 'redux-saga-test-plan'; // <-- this is gone import { expectSaga, testSaga } from 'redux-saga-test-plan'; // <-- this is correct
- No support has been added for the new
getContext
andsetContext
effect creators yet. I'm waiting on the docs to be filled in before I feel comfortable supporting these effects.
Attempting to rename the sagaWrapper
function in expectSaga
to the name of a
forked saga caused a thrown error in PhantomJS. This is a bug in PhantomJS that
will likely never be fixed:
issue. Accordingly, Redux
Saga Test Plan catches the thrown error now.
NOTE: this means that you can't depend on the task name
property being
correct in PhantomJS. For expectSaga
to work properly, it necessarily has to
wrap forked sagas with sagaWrapper
in order to intercept effects with
providers.
Update docs with some examples.
expectSaga
- Ensure that the task object for forked/spawned
sagaWrapper
tasks has the same name as the wrapped saga. Check #96 for more context. - Ensure that forked/spawned sagas can detect cancellation.
- Ensure that the task object for forked/spawned
- Effects nested in a
race
effect weren't being properly handled to ensure that nested generator functions could receive provided values. - Tested sagas were being blocked by redux-saga if function calls or providers
returned falsy values that weren't
null
orundefined
. Check #94 for more context.
Providers now automatically work in forked/spawned sagas, meaning the
provideInForkedTasks
option is no longer needed. If you are still using the
option, Redux Saga Test Plan will print a warning message, letting you know that
you can safely remove it. More details are provided later in these release
notes, but the internal limitation that necessitated the provideInForkedTasks
option has been fixed.
Lots of new features are now available in expectSaga
to make testing easier!
Please read through the awesome additions below.
You can now provide mock values in a terser manner via static providers. Pass in
an array of tuple pairs (array pairs) into the provide
method. For each pair,
the first element should be a matcher for matching the effect and the second
effect should be the mock value you want to provide. You can use effect creators
from redux-saga/effects
as matchers or import matchers from
redux-saga-test-plan/matchers
. The benefit of using Redux Saga Test Plan's
matchers is that they also offer partial matching. For example, you can use
call.fn
to match any calls to a function without regard to its arguments.
import { call, put, select } from 'redux-saga/effects';
import { expectSaga } from 'redux-saga-test-plan';
import * as matchers from 'redux-saga-test-plan/matchers';
import api from 'my-api';
import * as selectors from 'my-selectors';
function* saga() {
const id = yield select(selectors.getId);
const user = yield call(api.fetchUser, id);
yield put({ type: 'RECEIVE_USER', payload: user });
}
it('provides a value for the API call', () => {
return expectSaga(saga)
.provide([
// Use the `select` effect creator from Redux Saga to match
[select(selectors.getId), 42],
// Use the `call.fn` matcher from Redux Saga Test Plan
[matchers.call.fn(api.fetchUser), { id: 42, name: 'John Doe' }],
])
.put({
type: 'RECEIVE_USER',
payload: { id: 42, name: 'John Doe' },
})
.run();
});
You can simulate errors with static providers via the throwError
function from
the redux-saga-test-plan/providers
module. When providing an error, wrap it
with a call to throwError
to let Redux Saga Test Plan know that you want to
simulate a thrown error.
import { call, put } from 'redux-saga/effects';
import * as matchers from 'redux-saga-test-plan/matchers';
import { throwError } from 'redux-saga-test-plan/providers';
import api from 'my-api';
function* userSaga(id) {
try {
const user = yield call(api.fetchUser, id);
yield put({ type: 'RECEIVE_USER', payload: user });
} catch (e) {
yield put({ type: 'FAIL_USER', error: e });
}
}
it('handles errors', () => {
const error = new Error('error');
return expectSaga(userSaga)
.provide([
[matchers.call.fn(api.fetchUser), throwError(error)],
])
.put({ type: 'FAIL_USER', error })
.run();
});
If you prefer to use the object providers syntax, you can now supply multiple
object providers via a couple methods. The easiest way is to pass in an array of
object providers to the provide
method. Provider functions will be composed
according to the effect type, meaning the provider functions in the first object
will be called before subsequent provider functions in the array.
Because provider functions are composed, they are similar to middleware. The
next
function argument inside provider functions allows you to delegate to the
next provider in the middleware stack. If no more providers are available, then
next
will delegate to Redux Saga to handle the effect as normal.
import { call, put, select } from 'redux-saga/effects';
import api from 'my-api';
import * as selectors from 'my-selectors';
function* saga() {
const user = yield call(api.findUser, 1);
const dog = yield call(api.findDog);
const greeting = yield call(api.findGreeting);
const otherData = yield select(selectors.getOtherData);
yield put({
type: 'DONE',
payload: { user, dog, greeting, otherData },
});
}
const fakeUser = { name: 'John Doe' };
const fakeDog = { name: 'Tucker' };
const fakeOtherData = { foo: 'bar' };
const provideUser = ({ fn, args: [id] }, next) => (
fn === api.findUser ? fakeUser : next()
);
const provideDog = ({ fn }, next) => (
fn === api.findDog ? fakeDog : next()
);
const provideOtherData = ({ selector }, next) => (
selector === selectors.getOtherData ? fakeOtherData : next()
);
it('takes multiple providers and composes them', () => {
return expectSaga(saga)
.provide([
{ call: provideUser, select: provideOtherData },
{ call: provideDog },
])
.put({
type: 'DONE',
payload: {
user: fakeUser,
dog: fakeDog,
greeting: 'hello',
otherData: fakeOtherData,
},
})
.run();
});
An alternative to supplying multiple provider objects is to only pass one object
into provide
and use the composeProviders
function to compose multiple
provider functions for a specific effect. You can import the composeProviders
function from the redux-saga-test-plan/providers
module. The provider
functions are composed from left to right.
import { composeProviders } from 'redux-saga-test-plan/providers';
it('takes multiple providers and composes them', () => {
return expectSaga(saga)
.provide({
call: composeProviders(
provideUser,
provideDog
),
select: provideOtherData,
})
.put({
type: 'DONE',
payload: {
user: fakeUser,
dog: fakeDog,
greeting: 'hello',
otherData: fakeOtherData,
},
})
.run();
});
Static providers can provide dynamic values too. Instead of supplying a static
value, you can supply a function that produces the value. This function takes as
arguments the effect as well as the next
function in case you want to the next
provider or Redux Saga to handle the effect. Additionally, you must wrap the
function with a call to the dynamic
function from the
redux-saga-test-plan/providers
module.
import { call, put } from 'redux-saga/effects';
import * as matchers from 'redux-saga-test-plan/matchers';
import { dynamic } from 'redux-saga-test-plan/providers';
const add2 = a => a + 2;
function* someSaga() {
const x = yield call(add2, 4);
const y = yield call(add2, 6);
const z = yield call(add2, 8);
yield put({ type: 'DONE', payload: x + y + z });
}
const provideDoubleIf6 = ({ args: [a] }, next) => (
a === 6 ? a * 2 : next()
);
const provideTripleIfGt4 = ({ args: [a] }, next) => (
a > 4 ? a * 3 : next()
);
it('works with dynamic static providers', () => {
return expectSaga(someSaga)
.provide([
[matchers.call.fn(add2), dynamic(provideDoubleIf6)],
[matchers.call.fn(add2), dynamic(provideTripleIfGt4)],
])
.put({ type: 'DONE', payload: 42 })
.run();
});
Prior to v2.4.0, if you provided a value for a particular effect, then you were unable to also assert that your saga yielded that particular effect. This is now fixed, so you can assert on these effects. One use case for this is if you wanted to provide a value for any API call but also assert that your saga called the API function with certain arguments.
import { call, put } from 'redux-saga/effects';
import * as matchers from 'redux-saga-test-plan/matchers';
import api from 'my-api';
function* userSaga(id) {
const user = yield call(api.fetchUser, id);
yield put({ type: 'RECEIVE_USER', payload: user });
}
it('handles errors', () => {
const id = 42;
const fakeUser = { id, name: 'John Doe' };
return expectSaga(userSaga, id)
.provide([
// Provide `fakeUser` for the function call
[matchers.call.fn(api.fetchUser), fakeUser],
])
// Still assert that the function was called with the `id`
.call(api.fetchUser, id)
.put({ type: 'RECEIVE_USER', payload: fakeUser })
.run();
});
You can assert the return value of a saga via the returns
method. This only
works for the top-level saga under test, meaning other sagas that are invoked
via call
, fork
, or spawn
won't report their return value.
function* saga() {
return { hello: 'world' };
}
it('returns a greeting', () => {
return expectSaga(saga)
.returns({ hello: 'world' })
.run();
});
Return values from sagas now work with the call
effect with expectSaga
.
import { call, put } from 'redux-saga/effects';
import { expectSaga } from 'redux-saga-test-plan';
it('returns values from other sagas', () => {
function* otherSaga() {
return { hello: 'world' }; // <-- this now works in tests
}
function* saga() {
const result = yield call(otherSaga);
yield put({ type: 'RESULT', payload: result });
}
return expectSaga(saga)
.put({ type: 'RESULT', payload: { hello: 'world' } })
.run();
});
Providers now work with composed/nested sagas invoked via the call
effect.
takeEvery
/takeLatest
workers now receive the dispatched action that triggered them when using theprovideInForkedTasks
option.- Yielding
takeEvery
/takeLatest
inside an array now works with theprovideInForkedTasks
option.
Note: these fixes only apply to the takeEvery
and takeLatest
effect
creators from the redux-saga/effects
module. The takeEvery
and takeLatest
saga helpers from the redux-saga
module are deprecated and therefore
unsupported by Redux Saga Test Plan.
Providers now work with workers given to takeEvery
and takeLatest
if you
supply the provideInForkedTasks
option to expectSaga.provide
.
Note: this applies only to the takeEvery
and takeLatest
effect creators
from the redux-saga/effects
module. The takeEvery
and takeLatest
saga
helpers from the redux-saga
module are deprecated and therefore unsupported by
Redux Saga Test Plan.
The internal sagaWrapper
implementation used by expectSaga
required a
dependency on regeneratorRuntime
. sagaWrapper
was rewritten to explicitly
utilize fsm-iterator instead.
Sagas that used the take
effect creator with patterns besides strings and
symbols (i.e. arrays and functions) were not receiving dispatched actions.
Here's an example of a saga that takes an array that should work now:
function* sagaTakeArray() {
const action = yield take(['FOO', 'BAR']);
yield put({ type: 'DONE', payload: action.payload });
}
it('takes action types in an array', () => {
return expectSaga(sagaTakeArray)
.put({ type: 'DONE', payload: 'foo payload' })
.dispatch({ type: 'FOO', payload: 'foo payload' })
.run();
});
Lots of new features are now available in expectSaga
to make testing easier!
Please read through the awesome additions below.
Sometimes integration testing sagas can be laborious, especially when you have
to mock server APIs for call
or create fake state and selectors to use with
select
.
To make tests simpler, Redux Saga Test Plan allows you to intercept and handle effect creators instead of letting Redux Saga handle them. This is similar to a middleware layer that Redux Saga Test Plan calls providers.
To use providers, you can call the provide
method. The provide
method takes
one argument, an object literal with effect creator names as keys and function
handlers as values. Each function handler takes two arguments, the yielded
effect and a next
callback. You can inspect the effect and return a fake value
based on the properties in the effect. If you don't want to handle the effect
yourself, you can pass it on to Redux Saga by invoking the next
callback
parameter.
Here is an example with Jest to show you how to supply a fake value for an API call:
import { call, put, take } from 'redux-saga/effects';
import { expectSaga } from 'redux-saga-test-plan';
import api from 'my-api';
function* saga() {
const action = yield take('REQUEST_USER');
const id = action.payload;
const user = yield call(api.fetchUser, id);
yield put({ type: 'RECEIVE_USER', payload: user });
}
it('provides a value for the API call', () => {
return expectSaga(saga)
.provide({
call(effect, next) {
// Check for the API call to return fake value
if (effect.fn === api.fetchUser) {
const id = effect.args[0];
return { id, name: 'John Doe' };
}
// Allow Redux Saga to handle other `call` effects
return next();
},
})
.put({
type: 'RECEIVE_USER',
payload: { id: 1, name: 'John Doe' },
})
.dispatch({ type: 'REQUEST_USER', payload: 1 })
.run();
});
Sometimes you're not interested in the exact arguments passed to a call
effect
creator or the payload inside an action from a put
effect. Instead you're only
concerned with if a particular function was invoked via call
or if a
particular action type was dispatched via put
. You can handle these situations
with partial matcher assertions.
Here is an example that uses the convenient matcher helper methods call.fn
and
put.actionType
:
function* userSaga(id) {
try {
const user = yield call(api.fetchUser, id);
yield put({ type: 'RECEIVE_USER', payload: user });
} catch (e) {
yield put({ type: 'FAIL_USER', error: e });
}
}
it('fetches user', () => {
return expectSaga(userSaga)
.call.fn(api.fetchUser)
.run();
});
it('fails', () => {
return expectSaga(userSaga)
.provide({
call() {
throw new Error('Not Found');
},
})
.put.actionType('FAIL_USER')
.run();
});
Notice that we can assert that the api.fetchUser
function was called without
specifying the arguments. We can also assert in a failure scenario that an
action of type FAIL_USER
was dispatched without worrying about the error
property of the action.
You can now negate assertions. Use the not
property before calling an
assertion
. Negated assertions also work with partial matcher assertions!
function* authSaga() {
const token = yield select(authToken);
if (token) {
yield call(api.setToken, token);
}
}
it('does not set the token', () => {
return expectSaga(authSaga)
.withState({})
.not.call.fn(api.setToken)
.run();
});
You can now dispatch actions while a saga is running. This is useful for delaying actions so Redux Saga Test Plan doesn't dispatch them too quickly.
function* mainSaga() {
// Received almost immediately
yield take('FOO');
// Received after 250ms
yield take('BAR');
yield put({ type: 'DONE' });
}
const delay = time => new Promise((resolve) => {
setTimeout(resolve, time);
});
it('can dispatch actions while running', () => {
const saga = expectSaga(mainSaga);
saga.put({ type: 'DONE' });
saga.dispatch({ type: 'FOO' });
const promise = saga.run({ timeout: false });
return delay(250).then(() => {
saga.dispatch({ type: 'BAR' });
return promise;
});
});
While being able to dispatch actions while the saga is running has use cases
besides only delaying, if you just want to delay dispatched actions, you can use
the delay
method. It takes a delay time as its only argument.
it('can delay actions', () => {
return expectSaga(mainSaga)
.put({ type: 'DONE' })
.dispatch({ type: 'FOO' })
.delay(250)
.dispatch({ type: 'BAR' })
.run({ timeout: false });
});
- Forked sagas no longer extend the timeout of
expectSaga
. The original behavior was not properly documented and probably unhelpful behavior anyway. (credit @peterkhayes) - Forked sagas caused the
silenceTimeout
option ofexpectSaga
to not work. This is now fixed. (credit @peterkhayes)
To select
state that might change, you can use the withReducer
method. It
takes two arguments: your reducer and optional initial state. If you don't
supply the initial state, then withReducer
will extract it by passing an
initial action into your reducer like Redux.
const HAVE_BIRTHDAY = 'HAVE_BIRTHDAY';
const AGE_BEFORE = 'AGE_BEFORE';
const AGE_AFTER = 'AGE_AFTER';
const initialDog = {
name: 'Tucker',
age: 11,
};
function dogReducer(state = initialDog, action) {
if (action.type === HAVE_BIRTHDAY) {
return {
...state,
age: state.age + 1,
};
}
return state;
}
function getAge(state) {
return state.age;
}
function* saga() {
const ageBefore = yield select(getAge);
yield put({ type: AGE_BEFORE, payload: ageBefore });
yield take(HAVE_BIRTHDAY);
const ageAfter = yield select(getAge);
yield put({ type: AGE_AFTER, payload: ageAfter });
}
it('handles reducers when not supplying initial state', () => {
return expectSaga(saga)
.withReducer(dogReducer)
.put({ type: AGE_BEFORE, payload: 11 })
.put({ type: AGE_AFTER, payload: 12 })
.dispatch({ type: HAVE_BIRTHDAY })
.run();
});
it('handles reducers when supplying initial state', () => {
return expectSaga(saga)
.withReducer(dogReducer, initialDog)
.put({ type: AGE_BEFORE, payload: 11 })
.put({ type: AGE_AFTER, payload: 12 })
.dispatch({ type: HAVE_BIRTHDAY })
.run();
});
NOTE: expectSaga
is a relatively new feature of Redux Saga Test Plan, and
many kinks may still need worked out and other use cases considered.
Requires global Promise
to be available
Redux Saga Test Plan now exports a new function called expectSaga
for
integration, BDD-style testing!
One downside to unit testing is that it couples your test to your
implementation. Simple reordering of yielded effects in your saga could break
your tests even if the functionality stays the same. If you're not concerned
with the order or exact effects your saga yields, then you can take a
integrative approach, testing the behavior of your saga when run by Redux Saga.
Then, you can simply test that a particular effect was yielded during the saga
run. expectSaga
runs your saga asynchronously, so it returns a Promise
.
import { expectSaga } from 'redux-saga-test-plan';
function identity(value) {
return value;
}
function* mainSaga(x, y) {
const action = yield take('HELLO');
yield put({ type: 'ADD', payload: x + y });
yield call(identity, action);
}
it('works!', () => {
return expectSaga(mainSaga, 40, 2)
// assert that the saga will eventually yield `put`
// with the expected action
.put({ type: 'ADD', payload: 42 })
// dispatch any actions your saga will `take`
.dispatch({ type: 'HELLO' })
// run it
.run();
});
Redux Saga introduced effect creators for the saga helpers takeEvery
,
takeLatest
, and throttle
in order to simply interacting with and testing
these helpers. Please review the example from Redux Saga's release
notes below:
import { takeEvery } from 'redux-saga/effects'
// ...
yield* takeEvery('ACTION', worker) // this WON'T work, as effect is just an object
const task = yield takeEvery('ACTION', worker) // this WILL work like charm
-----
import { takeEvery } from 'redux-saga'
// ...
yield* takeEvery('ACTION', worker) // this will continue to work for now
const task = yield takeEvery('ACTION', worker) // and so will this
Accordingly, Redux Saga Test Plan now supports testing these effect creators via
the respective assertion methods takeEveryEffect
, takeLatestEffect
, and
throttleEffect
. The old patterns of delegating or yielding the helpers
directly is deprecated and may eventually be removed by Redux Saga and Redux
Saga Test Plan. Your are encouraged to move to using the equivalent effect
creators.
Redux Saga renamed takem
to take.maybe
. Redux Saga Test Plan has added an
equivalent take.maybe
assertion method. The former is deprecated but still
available in Redux Saga and Redux Saga Test Plan.
Redux Saga also renamed put.sync
to put.resolve
. Redux Saga Test Plan had
never supported put.sync
, but now supports the renamed put.resolve
. There
are no plans to support put.sync
since it had never been added to Redux Saga
Test Plan, so please move to put.resolve
.
The only real breaking change is that Redux Saga Test Plan drops support for Redux Saga versions prior to 0.14.x. No assertion methods were removed or renamed.
Original idea and credit goes to @christian-schulze.
The inspect
method allows you to inspect the yielded value after calling
next
or throw
. This is useful for handling more complex scenarios such as
yielding nondeterministic values that the effect assertions and general
assertions can't test.
function* saga() {
yield () => 42;
}
testSaga(saga)
.next()
.inspect((fn) => {
expect(fn()).toBe(42);
});
Redux Saga 0.13.0 mainly introduced tweaks to their monitor API, which primarily affected their middleware and internals. Therefore, Redux Saga Test Plan should continue to work just fine with Redux Saga 0.13.0.
- Migrate to jest for testing
- 100% code coverage
- Some internal cleanup
- Rearrange order of unsupported version errors in
createEffectHelperTester
- Remove unsupported version error in
createTakeHelperProgresser
- Fix bug trying to access
utils.is.helper
when it may not be available in older versions of redux-saga.
- Added
flush
effect creator assertion - Add
throttle
saga helper assertion - Backwards-compatible support: attempting to use an effect creator like
flush
or a saga helper likethrottle
on a version of Redux Saga that does not support it will throw an error with a message that your version lacks support. This is primarily to keep from bumping the major version of Redux Saga Test Plan and ensure bug fixes for other features will work for all supported versions of Redux Saga (0.10.x - 0.12.x). - Add support for testing yielded
takeEvery
,takeLatest
, andthrottle
instead of just delegating to them. Use the*Fork
variants:takeEveryFork
,takeLatestFork
, andthrottleFork
. Example below.
import { takeEvery } from 'redux-saga';
import { call } from 'redux-saga/effects';
import testSaga from 'redux-saga-test-plan';
function identity(value) {
return value;
}
function* otherSaga(action, value) {
yield call(identity, value);
}
function* anotherSaga(action) {
yield call(identity, action.payload);
}
function* mainSaga() {
yield call(identity, 'foo');
yield takeEvery('READY', otherSaga, 42);
}
// All good
testSaga(mainSaga)
.next()
.call(identity, 'foo')
.next()
.takeEveryFork('READY', otherSaga, 42)
.finish()
.isDone();
// Will throw
testSaga(mainSaga)
.next()
.call(identity, 'foo')
.next()
.takeEveryFork('READY', anotherSaga, 42)
.finish()
.isDone();
// SagaTestError:
// Assertion 2 failed: expected takeEvery to fork anotherSaga
//
// Expected
// --------
// [Function: anotherSaga]
//
// Actual
// ------
// [Function: otherSaga]
Redux Saga Test Plan now offers assertions for the saga helper functions
takeEvery
and takeLatest
. The difference between these assertions and the
normal effect creator assertions is that you shouldn't call next
on your test
saga beforehand. The takeEvery
and takeLatest
functions in Redux Saga Test
Plan will automatically advance the saga for you. You can read more about
takeEvery
and takeLatest
in Redux Saga's docs
here.
import { takeEvery } from 'redux-saga';
import { call } from 'redux-saga/effects';
import testSaga from 'redux-saga-test-plan';
function identity(value) {
return value;
}
function* otherSaga(action, value) {
yield call(identity, value);
}
function* anotherSaga(action) {
yield call(identity, action.payload);
}
function* mainSaga() {
yield call(identity, 'foo');
yield* takeEvery('READY', otherSaga, 42);
}
// All good
testSaga(mainSaga)
.next()
.call(identity, 'foo')
.takeEvery('READY', otherSaga, 42)
.finish()
.isDone();
// Will throw
testSaga(mainSaga)
.next()
.call(identity, 'foo')
.takeEvery('READY', anotherSaga, 42)
.finish()
.isDone();
// SagaTestError:
// Assertion 1 failed: expected to takeEvery READY with anotherSaga
More helpful error messages (credit @peterkhayes)
Changed error messages to show assertion number if an assertion fails.
function identity(value) {
return value;
}
function* mainSaga() {
yield call(identity, 42);
yield put({ type: 'DONE' });
}
testSaga(mainSaga)
.next()
.call(identity, 42)
.next()
.put({ type: 'READY' })
.next()
.isDone();
// SagaTestError:
// Assertion 2 failed: put effects do not match
//
// Expected
// --------
// { channel: null, action: { type: 'READY' } }
//
// Actual
// ------
// { channel: null, action: { type: 'DONE' } }
You can now restart your saga with different arguments by supplying a variable
number of arguments to the restart
method.
function getPredicate() {}
function* mainSaga(x) {
try {
const predicate = yield select(getPredicate);
if (predicate) {
yield take('TRUE');
} else {
yield take('FALSE');
}
yield put({ type: 'DONE', payload: x });
} catch (e) {
yield take('ERROR');
}
}
const saga = testSaga(mainSaga, 42);
saga
.next()
.select(getPredicate)
.next(true)
.take('TRUE')
.next()
.put({ type: 'DONE', payload: 42 })
.next()
.isDone()
.restart('hello world')
.next()
.select(getPredicate)
.next(true)
.take('TRUE')
.next()
.put({ type: 'DONE', payload: 'hello world' })
.next()
.isDone();
- Some internal variable renaming.
With the recent additions courtesy of @rixth, the API feels solid enough to bump to v1.0.0.
NEW - Finish early (credit @rixth)
If you want to finish a saga early like bailing out of a while
loop, then you
can use the finish
method.
function identity(value) {
return value;
}
function* loopingSaga() {
while (true) {
const action = yield take('HELLO');
yield call(identity, action);
}
}
const saga = testSaga(loopingSaga);
saga
.next()
.take('HELLO')
.next(action)
.call(identity, action)
.next()
.take('HELLO')
.next(action)
.call(identity, action)
.next()
.finish()
.next()
.isDone();
NEW - Assert returned values (credit @rixth)
Assert a value is returned from a saga and that it is finished with the
returns
method.
function* doubleSaga(x) {
return x * 2;
}
const saga = testSaga(doubleSaga, 21);
saga
.next()
.returns(42);
NEW - Save and restore history (credit @rixth)
For more robust time travel, you can use the save
and restore
methods. The
save
method allows you to label a point in the saga that you can return to by
calling restore
with the same label. This can be more useful and less brittle
than using the simple back
method.
function getPredicate() {}
function getFinalPayload() {}
export default function* mainSaga() {
try {
yield take('READY');
const predicate = yield select(getPredicate);
if (predicate) {
yield take('TRUE');
} else {
yield take('FALSE');
}
let payload = yield select(getFinalPayload);
payload %= 101;
yield put({ payload, type: 'DONE' });
} catch (e) {
yield take('ERROR');
}
}
const saga = testSaga(mainSaga);
saga
.next()
.take('READY')
.next()
.select(getPredicate)
.save('before predicate') // <-- save the point before if/else
.next(true)
.take('TRUE')
.next()
.select(getFinalPayload)
.next(42)
.put({ type: 'DONE', payload: 42 })
.next()
.isDone()
.restore('before predicate') // <-- restore history before if/else
.next(false)
.take('FALSE')
.next()
.select(getFinalPayload)
.next(42)
.put({ type: 'DONE', payload: 42 })
.next()
.isDone();
redux-saga-test-plan will now print out actual and expected effects when assertions fail, so you have a better idea why a test is failing.