redux-saga-test-plan icon indicating copy to clipboard operation
redux-saga-test-plan copied to clipboard

Test delay from redux-saga

Open ACoolmanBigHealth opened this issue 6 years ago • 11 comments

Background

I have a "gradual-backoff" built into a saga using Redux-saga's delay() effect.

I am using expectSaga to write the test.

But in order to get the test to work I had to disable the delay.

Question

What is the best way to test the delay amount?

Code

"Back off" function that returns 1s, 2s, or 3s

const BACK_OFF_SERIES = [1000, 2000, 3000];
const backOff = attempt =>
  BACK_OFF_SERIES[Math.min(attempt, BACK_OFF_SERIES.length - 1)];

tryDownload saga

const MAX_ATTEMPTS = 3;
function* tryDownloadFiles(assets, downloadOptions) {
  for (let attempts = MAX_ATTEMPTS; attempts; attempts -= 1) {
    try {
      const urls = yield call(downloader, assets, downloadOptions);
      return urls;
    } catch (error) {
      if (attempts > 1) {
        yield call(delay, backOff(attempts));
      } else {
        yield put(
          showError({
            error: `${error}. Failed!`,
          })
        );
      }
    }
  }
}

And an expectSaga test that uses a provider bypass the delay:

const res = expectSaga(tryDownloadFiles, mockAssets, mockDownloadOptions)
  .provide([
    [matchers.call.fn(delay), null],
    [matchers.call.fn(downloader), throwError(mockError)],
  ])
  // .call(delay, backOff(3))
  // .call(delay, backOff(2))
  // .call(delay, backOff(1))
  .put(showError({ error: `${mockError}. Failed!` }))
  .run();

ACoolmanBigHealth avatar Mar 08 '19 14:03 ACoolmanBigHealth

I'm trying to do something similar and am not sure how to simulate a delay in 4.0.0-beta2. In redux-saga 1.0.x, you can no longer use the yield call(delay, ms) approach and must call yield delay(ms) directly.

Any suggestions?

cobarx avatar Apr 01 '19 22:04 cobarx

@cobarx We did it by relying on the fact that delay uses call under the hood:

return expectSaga(toastSaga)
      .provide({
        call: (effect, next) => {},
      })
      .take(foo)
      .dispatch(bar)
      .silentRun()

You can handle different calls by using effect or next (as in the docs).

Would love a more built-in way to do this though.

michaelgmcd avatar Apr 11 '19 15:04 michaelgmcd

@michaelgmcd That worked for me, thanks.

I had my delay in a race and managed to test it by providing both race() and call().

const [undo] = yield race([notificationPromise, delay(500)]);
expectSaga(...)
  .provide({
    race: () => [false],
    call: () => false,
  })

ghost avatar May 30 '19 16:05 ghost

This is how I did it. I have to check whether the name of the Function is 'delayP' or not. Otherwise, it mocks every calls in my *Saga() method.

Fake const provideDelay = ({ fn }, next) => (fn.name === 'delayP') ? null : next();

Usage to Test addWorkoutSaga

it('should call addWorkoutSaga function', () => {
        return expectSaga(addWorkoutSaga, fakeWorkoutPayload)
            .provide([
                { call: provideDelay },
                [matchers.call.fn(WorkoutService.add), null],
                [matchers.call.fn(fetchWorkoutsSaga), null]
            ])
            .call(WorkoutService.add, fakeWorkoutPayload.payload)
            .put(addWorkout.success())
            .call(fetchWorkoutsSaga)
            .run();
    });

addWorkoutSaga

export function* addWorkoutSaga({ payload }) {
    try {
        yield put(beginAjaxCall());

        // throw error intentionally for calories lower than 50
        if (payload.calories < 50)
            payload.calories = '';

        yield delay(1000);
        yield call(WorkoutService.add, payload);

        yield call(toast.success, "Item added successfully.");
        yield put(closeModal(Modal.AddWorkout));
        yield put(addWorkout.success());
        yield call(fetchWorkoutsSaga);
    }
    catch (error) {
        yield put(addWorkout.failure({ errorMessage: error.statusText }));
        yield call(toast.error, "Error occured.  Please try again.");
    }
}

ttcg avatar Jun 18 '19 09:06 ttcg

I was able to test it with a race but without using providers or anything weird by using a call on the delay. I was thrown in the wrong direction for a while by @cobarx's comment about not being able to use yield call(delay, ms), but that is because delay itself is a promise now not an effect.

So for me I used the delay inside a race which needs an effect, so I had the following code to test my delay

//Actual
    yield race({
      delay: call(delay, ms),
      cancel: take(cancelDelayAction)
    });

//Test
    .race({
      delay: call(delay, ms),
      cancel: take(cancelDelayAction)
    })

which made my test work as expected.

before i was trying to use

    yield race({
      delay: delay(ms),
      cancel: take(cancelDelayAction)
    });

which resulted in the test failing

jacquesyoco avatar Aug 06 '19 11:08 jacquesyoco

Would be good to change the 'yield delay' values via matchers or so...

For now we had to mock it too... :(

olegatg avatar Aug 23 '19 15:08 olegatg

Although not ideal, I got around the issue by using a proxy method for my delay:

export function* oneSecondDelay(): Saga<void> {
    yield delay(1000);
}

which I can then mock:

.provide([
    [matchers.call.fn(sagas.oneSecondDelay), null],
])

arosca avatar Nov 27 '19 11:11 arosca

Digging this up to provide a new solution we found:

.provide([
    [matchers.call.fn(delayP), null]
])

This takes advantage of redux-saga calling call on delayP under the hood here: https://github.com/redux-saga/redux-saga/blob/d05370f12f49f29fea78767fdf5b7ba3621b60f7/packages/core/src/internal/io.js#L281

rickyrombo avatar Jan 28 '22 18:01 rickyrombo

delayP

where are you importing delayP from? I'm getting an error if I try to get it form redux-saga. Can you please add more complete example?

anwarhamr avatar Jul 29 '22 05:07 anwarhamr

Yeah for sure!

import delayP from '@redux-saga/delay-p'

Here's one of our test files:

https://github.com/AudiusProject/audius-client/blob/4114a2d8bfae8c31841acde32b1d1882b81806c1/packages/web/src/pages/audio-rewards-page/store/store.test.ts#L314

rickyrombo avatar Jul 29 '22 16:07 rickyrombo

const provideDelay = ({ fn }, next) => (fn.name === 'delayP') ? null : next();

Thanks, @ttcg, It works well. Moreover, I can check if the delay function was called with a specific parameter

import { delay } from 'redux-saga/effects';

it('should call delay with 5000', async () => {
   const { effects } = await expectSaga(testedSaga)
            .provide([
                { call: provideDelay },
            ])
            .run();
   expect(effects.call[0]).toEqual(delay(5000))
});

BohdanKov avatar Mar 23 '23 15:03 BohdanKov