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

Providers won't work with endless loops #74

Open jfairbank opened 7 years ago

jfairbank commented 7 years ago

Need to devise a way to use providers in endless loops.

jfairbank commented 7 years ago

Stream of thought:

This would require some rethinking of how providers work, possibly requiring a provider to be dropped after being consumed. That would need to be an opt-in behavior via an option, though. The option could be at an individual effect provider level or for all provided effects. #88 and #89 would make this easier because providers will become more composable and reusable, meaning you don't need to stuff all your providers in one big object.

rplotkin commented 7 years ago

I'm running into this issue, but not quite sure why providers won't work in a loop. Are you able to explain what's happening that's causing the failure? I'm happy to dig in and help solve the problem, I just don't want to start at zero.

rplotkin commented 7 years ago

Also, a possible approach (though this only works if the entire fn is wrapped in while(true), is to have myFuncUnlooped (like an unconnected React component) and myFunc (wrapping Unlooped in a loop), then only testing myFuncUnlooped.

jfairbank commented 7 years ago

This isn't so much a bug as it is the way providers are defined. Providers aren't dropped after they match, so they will be continuously used if they match an effect. This was a design decision when providers started off as one object literal. It was important to ensure that your provider function could provide values for more than one instance of an effect (e.g. two different call effects).

Take this code as an example:

import { eventChannel } from 'redux-saga';
import { fork, put, take } from 'redux-saga/effects';
import { expectSaga } from 'redux-saga-test-plan';

const messageChannel = eventChannel((emitter) => {
  window.addEventListener('message', emitter);
  return () => window.removeEventListener('message', emitter);
});

function* processEvent(event) {
  yield put({ type: 'EVENT', payload: event });
}

function* saga() {
  while (true) {
    const event = yield take(messageChannel);
    yield fork(processEvent, event);
  }
}

it('keeps providing the take', () => {
  const fakeEvent = { hello: 'world' };

  return expectSaga(saga)
    .provide([
      [take(messageChannel), fakeEvent],
    ])
    .put({ type: 'EVENT', payload: fakeEvent })
    .run();
});

Inside the loop, we use a take effect on the messageChannel, waiting on a message event from the window. To simulate events, we use a static provider in this case to provide a fakeEvent whenever we wait on the messageChannel with take. When we provide the fakeEvent, the saga then forks the processEvent saga, meaning it returns to the start of the loop. That means it's going to wait on the messageChannel again, but Redux Saga Test Plan will run the providers on the yielded take effect and again find a match, sending back the fakeEvent. Therefore, we get stuck in an endless loop of providing the fakeEvent to the saga.

To fix the issue, you could do something like this:

function provideEvent(event) {
  let consumed = false;

  return {
    take({ channel }, next) {
      if (channel === messageChannel && !consumed) {
        consumed = true;
        return event;
      }

      return next();
    },
  };
}

it('provides the take once', () => {
  const fakeEvent = { hello: 'world' };

  return expectSaga(saga)
    .provide([
      provideEvent(fakeEvent),
    ])
    .put({ type: 'EVENT', payload: fakeEvent })
    .run();
});

That works, but it's a little cumbersome to manually write the provideEvent helper function.

I'm potentially suggesting to add some helper methods on the static providers, so you can specify how many times you want a provider to provide values. Something like this:

import * as matchers from 'redux-saga-test-plan/matchers';

it('can provide once', () => {
  const fakeEvent = { hello: 'world' };

  return expectSaga(saga)
    .provide([
      // Convenient `once` method
      [matchers.take.once(messageChannel), fakeEvent],
    ])
    .put({ type: 'EVENT', payload: fakeEvent })
    .run();
});

it('can specify how many times to provide', () => {
  const fakeEvent = { hello: 'world' };

  return expectSaga(saga)
    .provide([
      // Or more general `times` method
      [matchers.take.times(3, messageChannel), fakeEvent],
    ])
    .put({ type: 'EVENT', payload: fakeEvent })
    .run();
});

As far as dynamic providers with object literals, we could have an exported helper for those too, but I'm a not a big fan of that option yet.

Does that all make sense?

rplotkin commented 7 years ago

That does make sense. It seems like the right implementation should also solve #86 (assert effect yielded N times). In one sense, toHaveBeenCalledNTimes and the provision should be completely separate -- we might provide a response twice, but want the function to be called 4 times before the saga exits. But I think it's likely that many times we'll want to control the number of calls to a method and validate that count.

There's also the scenario where I want a provider to return a different value the fourth time, like Jest's mockImplementationOnce(fn) chaining.

So instead of coupling a .times to the matcher, I'd probably want to configure this in the second parameter, where fakeEvent could be a simple response, OR it could be replaced with a config object containing a mock function and the number of times that it can be matched in total. I say mock function, because I could then use the built-in functionality of [name-your-preferred-test-framework]. My mock could return different values each time, have a limit of proper return values, and be validated by the surrounding test framework. Like this (presume Jest):

it('can specify how many times to provide', () => {
  const fakeEvent = { hello: 'world' };
  const firstFakeEvent = { goodbye: 'land' }
  const secondFakeEvent = { lunch: 'time' }

  //https://facebook.github.io/jest/docs/mock-function-api.html#mockfnmockreturnvalueoncevalue
  const myMockFn = jest.fn(() => fakeEvent)
  .mockImplementationOnce(() => firstFakeEvent)
  .mockImplementationOnce(() => secondFakeEvent);

  return expectSaga(saga)
    .provide([
      // Or more general `times` method
      [matchers.take(messageChannel), {
        fn: myMockFn,
        times: 3,
      }],
    ])
    .put({ type: 'EVENT', payload: fakeEvent })
    .run()
    .then(() => {
      return expect(myMockFn).toHaveBeenCalledTimes(3)
    })
});

Thoughts?

rplotkin commented 7 years ago

Following up, in my own tests, using the code you provided above, I just wrote the following

const makeProvideCallGetData = (getDataMockFn, maxCalls) => {
  return (result) => {
    let callCount = 0
    return {
      call(effect, next) {
        if (effect.fn === getData && callCount < maxCalls) {
          callCount += 1
          return getDataMockFn(result)
        }
        return next()
      },
    }
  }
}

it('should let me count calls and provide my own function', () => {
  const myGetDataMock = jest.fn(result => result)

  //allow it to be called twice
  const provideCallGetData = makeProvideCallGetData(myGetDataMock, 2) 

  return expectSaga(watchLoadTopics)
    .provide([
      provideCallGetData({hello: 'world'}),
    ])
    .run()
    .then(() => {
      return expect(myGetDataMock).toHaveBeenCalledTimes(2)
    })
})
jfairbank commented 7 years ago

Maybe the best approach would be another helper function in the redux-saga-test-plan/providers module. It could be called times, limit, or something to that effect. You could then compose that with the dynamic helper or wrap the traditional object literal function providers to limit how many times it matches. Here are your previous two examples with a theoretical limit function:

import { dynamic, limit } from 'redux-saga-test-plan/providers';

it('can specify how many times to provide', () => {
  const fakeEvent = { hello: 'world' };
  const firstFakeEvent = { goodbye: 'land' }
  const secondFakeEvent = { lunch: 'time' }

  //https://facebook.github.io/jest/docs/mock-function-api.html#mockfnmockreturnvalueoncevalue
  const myMockFn = jest.fn(() => fakeEvent)
  .mockImplementationOnce(() => firstFakeEvent)
  .mockImplementationOnce(() => secondFakeEvent);

  return expectSaga(saga)
    .provide([
      // Or more general `times` method
      [matchers.take(messageChannel), limit(3, dynamic(myMockFn))],
    ])
    .put({ type: 'EVENT', payload: fakeEvent })
    .run()
    .then(() => {
      return expect(myMockFn).toHaveBeenCalledTimes(3)
    })
});

it('should let me count calls and provide my own function', () => {
  const myGetDataMock = jest.fn(result => result)

  return expectSaga(watchLoadTopics)
    .provide({
      call: limit(2, (effect, next) => {
        if (effect.fn === getData) {
          return getDataMockFn({hello: 'world'})
        }
        return next()
      }),
    })
    .run()
    .then(() => {
      return expect(myGetDataMock).toHaveBeenCalledTimes(2)
    })
})

Thoughts on that?

rplotkin commented 7 years ago

Yeah, that's good, but I might use it more like [matchers.take(messageChannel), dynamic(myMockFn, { limit: 3 })] giving dynamic the ability to consume options about a dynamic implementation.

I've tried to get this working in a PR, but I haven't had enough time to work through exactly how things are assembled. I assume the pieces fit roughly into what I'm posting below

(more generic fn that I'm now using many places)

const makeProvider = (mockFn, fn, maxCalls) => {
  return (result) => {
    let callCount = 0
    return {
      call(effect, next) {
        if (effect.fn === fn && callCount < maxCalls) {
          callCount += 1
          return mockFn(result)
        }
        return next()
      },
    }
  }
}
liamaharon commented 6 years ago

Has this issue been addressed? What's the currently recommended way of handling this? I couldn't find anything in the docs

FSM1 commented 5 years ago

would be great to have this functionality

cefn commented 5 years ago

I am still getting the hang of dynamic providers, but since there are now multiple providers possible, you could add one for each action pattern you want to mock, which intercepts actionChannel and take calls corresponding with a specific pattern and serves from a series of actions until they are exhausted.

The draft below should be considered pseudo-code but hopefully gives an idea of what I mean.

function mockActionPattern(mockPattern, iterable) {
  const mockActionIterator = iterable[Symbol.iterator]()
  const mockChannel = {}
  const mockProvider = {
    actionChannel: ({ pattern }, next) => {
      if (pattern === mockPattern) {
        return mockChannel
      }
      return next()
    },
    take: ({ channel, pattern }, next) => {
      if ((channel === mockChannel) || (pattern === mockPattern)) {
        const { done, value: action } = mockActionIterator.next()
        if (!done) {
          return action
        }
      }
      return next()
    },
    flush: () => { },
    close: () => { },
  }
  return mockProvider
}

...which could be incorporated in a test scenario like...

it("Calls take once", () => {
  const { effects } = expectSaga(saga).provide([
    mockActionPattern("*", [{ type: "FAKE", payload: null }])
  ])
  return effects.take.length === 1
})

Probably defining a signature which is iterable, or maybe a generator function , would then permit all the problem cases described above to be resolved with 'imperative' logic in your own iterator/generator rather than needing to define a fluent-style API.