Testing Observable epic which invokes other epic - reactjs

I am trying to test a Redux Observable epic which dispatches an action to invoke an other epic. Somehow the invoked epic is not called.
Lets say my epics looks like this;
const getJwtEpic = (action$, store, { api }) =>
action$.ofType('GET_JWT_REQUEST')
.switchMap(() => api.getJWT()
.map(response => {
if (response.errorCode > 0) {
return {
type: 'GET_JWT_FAILURE',
error: { code: response.errorCode, message: response.errorMessage },
};
}
return {
type: 'GET_JWT_SUCCESS',
idToken: response.id_token,
};
})
);
const bootstrapEpic = (action$, store, { api }) =>
action$.ofType('BOOTSTRAP')
.switchMap(() =>
action$.filter(({ type }) => ['GET_JWT_SUCCESS', 'GET_JWT_FAILURE'].indexOf(type) !== -1)
.take(1)
.mergeMap(action => {
if (action.type === 'GET_JWT_FAILURE') {
return Observable.of({ type: 'BOOTSTRAP_FAILURE' });
}
return api.getProfileInfo()
.map(({ profile }) => ({
type: 'BOOTSTRAP_SUCCESS', profile,
}));
})
.startWith({ type: 'GET_JWT_REQUEST' })
);
When I try to test the bootstrapEpic in Jest with the following code;
const response = {};
const api = { getJWT: jest.fn() };
api.getJWT.mockReturnValue(Promise.resolve(response));
const action$ = ActionsObservable.of(actions.bootstrap());
const epic$ = epics.bootstrapEpic(action$, null, { api });
const result = await epic$.toArray().toPromise();
console.log(result);
The console.log call gives me the following output;
[ { type: 'GET_JWT_REQUEST' } ]
Somehow the getJwtEpic isn't called at all. I guess it has something to do with the action$ observable not dispatching the GET_JWT_REQUEST action but I can't figure out why. All help is so welcome!

Assuming actions.rehydrate() returns an action of type BOOTSTRAP and the gigya stuff is a typo,
getJwtEpic isn't called because you didn't call it yourself 🤡 When you test epics by manually calling them, then it's just a function which returns an Observable, without any knowledge of the middleware or anything else. The plumbing that connects getJwtEpic as part of the root epic, and provides it with (action$, store) is part of the middleware, which you're not using in your test.
This is the right approach, testing them in isolation, without redux/middleware. 👍 So you test each epic individually, by providing it actions and dependencies, then asserting on the actions it emits and the dependencies it calls.
You'll test the success path something like this:
const api = {
getProfileInfo: () => Observable.of({ profile: 'mock profile' })
};
const action$ = ActionsObservable.of(
actions.rehydrate(),
{ type: 'GET_JWT_SUCCESS', idToken: 'mock token' }
);
const epic$ = epics.bootstrapEpic(action$, null, { api });
const result = await epic$.toArray().toPromise();
expect(result).toEqual([
{ type: 'GET_JWT_REQUEST' },
{ type: 'BOOTSTRAP_SUCCESS', profile: 'mock profile' }
]);
Then you'll test the failure path in another test by doing the same thing except giving it GET_JWT_FAILURE instead of GET_JWT_SUCCESS. You can then test getJwtEpic separately as well.
btw, ofType accepts any number of types, so you can just do action$.ofType('GET_JWT_SUCCESS', 'GET_JWT_FAILURE')

Related

How to use a thunk dispatch to create a standard promise that returns `success` or `error`

