jfairbank / redux-saga-test-plan

Test Redux Saga with an easy plan.
http://redux-saga-test-plan.jeremyfairbank.com
MIT License
1.25k stars 127 forks source link

Detour call effects #203

Open rickwebiii opened 6 years ago

rickwebiii commented 6 years ago

Hello, We're using redux-saga-test-plan for testing concurrency. We find ourselves needing to rely on a few hacks (one pretty egregious) for this to work well.

  1. We often need to dispatch an action within a dynamic static provider to simulate a concurrent saga changing the redux store out from under us. While redux-saga-test-plan explicitly supports asynchronously calling saga.dispatch(), doing this in a provider is a pretty ugly way to synchronize the test driver and the tested saga.
  2. Providers today can cause what were async call effects to become synchronous. This causes problems where sagas don't yield to other sagas in a race or fork where the production code does. This can cause different behavior, specifically as we're creating observer sagas to watch the store and abort the main saga if an invariant gets violated. We found a terrible hack to get around this, but would rather have first class support.

My ask is two fold: 1) First class support for dispatching actions when effect X occurs. Providers don't seem like the right place to do this, but it does work. Delay and timings simply don't work because we can't predict the exact spot in the code where we need to simulate a concurrent change in the redux store. This might look like:


expectSaga(mySaga, initialState)
  .withReducer(myReducer)
  .dispatch(matchers.call.like({ fn: restApi, args: ['/api/path'] }), someAction)

The dispatch can occur immediately before the matched effect runs so we can tie this into number 2.

2) First class support for detouring call effects. This lets you ensure that while providing data you keep the function async by inserting a yield delay(0) in your provider. An API to do this might look like:


// Like a provider, but we're guaranteeing the result comes back asynchronously,
// yielding to other sagas in a fork or race.
function* myDetouredSaga() {
  yield call(delay(0));

  return 27;
}

expectSaga(mySaga, initialState)
  .withReducer(myReducer)
  .detour(matchers.call.like({ fn: restApi, args: ['/api/path'] }), myDetouredSaga)
...

I did manage to accomplish detouring with a terrible hack, but I'd rather not rely on it:


/**
 * This provider lets you detour CALL effects to newSaga via a pretty awful hack.
 * Strange and probably wrong things will happen if you do this on any other effect.
 * We do this by creating a dynamic provider and in the callback overwrite the fn on the
 * passed call effect before returning next().
 * @param newSaga The new saga that runs in place of the one the effect provider matches.
 */
export function hijackProvider(newSaga: () => IterableIterator<any>) {
  return dynamic<CallEffectDescriptor>((callEffect, next) => {
    callEffect.fn = newSaga;
    return next();
  });
}
rickwebiii commented 6 years ago

As I think about it more, we really only need detours. You can yield put effects in your detoured function and get the ordering exactly as you want.