@brighte/redux-saga-test-plan
Version:
Test Redux Saga with an easy plan
765 lines (629 loc) • 18.3 kB
JavaScript
// @flow
/* eslint-disable no-underscore-dangle */
import { runSaga, stdChannel } from 'redux-saga';
import * as is from '@redux-saga/is';
import * as effects from 'redux-saga/effects';
import assign from 'object-assign';
import { splitAt } from '../utils/array';
import Map from '../utils/Map';
import ArraySet from '../utils/ArraySet';
import { warn } from '../utils/logging';
import { delay, schedule } from '../utils/async';
import identity from '../utils/identity';
import parseEffect from './parseEffect';
import { NEXT, provideValue } from './provideValue';
import { mapValues } from '../utils/object';
import findDispatchableActionIndex from './findDispatchableActionIndex';
import createSagaWrapper, { isSagaWrapper } from './sagaWrapper';
import sagaIdFactory from './sagaIdFactory';
import { coalesceProviders } from './providers/helpers';
import { asEffect } from '../utils/asEffect';
import type { Expectation } from './expectations';
import {
createEffectExpectation,
createReturnExpectation,
createStoreStateExpectation,
} from './expectations';
import {
ACTION_CHANNEL,
ALL,
CALL,
CPS,
FORK,
GET_CONTEXT,
PUT,
RACE,
SELECT,
SET_CONTEXT,
TAKE,
} from '../shared/keys';
const { all, call, fork, race, spawn } = effects;
const INIT_ACTION = { type: '@@redux-saga-test-plan/INIT' };
const defaultSagaWrapper = createSagaWrapper();
function extractState(reducer: Reducer, initialState?: any): any {
return initialState || reducer(undefined, INIT_ACTION);
}
function toJSON(object: mixed): mixed {
if (Array.isArray(object)) {
return object.map(toJSON);
}
if (typeof object === 'function') {
return `@@redux-saga-test-plan/json/function/${object.name ||
'<anonymous>'}`;
}
if (typeof object === 'object' && object !== null) {
return mapValues(object, toJSON);
}
return object;
}
function lacksSagaWrapper(value: Object): boolean {
const { type, effect } = parseEffect(value);
return type !== 'FORK' || !isSagaWrapper(effect.fn);
}
const exposableEffects = {
[TAKE]: 'take',
[PUT]: 'put',
[RACE]: 'race',
[CALL]: 'call',
[CPS]: 'cps',
[FORK]: 'fork',
[GET_CONTEXT]: 'getContext',
[SELECT]: 'select',
[SET_CONTEXT]: 'setContext',
[ACTION_CHANNEL]: 'actionChannel',
};
export default function expectSaga(
generator: Function,
...sagaArgs: mixed[]
): ExpectApi {
const allEffects = [];
const effectStores = {
[TAKE]: new ArraySet(),
[PUT]: new ArraySet(),
[RACE]: new ArraySet(),
[CALL]: new ArraySet(),
[CPS]: new ArraySet(),
[FORK]: new ArraySet(),
[GET_CONTEXT]: new ArraySet(),
[SET_CONTEXT]: new ArraySet(),
[SELECT]: new ArraySet(),
[ACTION_CHANNEL]: new ArraySet(),
};
const expectations: Array<Expectation> = [];
const ioChannel = stdChannel();
const queuedActions = [];
const forkedTasks = [];
const outstandingForkEffects = new Map();
const outstandingActionChannelEffects = new Map();
const channelsToPatterns = new Map();
const dispatchPromise = Promise.resolve();
const nextSagaId = sagaIdFactory();
let stopDirty = false;
let negateNextAssertion = false;
let isRunning = false;
let delayTime = null;
let iterator;
let mainTask;
let mainTaskPromise;
let providers;
let returnValue;
let storeState: any;
function setReturnValue(value: any): void {
returnValue = value;
}
function useProvidedValue(value) {
function addEffect() {
// Because we are providing a return value and not hitting redux-saga, we
// need to manually store the effect so assertions on the effect work.
processEffect({
effectId: nextSagaId(),
effect: value,
});
}
try {
const providedValue = provideValue(providers, value);
if (providedValue === NEXT) {
return value;
}
addEffect();
return providedValue;
} catch (e) {
addEffect();
throw e;
}
}
function refineYieldedValue(value) {
const parsedEffect = parseEffect(value);
const localProviders = providers || {};
const { type, effect } = parsedEffect;
switch (true) {
case type === RACE && !localProviders.race:
processEffect({
effectId: nextSagaId(),
effect: value,
});
return race(parsedEffect.mapEffects(refineYieldedValue));
case type === ALL && !localProviders.all:
return all(parsedEffect.mapEffects(refineYieldedValue));
case type === FORK: {
const { args, detached, context, fn } = effect;
const providedValue = useProvidedValue(value);
const isProvided = providedValue !== value;
if (!detached && !isProvided) {
// Because we wrap the `fork`, we need to manually store the effect,
// so assertions on the `fork` work.
processEffect({
effectId: nextSagaId(),
effect: value,
});
const finalArgs = args;
return fork(
createSagaWrapper(fn.name),
fn.apply(context, finalArgs),
refineYieldedValue,
);
}
if (detached && !isProvided) {
// Because we wrap the `spawn`, we need to manually store the effect,
// so assertions on the `spawn` work.
processEffect({
effectId: nextSagaId(),
effect: value,
});
return spawn(
createSagaWrapper(fn.name),
fn.apply(context, args),
refineYieldedValue,
);
}
return providedValue;
}
case type === CALL: {
const providedValue = useProvidedValue(value);
if (providedValue !== value) {
return providedValue;
}
// Because we manually consume the `call`, we need to manually store
// the effect, so assertions on the `call` work.
processEffect({
effectId: nextSagaId(),
effect: value,
});
const { context, fn, args } = effect;
const result = fn.apply(context, args);
if (is.iterator(result)) {
return call(defaultSagaWrapper, result, refineYieldedValue);
}
return result;
}
// Ensure we wrap yielded iterators (i.e. `yield someInnerSaga()`) for
// providers to work.
case is.iterator(value):
return useProvidedValue(defaultSagaWrapper(value, refineYieldedValue));
default:
return useProvidedValue(value);
}
}
function defaultReducer(state = storeState) {
return state;
}
let reducer: Reducer = defaultReducer;
function getAllPromises(): Promise<*> {
return new Promise(resolve => {
Promise.all([...forkedTasks.map(taskPromise), mainTaskPromise]).then(
() => {
if (stopDirty) {
stopDirty = false;
resolve(getAllPromises());
}
resolve();
},
);
});
}
function addForkedTask(task: Task): void {
stopDirty = true;
forkedTasks.push(task);
}
function cancelMainTask(
timeout: number,
silenceTimeout: boolean,
timedOut: boolean,
): Promise<*> {
if (!silenceTimeout && timedOut) {
warn(`Saga exceeded async timeout of ${timeout}ms`);
}
mainTask.cancel();
return mainTaskPromise;
}
function scheduleStop(timeout: Timeout | TimeoutConfig): Promise<*> {
let promise = schedule(getAllPromises).then(() => false);
let silenceTimeout = false;
let timeoutLength: ?Timeout;
if (typeof timeout === 'number') {
timeoutLength = timeout;
} else if (typeof timeout === 'object') {
silenceTimeout = timeout.silenceTimeout === true;
if ('timeout' in timeout) {
timeoutLength = timeout.timeout;
} else {
timeoutLength = expectSaga.DEFAULT_TIMEOUT;
}
}
if (typeof timeoutLength === 'number') {
promise = Promise.race([promise, delay(timeoutLength).then(() => true)]);
}
return promise.then(timedOut =>
schedule(cancelMainTask, [timeoutLength, silenceTimeout, timedOut]),
);
}
function queueAction(action: Action): void {
queuedActions.push(action);
}
function notifyListeners(action: Action): void {
ioChannel.put(action);
}
function dispatch(action: Action): any {
if (typeof action._delayTime === 'number') {
const { _delayTime } = action;
dispatchPromise.then(() => delay(_delayTime)).then(() => {
storeState = reducer(storeState, action);
notifyListeners(action);
});
} else {
storeState = reducer(storeState, action);
dispatchPromise.then(() => notifyListeners(action));
}
}
function associateChannelWithPattern(channel: Object, pattern: any): void {
channelsToPatterns.set(channel, pattern);
}
function getDispatchableActions(effect: Object): Array<Action> {
const pattern = effect.pattern || channelsToPatterns.get(effect.channel);
const index = findDispatchableActionIndex(queuedActions, pattern);
if (index > -1) {
const actions = queuedActions.splice(0, index + 1);
return actions;
}
return [];
}
function processEffect(event: Object): void {
const parsedEffect = parseEffect(event.effect);
// Using string literal for flow
if (parsedEffect.type === 'NONE') {
return;
}
const effectStore = effectStores[parsedEffect.type];
if (!effectStore) {
return;
}
allEffects.push(event.effect);
effectStore.add(event.effect);
switch (parsedEffect.type) {
case FORK: {
outstandingForkEffects.set(event.effectId, parsedEffect.effect);
break;
}
case TAKE: {
const actions = getDispatchableActions(parsedEffect.effect);
const [reducerActions, [sagaAction]] = splitAt(actions, -1);
reducerActions.forEach(action => {
dispatch(action);
});
if (sagaAction) {
dispatch(sagaAction);
}
break;
}
case ACTION_CHANNEL: {
outstandingActionChannelEffects.set(
event.effectId,
parsedEffect.effect,
);
break;
}
// no default
}
}
function addExpectation(expectation: Function): void {
expectations.push(expectation);
}
const io = {
dispatch,
channel: ioChannel,
getState(): any {
return storeState;
},
sagaMonitor: {
effectTriggered(event: Object): void {
processEffect(event);
},
effectResolved(effectId: number, value: any): void {
const forkEffect = outstandingForkEffects.get(effectId);
if (forkEffect) {
addForkedTask(value);
return;
}
const actionChannelEffect = outstandingActionChannelEffects.get(
effectId,
);
if (actionChannelEffect) {
associateChannelWithPattern(value, actionChannelEffect.pattern);
}
},
effectRejected() {},
effectCancelled() {},
},
};
const api = {
run,
silentRun,
withState,
withReducer,
provide,
returns,
hasFinalState,
dispatch: apiDispatch,
delay: apiDelay,
// $FlowFixMe
get not() {
negateNextAssertion = true;
return api;
},
actionChannel: createEffectTesterFromEffects(
'actionChannel',
ACTION_CHANNEL,
asEffect.actionChannel,
),
apply: createEffectTesterFromEffects('apply', CALL, asEffect.call),
call: createEffectTesterFromEffects('call', CALL, asEffect.call),
cps: createEffectTesterFromEffects('cps', CPS, asEffect.cps),
fork: createEffectTesterFromEffects('fork', FORK, asEffect.fork),
getContext: createEffectTesterFromEffects(
'getContext',
GET_CONTEXT,
asEffect.getContext,
),
put: createEffectTesterFromEffects('put', PUT, asEffect.put),
putResolve: createEffectTesterFromEffects('putResolve', PUT, asEffect.put),
race: createEffectTesterFromEffects('race', RACE, asEffect.race),
select: createEffectTesterFromEffects('select', SELECT, asEffect.select),
spawn: createEffectTesterFromEffects('spawn', FORK, asEffect.fork),
setContext: createEffectTesterFromEffects(
'setContext',
SET_CONTEXT,
asEffect.setContext,
),
take: createEffectTesterFromEffects('take', TAKE, asEffect.take),
takeMaybe: createEffectTesterFromEffects('takeMaybe', TAKE, asEffect.take),
};
api.actionChannel.like = createEffectTester(
'actionChannel',
ACTION_CHANNEL,
effects.actionChannel,
asEffect.actionChannel,
true,
);
api.actionChannel.pattern = pattern => api.actionChannel.like({ pattern });
api.apply.like = createEffectTester(
'apply',
CALL,
effects.apply,
asEffect.call,
true,
);
api.apply.fn = fn => api.apply.like({ fn });
api.call.like = createEffectTester(
'call',
CALL,
effects.call,
asEffect.call,
true,
);
api.call.fn = fn => api.call.like({ fn });
api.cps.like = createEffectTester(
'cps',
CPS,
effects.cps,
asEffect.cps,
true,
);
api.cps.fn = fn => api.cps.like({ fn });
api.fork.like = createEffectTester(
'fork',
FORK,
effects.fork,
asEffect.fork,
true,
);
api.fork.fn = fn => api.fork.like({ fn });
api.put.like = createEffectTester(
'put',
PUT,
effects.put,
asEffect.put,
true,
);
api.put.actionType = type => api.put.like({ action: { type } });
api.putResolve.like = createEffectTester(
'putResolve',
PUT,
effects.putResolve,
asEffect.put,
true,
);
api.putResolve.actionType = type => api.putResolve.like({ action: { type } });
api.select.like = createEffectTester(
'select',
SELECT,
effects.select,
asEffect.select,
true,
);
api.select.selector = selector => api.select.like({ selector });
api.spawn.like = createEffectTester(
'spawn',
FORK,
effects.spawn,
asEffect.fork,
true,
);
api.spawn.fn = fn => api.spawn.like({ fn });
function checkExpectations(): void {
expectations.forEach(expectation => {
expectation({ storeState, returnValue });
});
}
function apiDispatch(action: Action): ExpectApi {
let dispatchableAction;
if (typeof delayTime === 'number') {
dispatchableAction = assign({}, action, {
_delayTime: delayTime,
});
delayTime = null;
} else {
dispatchableAction = action;
}
if (isRunning) {
dispatch(dispatchableAction);
} else {
queueAction(dispatchableAction);
}
return api;
}
function taskPromise(task: Task): Promise<*> {
return task.toPromise();
}
function start(): ExpectApi {
const sagaWrapper = createSagaWrapper(generator.name);
isRunning = true;
iterator = generator(...sagaArgs);
mainTask = runSaga(
io,
sagaWrapper,
iterator,
refineYieldedValue,
setReturnValue,
);
mainTaskPromise = taskPromise(mainTask)
.then(checkExpectations)
// Pass along the error instead of rethrowing or allowing to
// bubble up to avoid PromiseRejectionHandledWarning
.catch(identity);
return api;
}
function stop(timeout: Timeout | TimeoutConfig): Promise<*> {
return scheduleStop(timeout).then(err => {
if (err) {
throw err;
}
});
}
function exposeResults(): Object {
const finalEffects = Object.keys(exposableEffects).reduce((memo, key) => {
const effectName = exposableEffects[key];
const values = effectStores[key].values().filter(lacksSagaWrapper);
if (values.length > 0) {
// eslint-disable-next-line no-param-reassign
memo[effectName] = effectStores[key].values().filter(lacksSagaWrapper);
}
return memo;
}, {});
return {
storeState,
returnValue,
effects: finalEffects,
allEffects,
toJSON: () => toJSON(finalEffects),
};
}
function run(
timeout?: Timeout | TimeoutConfig = expectSaga.DEFAULT_TIMEOUT,
): Promise<*> {
start();
return stop(timeout).then(exposeResults);
}
function silentRun(
timeout?: Timeout = expectSaga.DEFAULT_TIMEOUT,
): Promise<*> {
return run({
timeout,
silenceTimeout: true,
});
}
function withState(state: any): ExpectApi {
storeState = state;
return api;
}
function withReducer(newReducer: Reducer, initialState?: any): ExpectApi {
reducer = newReducer;
storeState = extractState(newReducer, initialState);
return api;
}
function provide(newProviders: Providers | Array<Providers | [Object, any]>) {
providers = Array.isArray(newProviders)
? coalesceProviders(newProviders)
: newProviders;
return api;
}
function returns(value: any): ExpectApi {
addExpectation(
createReturnExpectation({
value,
expected: !negateNextAssertion,
}),
);
return api;
}
function hasFinalState(state: mixed): ExpectApi {
addExpectation(
createStoreStateExpectation({
state,
expected: !negateNextAssertion,
}),
);
return api;
}
function apiDelay(time: number): ExpectApi {
delayTime = time;
return api;
}
function createEffectTester(
effectName: string,
storeKey: string,
effectCreator: Function,
extractEffect: Function,
like: boolean = false,
): Function {
return (...args: mixed[]) => {
const expectedEffect = like ? args[0] : effectCreator(...args);
addExpectation(
createEffectExpectation({
effectName,
expectedEffect,
storeKey,
like,
extractEffect,
store: effectStores[storeKey],
expected: !negateNextAssertion,
}),
);
negateNextAssertion = false;
return api;
};
}
function createEffectTesterFromEffects(
effectName: string,
storeKey: string,
extractEffect: Function,
): Function {
return createEffectTester(
effectName,
storeKey,
effects[effectName],
extractEffect,
);
}
return api;
}
expectSaga.DEFAULT_TIMEOUT = 250;