I have a toast package that receives a standard promise as an argument and does something upon success or error:
toast.promise(
updateNotePromise,
{
loading: 'Saving...',
success: (data: any) => 'Note saved!',
error: (err) => err.toString()
}
);
This is the promise I pass to the toast, but it returns a <PayloadAction> because it calls a thunk:
const updateNotePromise = await dispatch(
updateNoteInFirestore({ note: noteInput, noteDocId: noteProp.docId })
);
How can I return success or error from this dispatch thunk operation?
I thought of processing the returned <PayloadAction> by wrapping the thunk. This would be my naive approach:
const updateNotePromise = async(): Promise<{success: boolean | error: any}> => {
try {
await dispatch(updateNoteInFirestore({ note: noteInput, noteDocId: noteProp.docId
return success }))
}
catch {
(error)=> return error}
Am I on the right track?
Edit: here's the thunk code:
export const updateNoteInFirestore = createAsyncThunk(
'updateNoteInFirestore',
async (
{ note, noteDocId }: { note: string; noteDocId?: string },
{ getState, dispatch }
) => {
const poolState = (getState() as RootState).customerPool.pool;
const userState = (getState() as RootState).user;
const time = Timestamp.now();
const path = noteDocId ? noteDocId : undefined;
const message = note;
if (poolState?.docID) {
await notesService.updateNote(
{
pool: poolState.docID,
customer: userState?.user?.uid ?? 'Undefined Customer',
//we do not update dateFirstCreated
...(path ? { dateLastUpdated: time } : { dateFirstCreated: time }),
dateLastUpdated: time,
message: message,
editHistory: [],
seenByAdmin: false
},
path
);
dispatch(fetchNotesByCustomerId(userState?.user?.uid));
return { error: false };
}
return { error: true };
}
);
If you want to return an error with createAsyncThunk you can use rejectWithValue
const fetchUserById = createAsyncThunk(
'users/fetchById',
async (userId, { rejectWithValue }) => {
const response = await fetch(`https://example.com/api/stuff`)
if (response.status === 404)
return rejectWithValue(new Error("Impossible to do stuff"));
return response.json()
}
)
I think for your use case, it's better to use a promise-based function followed by a dispatch reducer action rather than an asyncThunk.
asyncThunks return value can only be consumed by builders that are defined within slice as far as I know.
You need to break your problem into three steps:
Creating a wrapper promiseFunction as needed by your toast.
Creating a promise helper function where you must be able to supply the variables poolState and userState as these variables were accessed through getState() in your async thunk but that isn't possible in your promiseHelperFunction If you define promiseHelperFunction within your functional component you could use useAppSelector to access those states. I have added the comment for the same in the promiseHelperFunction.
Now once you're done with this you can now consume promiseFunction in your toast.
You might need to import fetchNotesByCustomerId that you're using in your asyncThunk as it may not be accessible to the component where you're writing the toast implementation.
Here's the code for same:
const updateNotePromise = async () => {
return updatePromiseHelperFunction({
note: noteInput,
noteDocId: noteProp.docId,
});
};
const updatePromiseHelperFunction = async ({
note,
noteDocId,
}: {
note: string;
noteDocId?: string;
}) => {
/*
// Before the Promise you must ensure you're able to access these variables:
const poolState = useAppSelector(state => state.customerPool.pool);
const userState = useAppSelector(state => state.user);
*/
const time = Timestamp.now();
const path = noteDocId ? noteDocId : undefined;
if (poolState?.docID) {
await notesService.updateNote(
{
pool: poolState.docID,
customer: userState?.user?.uid ?? 'Undefined Customer',
//we do not update dateFirstCreated
...(path ? { dateLastUpdated: time } : { dateFirstCreated: time }),
dateLastUpdated: time,
message: note,
editHistory: [],
seenByAdmin: false,
},
path
);
dispatch(fetchNotesByCustomerId(userState?.user?.uid));
return { error: false };
}
return { error: true };
};

Instantiate a new Howler Object in Action Creators and dispatch actions on events

So I'm trying to build a React.js + Redux audio player.
I'm using the Howler.js Library to process audio from an API.
I'm dispatching actions from the player control buttons and from other component such as a track list component.
When the user clicks on a track in the lis, I'm dispatching a track object to the Player Reducer. I instantiate a new Howl Object here which works but there is some issues here :
Because Howler is fetching the data from an API, this is an async task and it's an anti pattern. I know that, but here I'm not triggering events from Howler so it works, but it's a bit buggy.
I can't use event triggers because it would be async and it's a bad pratice to dispatch actions from the Reducer.
What I would like :
Instanciate a new Howler Object in my Action Creator.
Use the event triggers, e.g : onload() or onloaderror(), to dispatch an other action to tell the player that Howler is ready to play a song or if there was an error.
The code :
Action Creator
export const PLAYER_INITIALIZE = 'PLAYER_INITIALIZE'
export const initialize = () => {
return {
type: PLAYER_INITIALIZE,
}
}
export const PLAYER_SET_TRACK = 'PLAYER_SET_TRACK'
export const setTrack = (trackId) => {
return {
type: PLAYER_SET_TRACK,
}
}
export const PLAYER_PLAY_TRACK = 'PLAYER_PLAY_TRACK'
export const playTrack = () => {
return {
type: PLAYER_PLAY_TRACK,
}
}
Reducer
function PlayerReducer(state = initialPlayerState, action) {
switch (action.type) {
case PlayerActions.PLAYER_SET_TRACK:
if (state.audioObj.state() != 'unloaded')
state.audioObj.unload();
return {
...state,
audioObj: new Howl({
src: API.API_STREAM_TRACK + action.trackId,
html5: true,
preload: true,
onload: () => {
console.log("Track loaded succesfully.");
},
onloaderror: (id, err) => {
console.error("Load Error : " + err);
}
}),
trackMetadata: action.trackMetadata
};
case PlayerActions.PLAYER_PLAY_TRACK:
state.audioObj.play();
return {
...state,
isPlaying: true,
};
[...]
My idea is to instanciate Howler like this :
new Howler({
[ ... some options ],
onload: () => {
dispatch({
type: PLAYER_LOAD_SUCCESS
});
},
onloaderror: () => {
dispatch({
type: PLAYER_LOAD_ERROR
});
}
});
How can I edit this code to create a new Howl Object correctly in the Action Creator and pass it to the player on each track that I want to listen?
Can you give me an example ?
Thanks.
You can add redux-thunk to your app and then be able to dispatch multiple actions from a single action creator. After adding redux-thunk, your action could look like the following:
export const setTrack = (trackId) => {
(dispatch, getState) => {
const { audioObj } = getState();
if (audioObj != null) {
audioObj.unload();
}
dispatch({
audioObj: new Howler({
[ ... some options ],
onload: () => {
dispatch({
type: PLAYER_LOAD_SUCCESS
});
},
onloaderror: () => {
dispatch({
type: PLAYER_LOAD_ERROR
});
}
}),
type: PLAYER_SET_TRACK
});
}
you can move your other async and Howler work into action creators this way and make your reducer only concerned with storing information.

How to mock out async actions

I'm trying to test this function:
function login(username, password) {
let user = { userName: username, password: password };
return dispatch => {
localStorageService.login(username, password).then((response) => {
dispatch(resetError());
dispatch(success( { type: userConstants.LOGIN, user} ));
}, (err) => {
dispatch(error(err));
});
};
function success(user) { return { type: userConstants.LOGIN, payload: user } };
};
Here is my test
const mockStore = configureStore([thunk]);
const initialState = {
userReducer: {
loggedInUser: "",
users: [],
error: ""
}
};
const store = mockStore(initialState);
jest.mock('./../../services/localStorageService');
describe("Login action should call localstorage login", () => {
let localStorage_spy = jest.spyOn(localStorageService, 'login');
store.dispatch(userActions.login(test_data.username, test_data.password)()).then( () => {
expect(localStorage_spy).toHaveBeenCalled();
});
});
The error I get:
Actions must be plain objects. Use custom middleware for async actions.
A lot of resources online keep telling me to use thunk in my test for these actions but it's not working. The last thing it calls is dispatch(resetError()); and it breaks. I've never really found a resource online which is similar enough to my problem. My function returns a dispatch which returns a promise which returns another dispatch when the promise resolves. I'm just trying to get the function to return. I've put a spy on localStorageService.login and also mocked it out and I have an expect to make sure it was called. But of course the function is not returning

React Hooks - How to test changes on global providers

I'm trying to test the following scenario:
A user with an expired token tries to access a resource he is not authorized
The resources returns a 401 error
The application updates a global state "isExpiredSession" to true
For this, I have 2 providers:
The authentication provider, with the global authentication state
The one responsible to fetch the resource
There are custom hooks for both, exposing shared logic of these components, i.e: fetchResource/expireSesssion
When the resource fetched returns a 401 status, it sets the isExpiredSession value in the authentication provider, through the sharing of a setState method.
AuthenticationContext.js
import React, { createContext, useState } from 'react';
const AuthenticationContext = createContext([{}, () => {}]);
const initialState = {
userInfo: null,
errorMessage: null,
isExpiredSession: false,
};
const AuthenticationProvider = ({ authStateTest, children }) => {
const [authState, setAuthState] = useState(initialState);
return (
<AuthenticationContext.Provider value={[authStateTest || authState, setAuthState]}>
{ children }
</AuthenticationContext.Provider>);
};
export { AuthenticationContext, AuthenticationProvider, initialState };
useAuthentication.js
import { AuthenticationContext, initialState } from './AuthenticationContext';
const useAuthentication = () => {
const [authState, setAuthState] = useContext(AuthenticationContext);
...
const expireSession = () => {
setAuthState({
...authState,
isExpiredSession: true,
});
};
...
return { expireSession };
}
ResourceContext.js is similar to the authentication, exposing a Provider
And the useResource.js has something like this:
const useResource = () => {
const [resourceState, setResourceState] = useContext(ResourceContext);
const [authState, setAuthState] = useContext(AuthenticationContext);
const { expireSession } = useAuthentication();
const getResource = () => {
const { values } = resourceState;
const { userInfo } = authState;
return MyService.fetchResource(userInfo.token)
.then((result) => {
if (result.ok) {
result.json()
.then((json) => {
setResourceState({
...resourceState,
values: json,
});
})
.catch((error) => {
setErrorMessage(`Error decoding response: ${error.message}`);
});
} else {
const errorMessage = result.status === 401 ?
'Your session is expired, please login again' :
'Error retrieving earnings';
setErrorMessage(errorMessage);
expireSession();
}
})
.catch((error) => {
setErrorMessage(error.message);
});
};
...
Then, on my tests, using react-hooks-testing-library I do the following:
it.only('Should fail to get resource with invalid session', async () => {
const wrapper = ({ children }) => (
<AuthenticationProvider authStateTest={{ userInfo: { token: 'FOOBAR' }, isExpiredSession: false }}>
<ResourceProvider>{children}</ResourceProvider>
</AuthenticationProvider>
);
const { result, waitForNextUpdate } = renderHook(() => useResource(), { wrapper });
fetch.mockResponse(JSON.stringify({}), { status: 401 });
act(() => result.current.getResource());
await waitForNextUpdate();
expect(result.current.errorMessage).toEqual('Your session is expired, please login again');
// Here is the issue, how to test the global value of the Authentication context? the line below, of course, doesn't work
expect(result.current.isExpiredSession).toBeTruthy();
});
I have tried a few solutions:
Rendering the useAuthentication on the tests as well, however, the changes made by the Resource doesn't seem to reflect on it.
Exposing the isExpiredSession variable through the Resource hook, i.e:
return {
...
isExpiredSession: authState.isExpiredSession,
...
};
I was expecting that by then this line would work:
expect(result.current.isExpiredSession).toBeTruthy();
But still not working and the value is still false
Any idea how can I implement a solution for this problem?
Author of react-hooks-testing-library here.
It's a bit hard without being able to run the code, but I think your issue might be the multiple state updates not batching correctly as they are not wrapped in an act call. The ability to act on async calls is in an alpha release of react (v16.9.0-alpha.0) and we have an issue tracking it as well.
So there may be 2 ways to solve it:
Update to the alpha version and a move the waitForNextUpdate into the act callback
npm install react#16.9.0-alpha.0
it.only('Should fail to get resource with invalid session', async () => {
const wrapper = ({ children }) => (
<AuthenticationProvider authStateTest={{ userInfo: { token: 'FOOBAR' }, isExpiredSession: false }}>
<ResourceProvider>{children}</ResourceProvider>
</AuthenticationProvider>
);
const { result, waitForNextUpdate } = renderHook(() => useResource(), { wrapper });
fetch.mockResponse(JSON.stringify({}), { status: 401 });
await act(async () => {
result.current.getResource();
await waitForNextUpdate();
});
expect(result.current.errorMessage).toEqual('Your session is expired, please login again');
expect(result.current.isExpiredSession).toBeTruthy();
});
Add in a second waitForNextUpdate call
it.only('Should fail to get resource with invalid session', async () => {
const wrapper = ({ children }) => (
<AuthenticationProvider authStateTest={{ userInfo: { token: 'FOOBAR' }, isExpiredSession: false }}>
<ResourceProvider>{children}</ResourceProvider>
</AuthenticationProvider>
);
const { result, waitForNextUpdate } = renderHook(() => useResource(), { wrapper });
fetch.mockResponse(JSON.stringify({}), { status: 401 });
act(() => result.current.getResource());
// await setErrorMessage to happen
await waitForNextUpdate();
// await setAuthState to happen
await waitForNextUpdate();
expect(result.current.errorMessage).toEqual('Your session is expired, please login again');
expect(result.current.isExpiredSession).toBeTruthy();
});
Your appetite for using alpha versions will likely dictate which option you go for, but, option 1 is the more "future proof". Option 2 may stop working one day once the alpha version hits a stable release.

Redux-observable: failed jest test for epic

I followed the steps from documentation to test epic.
...
store.dispatch({ type: FETCH_USER });
expect(store.getActions()).toEqual([
{ type: FETCH_USER },
{ type: FETCH_USER_FULFILLED, payload }
]);
...
But I get failed because second action is been received some later like following.
Test failed
Expected value to equal:
[{"type": "FETCH_USER"}, {"type": "FETCH_USER_FULFILLED", "payload": [some]}]
Received:
[{"type": "FETCH_USER"}]
Difference:
- Expected
+ Received
## -1,20 +1,5 ##
Array [
Object {"type": "FETCH_USER"},
Object {"type": "FETCH_USER_FULFILLED", "payload": [some]} ] // this is what should be.
So I think I should know when the dispatch is finished or some like that.
How can I solve this?
I used fetch() and Rx.Observable.fromPromise instead of ajax.getJSON()
Here is my epic.
const fetchUserEpic = (action$) =>
action$
.ofType(FETCH_USER)
.mergeMap(() => {
return Rx.Observable.fromPromise(api.fetchUser())
.map((users) => ({
type: FETCH_USER_FULFILLED,
payload: { users }
}))
.catch((error) => Rx.Observable.of({
type: FETCH_USER_ERROR,
payload: { error }
}))
.takeUntil(action$.ofType(FETCH_USER_CANCELLED))
})
The reason is that promises always resolve on the next microtask so your api.fetchUser() isn't emitting synchronously.
You'll need to either mock it out, use something like Promise.resolve().then(() => expect(store.getActions).toEqual(...) to wait until the next microtask, or you can experiment with testing your epics directly without using redux.
it('Epics with the appropriate input and output of actions', (done) => {
const action$ = ActionsObservable.of({ type: 'SOMETHING' });
somethingEpic(action$, store)
.toArray() // collects everything in an array until our epic completes
.subscribe(actions => {
expect(actions).to.deep.equal([
{ type: 'SOMETHING_FULFILLED' }// whatever actions
]);
done();
});
});
This will be our preferred testing story in the docs when I (or someone else) has time to write them up. So instead of using redux and the middleware in your tests, we just call the epic function directly with our own mocks. Much easier and cleaner.
With that approach, we can leverage the new dependency injection feature of redux-observable: https://redux-observable.js.org/docs/recipes/InjectingDependenciesIntoEpics.html
import { createEpicMiddleware, combineEpics } from 'redux-observable';
import { ajax } from 'rxjs/observable/dom/ajax';
import rootEpic from './somewhere';
const epicMiddleware = createEpicMiddleware(rootEpic, {
dependencies: { getJSON: ajax.getJSON }
});
// Notice the third argument is our injected dependencies!
const fetchUserEpic = (action$, store, { getJSON }) =>
action$.ofType('FETCH_USER')
.mergeMap(() =>
getJSON(`/api/users/${payload}`)
.map(response => ({
type: 'FETCH_USER_FULFILLED',
payload: response
}))
);
import { ActionsObservable } from 'redux-observable';
import { fetchUserEpic } from './somewhere/fetchUserEpic';
const mockResponse = { name: 'Bilbo Baggins' };
const action$ = ActionsObservable.of({ type: 'FETCH_USERS_REQUESTED' });
const store = null; // not needed for this epic
const dependencies = {
getJSON: url => Observable.of(mockResponse)
};
// Adapt this example to your test framework and specific use cases
fetchUserEpic(action$, store, dependencies)
.toArray() // buffers all emitted actions until your Epic naturally completes()
.subscribe(actions => {
assertDeepEqual(actions, [{
type: 'FETCH_USER_FULFILLED',
payload: mockResponse
}]);
});
First, use isomorphic-fetch instead of Observable.ajax for nock support, like this
const fetchSomeData = (api: string, params: FetchDataParams) => {
const request = fetch(`${api}?${stringify(params)}`)
.then(res => res.json());
return Observable.from(request);
};
So my epic is:
const fetchDataEpic: Epic<GateAction, ImGateState> = action$ =>
action$
.ofType(FETCH_MODEL)
.mergeMap((action: FetchModel) =>
fetchDynamicData(action.url, action.params)
.map((payload: FetchedData) => fetchModelSucc(payload.data))
.catch(error => Observable.of(
fetchModelFail(error)
)));
Then, you may need an interval to decide when to finish the test.
describe("epics", () => {
let store: MockStore<{}>;
beforeEach(() => {
store = mockStore();
});
afterEach(() => {
nock.cleanAll();
epicMiddleware.replaceEpic(epic);
});
it("fetch data model succ", () => {
const payload = {
code: 0,
data: someData,
header: {},
msg: "ok"
};
const params = {
data1: 100,
data2: "4"
};
const mock = nock("https://test.com")
.get("/test")
.query(params)
.reply(200, payload);
const go = new Promise((resolve) => {
store.dispatch({
type: FETCH_MODEL,
url: "https://test.com/test",
params
});
let interval: number;
interval = window.setInterval(() => {
if (mock.isDone()) {
clearInterval(interval);
resolve(store.getActions());
}
}, 20);
});
return expect(go).resolves.toEqual([
{
type: FETCH_MODEL,
url: "https://test.com/assignment",
params
},
{
type: FETCH_MODEL_SUCC,
data: somData
}
]);
});
});
enjoy it :)

Resources