@vijah/saga-tester
v2.3.0
Published
Order-independent tester library for redux-saga
Downloads
5
Readme
@vijah/saga-tester
A tester library for redux-saga, reverse-engineered from the API documentation, offering the following features:
- Order-independent config (changing yield order does not break the test, making your tests less fragile).
- Runs the entire saga from start to finish with one holistic config.
- Handles concurrent task executions, error handling, task cancellation and action propagation (including channels), like redux-saga.
- Handles the entire (documented) redux-saga api (though there are some ambiguities to clear up, see todo.md).
- Unit tests for this project also act as a neat reference for how to use redux-saga.
Install
yarn add @vijah/saga-tester --dev
npm install --save-dev @vijah/saga-tester
API
new SagaTester(saga, config).run(sagaArgs);
Example
Given the following saga method:
import selector from 'path/to/selector';
import generator from 'path/to/generator';
const someMethod = () => {};
const someAction = (a, b) => ({ type: 'someType', a, b });
const actualSelector = () => createSelector((state) => state.reducerKey, (stateData) => stateData);
function* mySaga(param) {
const callResult = yield call(someMethod, param);
const actualSelectorResult = yield select(actualSelector());
yield put(someAction(callResult, actualSelectorResult));
const selectorResult = yield select(selector());
const generatorResult = yield generator(selectorResult);
const takeResult = yield take('someType');
return { generatorResult, takeValue: takeResult.value };
}
We can test it the following way:
jest.mock('path/to/selector', () => {
const { mockSelector } = jest.requireActual('saga-tester');
return mockSelector('someSelector');
});
jest.mock('path/to/generator', () => {
const { mockGenerator } = jest.requireActual('saga-tester');
return mockGenerator(jest.requireActual('path/to/generator'));
});
const result = new SagaTester(mySaga, {
selectorConfig: { someSelector: 'baz', reducerKey: 'reducerValue' },
expectedCalls: [
{ name: 'someMethod', times: 1, params: ['foo'], output: 'bar' },
{ name: 'someGenerator', times: 1, params: ['baz'], output: 'brak' },
],
expectedActions: [{ action: someAction('bar', 'reducerValue'), times: 1 }],
effectiveActions: [{ type: 'someType', value: 'someValue' }],
}).run('foo'); // If the config is not respected, a detailed error is thrown here!
expect(result).toEqual({ generatorResult: 'brak', takeValue: 'someValue' });
config.selectorConfig
selectorConfig
: Object
that acts as the redux store.
Additionally, you can mock a selector using mockSelector, and its ID in the selectorConfig will give its value.
To avoid bad configs, if a real selector returns undefined, the saga will fail.
If you want a selector to return an undefined value without failing, set config.options.passOnUndefinedSelector
to true.
config.expectedActions
expectedActions
: Array
where each element is an action matcher (dispatched with 'put')
Each element of the array is a tuple of times
, strict
, action
or type
(only one of action
and type
must be provided).
For instance, if someAction
is called twice, once as someAction('abc')
and once as someAction(42, 42)
,
and if doOtherAction
of type 'TYPE' is called with unknown parameters, an appropriate config is:
[{ times: 1, action: someAction('abc') }, { action: someAction(42, 42) }, { type: 'TYPE' }]
Note that if times
is not provided, an error is thrown if the method is never called.
The strict
flag causes an error to be thrown the moment a non-matching action with a same type is dispatched.
It is true
by default. Setting it to false
will ignore similar actions with non-matching parameters.
config.expectedCalls
expectedCalls
: Array
where each object has a name
property being the name of received method (dispatched with call
, fork
or spawn
-- note that the retry
effect is treated as a call
).
E.g. if someCall
is called once with call(someCall, 'abc')
and expected output 'asd', and once with call(someCall, 42, 42)
:
expectedCalls: [
{ name: 'someCall', times: 1, params: ['abc'], output: 'asd' },
{ name: 'someCall', params: [42, 42] },
],
If times
is not provided, it acts as "at least once", i.e. an error is thrown if the method is never called.
output
is the mocked result of the call.throw
is similar to output, except the value ofthrow
is thrown. Useful to simulate errors.call
, if "true" means that the method is actually called (and if it is a generator, it is run), and the result of the generator becomes its output.wait
isfalse
by default, meaning it will be run immediately. If the value is anumber
ortrue
, it will create a pseudo-task that is only ran after some time (see Concurrent behavior).
Only one of output
, throw
or call: true
should ever be provided.
Note that if you want the SAME CALL (same method, same parameters) to yield different results (e.g. if testing an infinite loop), you can duplicate the entry in expectedCalls
and change its execution properties. The entry that will be executed will be the first non-satisfied entry in terms of times called.
Mocking generators
Generally, generators
will work seamlessly with SagaTester. However, there is an edge case: if they are yielded. Yielding a generator means SagaTester receives a nameless generator method, which it cannot match against the name
property. The mockGenerator
provides the ability to inject the name inside the generator, allowing it to be matched by SagaTester.
Using mockGenerator
is unnecessary if the generator is called inside a call
, fork
or spawn
effect, since the effect receives the named function and not a running generator object.
The recommended ways of mocking a generator is by forwarding the entire module in mockGenerator
, which can receive:
- an object (all properties that are generator methods are wrapped with metadata that sagaTester recognizes)
- a direct generator method (wrapped with metadata that sagaTester recognizes)
- a string (recommended only if you want to force a new name on your generator for sagaTester to detect; this mock is empty and should never be called with
call: true
).
Example of mockGenerator
:
jest.mock('path/to/generator', () => {
const { mockGenerator } = jest.requireActual('saga-tester');
return mockGenerator(jest.requireActual('path/to/generator'));
});
...
// path/to/generator.js :
export { generator1, generator2, notAGenerator }; // <= notAGenerator will not be mocked
...
// your test:
new SagaTester(saga, {
expectedCalls: [
{ name: 'generator1', params: ['foo'] },
{ name: 'generator2', params: ['bar'] },
],
}).run();
config.effectiveActions
effectiveActions
: Action[]
Indicating which actions are "active" in the context of take
, takeEvery
, takeLatest
, takeLeading
, debounce
, throttle
effects. By default, if effectiveActions
is not specified, the first argument of the "run" method is considered to be a contextual action.
Each time an effect "consumes" an effectiveActions
, it is removed from the list. If an effect finds no match in effectiveActions
, normal concurrent behavior happens.
Partial param matching
When providing a params
array to match, you can use PLACEHOLDER_ARGS
to specify a logic for matching different from equality.
import { PLACEHOLDER_ARGS } from 'saga-tester';
...
expectedCalls: [{ name: 'foo', times: 1, params: [PLACEHOLDER_ARGS.ANY, PLACEHOLDER_ARGS.TASK, PLACEHOLDER_ARGS.TYPE('number')] }],
PLACEHOLDER_ARGS.ANY
inside aparams
array to indicate an argument that is not important.PLACEHOLDER_ARGS.TASK
inside aparams
array to indicate a task object of any content.PLACEHOLDER_ARGS.TYPE(type)
inside aparams
array to indicate a value oftypeof type
.PLACEHOLDER_ARGS.FN((value) => boolean)
inside aparams
array to indicate a value for which the method returns true.
Concurrent behavior
SagaTester can simulate concurrently executing tasks, and these tasks can be made to execute after a certain pseudo-delay, which can cause them to execute in a specific order, which can be useful to test code which, for instance, needs one task to finish first, or for a cancellation to happen mid-execution.
The pseudo-delay of call
, fork
, or spawn
effects can be configured using expectedCalls[-].wait
:
- If
wait
is falsey, the work will be ran immediately. - If it is a
number
, it will wait that given number (it is a pseudo-delay, meaning the test does not actually wait; the number dictates in which order to run the tasks). - If it is
true
, it will be ran only when all other tasks which can be run have ran. - All pending work with identical
wait
are ran simultaneously.
The supported saga effects simulate redux-saga
behavior, meaning that:
- A task will wait for a
join
to resolve, - A task will wait for
fork
'ed tasks to finish before resolving, but notspawn
'ed tasks. - Cancellation will spread to the children.
- Unhandled errors will bubble up from the children to the parents, and cause siblings to be cancelled if the parent cannot handle the error.
all
will await all of its children.race
resolves when one of its children completes, and cancels all of the losers.delay(time)
acts as a task withwait: time
.- When multiple tasks are blocked, the fastest task (lowest
wait
) is ran. - A task will block after a
take
effect, unblocking only when the right action is dispatched. - A higher effect method like
takeLeading
,takeLatest
,takeEvery
,debounce
andthrottle
will create new tasks when matching actions, in the manner specified in the redux-saga api (see tests for examples).
Handling promises
To correctly handle promises, which includes yielded promises, call
containing promises, or async redux-thunk-style actions with promises, you must use runAsync
instead of run
. You can see examples in the reduxThunkActions
tests.
SagaTester will fail if the promises remain unresolved while nothing else is happening (it will interpret it as a deadlock). You should consider mocking your promises or mocking the relevant setTimeouts.
Handling setTimeout
To handle setTimeout correctly, you will need to mock timers and to run them using side effects
(see below). There is no built-in way to mock timers, but most javascript unit test libraries offer ways to do it. reduxThunkActions
tests have examples of timers mocked using the jest
library.
config.sideEffects
Side effects are an advanced element of SagaTester which are useful to test awkward cases like infinite loops, where you may want to test a case of a loop running once, but without causing your test to loop infinitely itself.
Side effects are a way to act "as if" there were additional things going on outside of the tested saga, and can include:
{ wait?: number | boolean, effect: put(someAction) }
{ wait?: number | boolean, effect: fork(someGeneratorFunction) }
{ wait?: number | boolean, effect: spawn(someGeneratorFunction) }
{ wait?: number | boolean, effect: call(someMethod) }
- useful to run timers{ wait?: number | boolean, effect: cancel() }
- this will cancel the main saga specifically{ wait?: number | boolean, changeSelectorConfig: (prevSelectorConfig) => newSelectorConfig }
- altersconfig.selectorConfig
for the rest of the run
Side effects do not register in config.expectedActions
or config.expectedCalls
and therefore cannot fail your test.
For examples, you can check the sideEffects tests.
config.options
These offer additional hooks to modify how sagaTester runs.
config.options.stepLimit
, default:1000
. When sagaTester has run for this many steps, it fails. This helps detect infinite loops.config.options.usePriorityConcurrency
, default:false
. Iffalse
, when e.g.task1.wait = 40
runs whiletask2.wait = 60
is pending,task2
will be lowered towait = 20
(60 - 40 = 20). IfusePriorityConcurrency
istrue
, task timers are not lowered, and instead act like priority weights.config.options.waitForSpawned
, default:false
. Iffalse
, a spawned task will only resolve if it is fast enough to run during the execution of the parent saga. Iftrue
, each spawned task is awaited when the parent saga finishes, and sagaTester only completes when all spawned tasks have resolved.config.options.executeTakeGeneratorsOnlyOnce
, default:false
.- If
true
, effectsdebounce
,throttle
,takeEvery
,takeLeading
andtakeLatest
will only ever be executed once. - If
false
, these effects will create as many tasks as would be created in a normal saga execution.
- If
config.options.ignoreTakeGenerators: pattern
, default: empty. Any action matched by the pattern (which can be a list, just like in the redux-saga api) will not trigger any take generators.config.options.swallowSpawnErrors
, default:false
. Iftrue
, ignores errors thrown byspawn
'ed tasks to prevent interrupting sagaTester.config.options.reduxThunkOptions
, default:{}
. Passed as a third parameter to redux-thunk-style actions.config.options.passOnUndefinedSelector
, default:false
. Iffalse
, when a selector returns undefined, SagaTester fails, reminding the user to configure it.config.options.failOnUnconfigured
, default:true
. Iftrue
, aspawn
,fork
,call
or yielded generator, which has aname
which does not match any entry inconfig.expectedCalls
will cause SagaTester to fail. IffailOnUnconfigured
isfalse
, the unmatched call will default to{ call: true, wait: false }
. Note that if an entry'sname
property matches but arguments do not, SagaTester will fail regardless of this option, as it is likely to be a misconfiguration.config.options.reducers
, default: passThrough. Can either be a reducer function(state, action) => state
, or an object of reducer keys whose values are reducers. If provided, all actions will run through the reducers, modifying the selectorConfig during the test execution. If the action is async, it will only modify the state when resolving.config.options.context
, default:{}
. The initial context for the tested saga, as returned bygetContext
effects.
Debugging
The config
of the tester can contain a property debug
which has options determining what to log.
unblock
will log when executing the top priority call.bubble
will log when a task is finished and it needs to be "bubbled" up the dependency tree, possibly unblocking other tasks which depended on it.interrupt
will log when a task cannot be run immediately by the tester. This step can be noisy due to SagaTester needing to trigger context shifts for the sake of correct-order execution.
The value of each debug property can be: true
, false
, a number
representing the task id (which depends on the order in which it was created - this order is deterministic, so it will always be the same), a string
representing the name or method associated with the task, or a list of string
or number
if several tasks are to be monitored. Example:
new SagaTester(saga, { debug: { bubble: ['foo', 3], unblock: true } }).run();
Roadmap
State of the library
SagaTester was designed to be detached from as many dependencies as possible.
The need for maintenance in this library is not large, including pretty-format
, jest-diff
, lodash.isequal
and indirectly (via matching string literals and api-mock-up), redux-saga
and reselect
.
Future features
See todo.md
Other ideas which we have no plans to work on, but which could be neat:
- Automatic mocking of generators.
Mocking generators must be made manually by wrapping the generators inside mockGenerator; there is currently no other way of naming the resulting generator method. A babel plugin could be made to run on all relevant javascript, wrapping all exports of generator methods inside mockGenerator... If anyone ever codes this, that would be nice, although it should be opt-in (adding a generic import to the test file) so as not to pollute non-saga tests.
That said, uncalled generators (like call(someGenerator, someArg)
, or fork(someGenerator, someArg)
) are named since we are passing the method and not the generator created by the method. This means mocking generators is ONLY useful if they are yielded (not including yield*
) and ONLY if the user wishes to intercept the call (but if so, the user can just use call
). Meaning the use case is very niche. What it would benefit is a slightly less confusing experience to inexperienced users.