Open jfairbank opened 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.
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.
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.
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?
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?
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)
})
})
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?
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()
},
}
}
}
Has this issue been addressed? What's the currently recommended way of handling this? I couldn't find anything in the docs
would be great to have this functionality
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.
Need to devise a way to use providers in endless loops.