I test saga with jest framework. I want to test the code when I throw new Error, but I have a problem.
Saga funtion
try {
const clientId = yield select(selectClientId)
if (!clientId) {
throw new Error('Client id is not exists')
}
const response = yield call(fetcher, {options})
yield put(clientReceiveData({ data: response }))
} catch (err) {
yield put(clientRequestDataFailure())
}
}
Here's a test saga
describe('client fetch data', () => {
const gen = cloneableGenerator(clientDataFetch)(params)
expect(gen.next().value).toEqual(select(selectClientId))
// #ts-ignore
expect(gen.throw(new Error('Client id is not exists')).value).toEqual( put(clientRequestDataFailure()))
// Here expect(received) is undefined
expect(gen.next().value).toEqual(call(fetcher, {options}))
})
received to equal with call fetcher is undefined
You already finished the generator đź‘Ť, there is no more code to execute, the way to assert that is:
:
expect(gen.next()).toStrictEqual({ done: true, value: undefined })
However, for clean code, you do expect cases where the client id is undefined, and handle correctly the case already, so it's not an unexpected exception, try:
// generator.ts
function* clientDataFetch(params) {
//...
const clientId = yield select(selectClientId)
if (!clientId) {
yield put(clientRequestDataFailure())
} else {
const response = yield call(fetcher, {options})
yield put(clientReceiveData({ data: response }))
}
}
// test.ts
describe('client fetch data', () => {
const gen = cloneableGenerator(clientDataFetch)(params)
expect(gen.next().value).toEqual(select(selectClientId))
// just return the falsy value
expect(gen.next(undefined).value).toEqual(put(clientRequestDataFailure()))
expect(gen.next()).toStrictEqual({ done: true, value: undefined })
})
Related
So i already create the createMonsterStart and it will hit my API, my API is returning response code, and I want to alert success when the response code is 00 otherwise it will alert failed, how can i achieve that? here is my code:
const onSubmitHandler = () => {
dispatch(createMonsterStart(monster))
if(dispatch success){
alert("success")
}else{
alert("error")
}
}
And here is the redux saga code:
export function* createMonsterAsync({ payload: { monster } }) {
try {
const user = yield select(getUser)
const a = yield call(createMonster, user.user.token, monster)
if (a.error) {
yield put(createMonsterFailure(a.error))
return false
}
const monsters = yield call(fetchMonsterAsync)
yield put(createMonsterSuccess(monsters))
} catch (error) {
yield put(createMonsterFailure(error))
}
}
I know about Redux Saga's all([...effects]) effect combinator that is very similar to Promise.all utility, but I've not found something similar to Promise.any behavior that will:
run all effects at the same time
fail if all effects fail (otherwise succeed)
if fail throw AggregateError of all errors
if succeed return nothing or just first result (from multiple results)
e.g.
export function* getHomeDataSaga() {
yield* any([
call(getTopUsersSaga, { payload: undefined }),
call(getFavoritesSaga, { payload: undefined }),
call(getTrendingTokensSaga, { payload: undefined }),
call(getTopCollectionsSaga, { payload: { itemsPerPage: 9, page: 1 } }),
]);
}
This would be very useful when you want to group multiple (decomposed) sagas in to a single saga, it won't fail-fast but finish all effects.
Answer
Based on Martin Kadlec answer ended up using:
export function* anyCombinator(effects: SagaGenerator<any, any>[]) {
const errors = yield* all(
effects.map((effect) =>
call(function* () {
try {
yield* effect;
return null;
} catch (error) {
return error;
}
}),
),
);
if (errors.every((error) => error !== null)) {
throw new AggregateError(errors);
}
}
There isn't an existing effect that would do that, but you can create your own utility that will do that for you. The any functionality is very similar to the all functionality in that in one case you will get all the results/errors and in the other you get the first one that succeeds/fails. So you can easily get the any functionality by flipping the all effect -> for each item you throw on success and return on error.
const sagaAny = (effects = []) => {
const taskRunner = function* (effect) {
let value;
try {
value = yield effect;
} catch (err) {
// On error, we want to just return it
// to map it later to AggregateError
return err;
}
// On success we want to cancel all the runners
// we do that by throwing here
throw value;
};
return call(function* () {
try {
const runners = effects.map((effect) => call(taskRunner, effect));
// If one of the runners throws on success the all effect will
// cancel all the other runners
const failedResults = yield all(runners);
throw new AggregateError(failedResults, "SAGA_ANY");
} catch (err) {
if (err instanceof AggregateError) throw err;
return err;
}
});
};
function* getHomeDataSaga() {
const result = yield sagaAny([
call(getTopUsersSaga, { payload: undefined }),
call(getFavoritesSaga, { payload: undefined }),
call(getTrendingTokensSaga, { payload: undefined }),
call(getTopCollectionsSaga, { payload: { itemsPerPage: 9, page: 1 } }),
]);
}
In case you would prefer not to cancel the other sagas once one succeeds, things get a bit trickier because in standard fork tree the main saga (e.g. getHomeDataSaga) would wait until all the forked sagas (task runners) are done before continuing. To get around that we can use the spawn effect, which will not block the main saga though it has some other implications (e.g. if you kill them main saga the spawned sagas will continue running).
Something like this should do the trick:
const sagaAny = (effects = []) => {
const taskRunner = function* (effect, resultsChannel) {
try {
value = yield effect;
yield put(resultsChannel, { type: "success", value });
} catch (err) {
yield put(resultsChannel, { type: "error", value: err });
}
};
return call(function* () {
const resultsChannel = yield call(channel);
yield all(
effects.map((effect) => spawn(taskRunner, effect, resultsChannel))
);
const errors = [];
while (errors.length < effects.length) {
const result = yield take(resultsChannel);
if (result.type === "success") {
yield put(resultsChannel, END);
return result.value;
}
if (result.type === "error") errors.push(result.value);
}
throw new AggregateError(errors, "SAGA_ANY");
});
};
I use custom channel here to send the results from the spawned runners to the utility saga so that I can react to each finished runner based on my needs.
I looking to doc and some samples online, but still not working. I use Sinon for unit test, and I keep getting this error, stuck on this one so long, can't figure it out.
expected { Object (##redux-saga/IO, combinator, ...) } to deeply equal { Object (##redux-saga/IO, combinator, ...) }
My action
export const loadingStatus = (response) => {
return { type: "LOADING_STATUS", response };
};
My saga
export function* mySampleSaga() {
try {
yield put(loadingStatus('loading'));
yield delay(1000);
const config = yield select(getConfig);
const requestCall = new SendingRequest(config);
const linkRequests = yield select(getLinks);
const response = yield call(
[requestService, requestCall.sample],
"2020-01-01",
"2020-12-21"
);
const result = get(response, 'entities.requests', {});
yield put(success(result));
yield put(loadingStatus('done'));
} catch (error) {
yield put(sendError(error));
yield put(loadingStatus('done'));
}
}
My test
describe('sample saga', () => {
const config = {
sample: "123"
};
const linkRequests = ['12345', '5678910'];
it('should update request status - happy path', () => {
const gen = mySampleSaga();
expect(gen.next().value).to.deep.equal(put(loadingStatus('loading'))); // This keep getting error below
});
it('If saga has error', () => {
const gen = mySampleSaga();
const error = new Error('error');
gen.next();
expect(gen.next().value).to.deep.equal(put(sendError(error)));
expect(gen.next().value).to.deep.equal(put(loadingStatus('done')));
expect(gen.next().done).to.equal(true);
});
});
So I have a saga which shows fetches some data to show in a table.
Action Creators are as follows
export const fetchInstanceDataSetAssocSuccess = (records) => {
return {
type: actionTypes.FETCH_INSTANCE_DATASETS_ASSOC_SUCCESS,
records: records
}
}
export const fetchInstanceDataSetAssocFailed = (error) => {
return {
type: actionTypes.FETCH_INSTANCE_DATASETS_ASSOC_FAILED,
error: error
}
}
export const fetchInstanceDataSetAssocStart = () => {
return {
type: actionTypes.FETCH_INSTANCE_DATASETS_ASSOC_START
}
}
export const fetchInstanceDataSetAssoc = () => {
return {
type: actionTypes.FETCH_INSTANCE_DATASETS_ASSOC_INITIATE
}
}
My saga is as follows
function * fetchInstanceDataSetAssocSaga (action) {
yield put(instanceDataSetAssocActions.fetchInstanceDataSetAssocStart())
const useMockData = yield constants.USE_MOCK_DATA
if (useMockData) {
yield delay(constants.MOCK_DELAY_SECONDS * 1000)
}
try {
const res = (useMockData)
? (yield constants.INSTANCE_DATASET_ASSOC)
: (yield call(request, {url:
API_URLS.INSTANCE_DATASET_ASSOC_API_ENDPOINT, method: 'GET'}))
yield put(instanceDataSetAssocActions.fetchInstanceDataSetAssocSuccess(res.data))
} catch (error) {
yield
put(instanceDataSetAssocActions.fetchInstanceDataSetAssocFailed(error))
}
}
Action to watch over the Saga is as follows
export function * watchInstanceDataSetAssocSaga () {
yield takeEvery(actionTypes.FETCH_INSTANCE_DATASETS_ASSOC_INITIATE,
fetchInstanceDataSetAssocSaga)
}
Test Cases are as follows
describe('load instance dataset assoc table', () => {
test('update state with instance-dataset records for landing page',() => {
const finalState = {
records: constants.INSTANCE_DATASET_ASSOC.data,
loading: false,
error: false
}
const requestParam = {url: API_URLS.INSTANCE_DATASET_ASSOC_API_ENDPOINT, method: 'GET'}
return expectSaga(watchInstanceDataSetAssocSaga)
.provide([[call(request,requestParam),constants.INSTANCE_DATASET_ASSOC]])
.withReducer(instanceDataSetAssoc)
.put(instanceDataSetAssocActions.fetchInstanceDataSetAssocStart())
.put(instanceDataSetAssocActions.fetchInstanceDataSetAssocSuccess(constants.INSTANCE_DATASET_ASSOC.data))
.dispatch(instanceDataSetAssocActions.fetchInstanceDataSetAssoc())
.hasFinalState(finalState)
.silentRun()
})
})
I get the following error for this.
SagaTestError:
put expectation unmet:
at new SagaTestError (node_modules/redux-saga-test-plan/lib/shared/SagaTestError.js:17:57)
at node_modules/redux-saga-test-plan/lib/expectSaga/expectations.js:63:13
at node_modules/redux-saga-test-plan/lib/expectSaga/index.js:572:7
at Array.forEach (<anonymous>)
at checkExpectations (node_modules/redux-saga-test-plan/lib/expectSaga/index.js:571:18)
I am following the docs correctly but still getting the above error.
Maybe its late, but i found an answer, maybe it will help you
This mistake may occure because of library timeout try to turn off the timeout with .run(false)
original link https://github.com/jfairbank/redux-saga-test-plan/issues/54
Is there clean/short/right way to using together axios promise and uploading progress event?
Suppose I have next upload function:
function upload(payload, onProgress) {
const url = '/sources/upload';
const data = new FormData();
data.append('source', payload.file, payload.file.name);
const config = {
onUploadProgress: onProgress,
withCredentials: true
};
return axios.post(url, data, config);
}
This function returned the promise.
Also I have a saga:
function* uploadSaga(action) {
try {
const response = yield call(upload, payload, [?? anyProgressFunction ??]);
yield put({ type: UPLOADING_SUCCESS, payload: response });
} catch (err) {
yield put({ type: UPLOADING_FAIL, payload: err });
}
}
I want to receive progress events and put it by saga. Also I want to catch success (or failed) result of the axios request. Is it possible?
Thanks.
So I found the answer, thanks Mateusz Burzyński for the clarification.
We need use eventChannel, but a bit canningly.
Suppose we have api function for uploading file:
function upload(payload, onProgress) {
const url = '/sources/upload';
const data = new FormData();
data.append('source', payload.file, payload.file.name);
const config = {
onUploadProgress: onProgress,
withCredentials: true
};
return axios.post(url, data, config);
}
In saga we need to create eventChannel but put emit outside.
function createUploader(payload) {
let emit;
const chan = eventEmitter(emitter => {
emit = emitter;
return () => {}; // it's necessarily. event channel should
// return unsubscribe function. In our case
// it's empty function
});
const uploadPromise = upload(payload, (event) => {
if (event.loaded.total === 1) {
emit(END);
}
emit(event.loaded.total);
});
return [ uploadPromise, chan ];
}
function* watchOnProgress(chan) {
while (true) {
const data = yield take(chan);
yield put({ type: 'PROGRESS', payload: data });
}
}
function* uploadSource(action) {
const [ uploadPromise, chan ] = createUploader(action.payload);
yield fork(watchOnProgress, chan);
try {
const result = yield call(() => uploadPromise);
put({ type: 'SUCCESS', payload: result });
} catch (err) {
put({ type: 'ERROR', payload: err });
}
}
I personally found the accepted answer to be very convoluted, and I was having a hard time implementing it. Other google / SO searches all led to similar type answers. If it worked for you, great, but I found another way using an EventEmitter that I personally find much simpler.
Create an event emitter somewhere in your code:
// emitter.js
import { EventEmitter } from "eventemitter3";
export default new EventEmitter();
In your saga to make the api call, use this emitter to emit an event within the onUploadProgress callback:
// mysagas.js
import eventEmitter from '../wherever/emitter';
function upload(payload) {
// ...
const config = {
onUploadProgress: (progressEvent) = {
eventEmitter.emit(
"UPLOAD_PROGRESS",
Math.floor(100 * (progressEvent.loaded / progressEvent.total))
);
}
};
return axios.post(url, data, config);
}
Then in your component that needs this upload progress number, you can listen for this event on mount:
// ProgressComponent.jsx
import eventEmitter from '../wherever/emitter';
const ProgressComponent = () => {
const. [uploadProgress, setUploadProgress] = useState(0);
useEffect(() => {
eventEmitter.on(
"UPLOAD_PROGRESS",
percent => {
// latest percent available here, and will fire every time its updated
// do with it what you need, i.e. update local state, store state, etc
setUploadProgress(percent)
}
);
// stop listening on unmount
return function cleanup() {
eventEmitter.off("UPLOAD_PROGRESS")
}
}, [])
return <SomeLoadingBar value={percent} />
}
This worked for me as my application was already making use of a global eventEmitter for other reasons. I found this easier to implement, maybe someone else will too.