I intend to write unit test for the following epic
// Actions
const actionCreator = actionCreatorFactory('PARENT_DIRECTORY');
export const fetchPage = actionCreator.async<Page, ParentPage>('FETCH_PAGE');
export const fetchParentDirectoryEpic: Epic = action$ =>
action$.pipe(
filter(fetchPage.started.match),
mergeMap((action) => {
return getDirectoryPage(action.payload).pipe(
map(response => fetchPage.done({ params: action.payload, result: response.response })),
catchError(error => of(fetchPage.failed({ params: action.payload, error: error })))
);
})
);
I mocked the getDirectoryPage like below -
import { AjaxResponse, AjaxError } from 'rxjs/ajax';
import { Observable, of } from 'rxjs';
export function getDirectoryPage(page: any): Observable<AjaxResponse> {
switch (page.index) {
case 0:
return Observable.create({'data': [], page: 0, pages: 1});
default:
return Observable.create(observer => {
return new AjaxError('Something bad happened!', null, null);
});
}
}
and following is how my unit test looks like -
describe('fetchParentDirectoryEpic Epic', () => {
it('dispatches the correct actions when it is successful', async (done) => {
const expectedOutputAction = outputAction;
fetchParentDirectoryEpic(inputAction, initialState, null)
.subscribe(actualOutputAction => {
expect(actualOutputAction).toBe(expectedOutputAction)
done()
}
);
});
});
Issue is that the call to fetchParentDirectoryEpic(inputAction, initialState, null) results in an Observable which doesn't have subscribe method. As I understand, the method is available with ActionObservable but I am unable to create its instance using a payload.
The issue was related to how I was creating expectedOutputAction. Its supposed to be an Action and not an ActionObservable.
After setting expectedOutputAction in the following manner, test worked out fine -
expectedOutputAction = {
type: fetchPage.done.type,
result: {'data': [], page: 0, pages: 1},
params: inputAction.payload
}
Related
I use next-redux-wrapper, MSW, #mswjs/data and redux-toolkit for storing my data in a store as well as mocking API calls and fetching from a mock Database.
I have the following scenario happening to me.
I am on page /content/editor and in the console and terminal, I can see the data was fetched from the mock database and hydrated from getStaticProps of Editor.js. So now IDs 1 to 6 are inside the store accessible.
Now I click on the PLUS icon to create a new project. I fill out the dialog and press "SAVE". a POST request starts, it's pending and then it gets fulfilled. The new project is now in the mock DB as well as in the store, I can see IDs 1 to 7 now.
Since I clicked "SAVE" and the POST request was successful, I am being routed to /content/editor/7 to view the newly created project.
Now I am on Page [id].js, which also fetched data from the mock DB and then it gets stored and hydrated into the redux store. The idea is, it takes the previous store's state and spreads it into the store, with the new data (if there are any).
Now the ID 7 no longer exists. And IDs 1 to 6 also don't exist anymore, instead, I can see in the console and terminal that IDs 8 to 13 were created, and the previous ones are no more.
Obviously, this is not great. When I create a new project and then switch the route, I should be able to access the newly created project as well as the previously created ones. But instead, they all get overwritten.
It either has something to do with the next-redux-wrapper or MSW, but I am not sure how to make it work. I need help with it. I will post some code now:
Code
getStaticProps
// path example: /content/editor
// Editor.js
export const getStaticProps = wrapper.getStaticProps(
(store) =>
async ({ locale }) => {
const [translation] = await Promise.all([
serverSideTranslations(locale, ['editor', 'common', 'thesis']),
store.dispatch(fetchProjects()),
store.dispatch(fetchBuildingBlocks()),
]);
return {
props: {
...translation,
},
};
}
);
// path example: /content/editor/2
// [id].js
export const getStaticProps = wrapper.getStaticProps(
(store) =>
async ({ locale, params }) => {
const { id } = params;
const [translation] = await Promise.all([
serverSideTranslations(locale, ['editor', 'common', 'thesis']),
store.dispatch(fetchProjects()),
// store.dispatch(fetchProjectById(id)), // issue: fetching by ID returns null
store.dispatch(fetchBuildingBlocks()),
]);
return {
props: {
...translation,
id,
},
};
}
);
Mock Database
Factory
I am going to shorten the code to the relevant bits. I will remove properties for a project, as well es helper functions to generate data.
const asscendingId = (() => {
let id = 1;
return () => id++;
})();
const isDevelopment =
process.env.NODE_ENV === 'development' || process.env.STORYBOOK || false;
export const projectFactory = () => {
return {
id: primaryKey(isDevelopment ? asscendingId : nanoid),
name: String,
// ... other properties
}
};
export const createProject = (data) => {
return {
name: data.name,
createdAt: getUnixTime(new Date()),
...data,
};
};
/**
* Create initial set of tasks
*/
export function generateMockProjects(amount) {
const projects = [];
for (let i = amount; i >= 0; i--) {
const project = createProject({
name: faker.lorem.sentence(faker.datatype.number({ min: 1, max: 5 })),
dueDate: date(),
fontFamily: getRandomFontFamily(),
pageMargins: getRandomPageMargins(),
textAlign: getRandomTextAlign(),
pageNumberPosition: getRandomPageNumberPosition(),
...createWordsCounter(),
});
projects.push(project);
}
return projects;
}
API Handler
I will shorten this one to GET and POST requests only.
import { db } from '../../db';
export const projectsHandlers = (delay = 0) => {
return [
rest.get('https://my.backend/mock/projects', getAllProjects(delay)),
rest.get('https://my.backend/mock/projects/:id', getProjectById(delay)),
rest.get('https://my.backend/mock/projectsNames', getProjectsNames(delay)),
rest.get(
'https://my.backend/mock/projects/name/:id',
getProjectsNamesById(delay)
),
rest.post('https://my.backend/mock/projects', postProject(delay)),
rest.patch(
'https://my.backend/mock/projects/:id',
updateProjectById(delay)
),
];
};
function getAllProjects(delay) {
return (request, response, context) => {
const projects = db.project.getAll();
return response(context.delay(delay), context.json(projects));
};
}
function postProject(delay) {
return (request, response, context) => {
const { body } = request;
if (body.content === 'error') {
return response(
context.delay(delay),
context.status(500),
context.json('Server error saving this project')
);
}
const now = getUnixTime(new Date());
const project = db.project.create({
...body,
createdAt: now,
maxWords: 10_000,
minWords: 7000,
targetWords: 8500,
potentialWords: 1500,
currentWords: 0,
});
return response(context.delay(delay), context.json(project));
};
}
// all handlers
import { buildingBlocksHandlers } from './api/buildingblocks';
import { checklistHandlers } from './api/checklist';
import { paragraphsHandlers } from './api/paragraphs';
import { projectsHandlers } from './api/projects';
import { tasksHandlers } from './api/tasks';
const ARTIFICIAL_DELAY_MS = 2000;
export const handlers = [
...tasksHandlers(ARTIFICIAL_DELAY_MS),
...checklistHandlers(ARTIFICIAL_DELAY_MS),
...projectsHandlers(ARTIFICIAL_DELAY_MS),
...buildingBlocksHandlers(ARTIFICIAL_DELAY_MS),
...paragraphsHandlers(ARTIFICIAL_DELAY_MS),
];
// database
import { factory } from '#mswjs/data';
import {
buildingBlockFactory,
generateMockBuildingBlocks,
} from './factory/buildingblocks.factory';
import {
checklistFactory,
generateMockChecklist,
} from './factory/checklist.factory';
import { paragraphFactory } from './factory/paragraph.factory';
import {
projectFactory,
generateMockProjects,
} from './factory/project.factory';
import { taskFactory, generateMockTasks } from './factory/task.factory';
export const db = factory({
task: taskFactory(),
checklist: checklistFactory(),
project: projectFactory(),
buildingBlock: buildingBlockFactory(),
paragraph: paragraphFactory(),
});
generateMockProjects(5).map((project) => db.project.create(project));
const projectIds = db.project.getAll().map((project) => project.id);
generateMockTasks(20, projectIds).map((task) => db.task.create(task));
generateMockBuildingBlocks(10, projectIds).map((block) =>
db.buildingBlock.create(block)
);
const taskIds = db.task.getAll().map((task) => task.id);
generateMockChecklist(20, taskIds).map((item) => db.checklist.create(item));
Project Slice
I will shorten this one as well to the relevant snippets.
// projects.slice.js
import {
createAsyncThunk,
createEntityAdapter,
createSelector,
createSlice,
current,
} from '#reduxjs/toolkit';
import { client } from 'mocks/client';
import { HYDRATE } from 'next-redux-wrapper';
const projectsAdapter = createEntityAdapter();
const initialState = projectsAdapter.getInitialState({
status: 'idle',
filter: { type: null, value: null },
statuses: {},
});
export const fetchProjects = createAsyncThunk(
'projects/fetchProjects',
async () => {
const response = await client.get('https://my.backend/mock/projects');
return response.data;
}
);
export const saveNewProject = createAsyncThunk(
'projects/saveNewProject',
async (data) => {
const response = await client.post('https://my.backend/mock/projects', {
...data,
});
return response.data;
}
);
export const projectSlice = createSlice({
name: 'projects',
initialState,
reducers: {
// irrelevant reducers....
},
extraReducers: (builder) => {
builder
.addCase(HYDRATE, (state, action) => {
// eslint-disable-next-line no-console
console.log('HYDRATE', action.payload);
const statuses = Object.fromEntries(
action.payload.projects.ids.map((id) => [id, 'idle'])
);
return {
...state,
...action.payload.projects,
statuses,
};
})
.addCase(fetchProjects.pending, (state, action) => {
state.status = 'loading';
})
.addCase(fetchProjects.fulfilled, (state, action) => {
projectsAdapter.addMany(state, action.payload);
state.status = 'idle';
action.payload.forEach((item) => {
state.statuses[item.id] = 'idle';
});
})
.addCase(saveNewProject.pending, (state, action) => {
console.log('SAVE NEW PROJECT PENDING', action);
})
.addCase(saveNewProject.fulfilled, (state, action) => {
projectsAdapter.addOne(state, action.payload);
console.group('SAVE NEW PROJECT FULFILLED');
console.log(current(state));
console.log(action);
console.groupEnd();
state.statuses[action.payload.id] = 'idle';
})
// other irrelevant reducers...
},
});
This should be all the relevant code. If you have questions, please ask them and I will try to answer them.
I have changed how the state gets hydrated, so I turned this code:
.addCase(HYDRATE, (state, action) => {
// eslint-disable-next-line no-console
console.log('HYDRATE', action.payload);
const statuses = Object.fromEntries(
action.payload.projects.ids.map((id) => [id, 'idle'])
);
return {
...state,
...action.payload.projects,
statuses,
};
})
Into this code:
.addCase(HYDRATE, (state, action) => {
// eslint-disable-next-line no-console
console.group('HYDRATE', action.payload);
const statuses = Object.fromEntries(
action.payload.projects.ids.map((id) => [id, 'idle'])
);
state.statuses = { ...state.statuses, ...statuses };
projectsAdapter.upsertMany(state, action.payload.projects.entities);
})
I used the adapter to upsert all entries.
I have a reducer that is intended for handling notification banners.
const notifReducer = (state = { notifMessage: null, notifType: null, timeoutID: null },
action
) => {
switch (action.type) {
case 'SET_NOTIFICATION':
if (state.timeoutID) {
clearTimeout(state.timeoutID)
}
return {
notifMessage: action.notifMessage,
notifType: action.notifType,
timeoutID: null
}
case 'REMOVE_NOTIFICATION':
return {
notifMessage: null,
notifType: null,
timeoutID: null
}
case 'REFRESH_TIMEOUT':
return {
...state,
timeoutID: action.timeoutID
}
default:
return state
}
}
export const setNotification = (notifMessage, notifType) => {
return async dispatch => {
dispatch({
type: 'SET_NOTIFICATION',
notifMessage,
notifType
})
let timeoutID = await setTimeout(() => {
dispatch({
type: 'REMOVE_NOTIFICATION'
})
}, 5000)
dispatch({
type: 'REFRESH_TIMEOUT',
timeoutID
})
}
}
export default notifReducer
It works fully fine in the rest of my app, except in this one event handler that uses a try-catch. If I intentionally trigger the catch statement (by logging in with a bad username/password), I get "Unhandle Reject (Error): Actions must be plain objects. Use custom middleware for async action", but I am already using redux-thunk middleware!
const dispatch = useDispatch()
const handleLogin = async (event) => {
event.preventDefault()
try {
const user = await loginService.login({
username, password
})
//
} catch (exception) {
dispatch(setNotification(
'wrong username or password',
'error')
)
}
}
edit:
here is my store.js contents
const reducers = combineReducers({
blogs: blogReducer,
user: userReducer,
notification: notifReducer,
})
const store = createStore(
reducers,
composeWithDevTools(
applyMiddleware(thunk)
)
)
I hope your question is answered in a post already. Please check the below link
Error handling redux-promise-middleware
this is the commnetsReducer.js file
import { ADD_COMMENT } from "./actionType";
// let initialState = {
// commentList : []
// };
const commnetsReducer = (state = { commentList: [] }, action) => {
switch (action.type) {
case ADD_COMMENT:
return { ...state, commentList: [...state.commentList, action.payload] };
default:
return state;
}
};
export default commnetsReducer;
**this is the unit test for above reducer commnetsReducer.test.js **
import commnetsReducer from "../reducer";
import { ADD_COMMENT } from "../actionType";
// const uuid = require("uuid");
describe("comment reducer ", () => {
it("should returns initial state", () => {
expect(commnetsReducer(undefined, {})).toEqual({
commentList: []
});
});
it("handle action of type SAVE_COMMENT ", () => {
expect(
commnetsReducer([], { type: ADD_COMMENT, payload: "new comment" })
).toEqual({commentList :['new comment']});
});
});
**this is the error I got in console **
enter image description here
commnetsReducer([], { type: ADD_COMMENT, payload: "new comment" })
You've passed in an array as the state. Arrays have no .commentList property, so when your reducer tries to spread state.commentList you get that error from trying to spread undefined.
Instead, pass in a state with the right shape, such as:
commnetsReducer({
commentList: []
}, {
type: ADD_COMMENT,
payload: "new comment"
});
I've got following Epic which works well in application, but I can't get my marble test working. I am calling action creator in map and it does return correct object into stream, but in the test I am getting empty stream back.
export const updateRemoteFieldEpic = action$ =>
action$.pipe(
ofType(UPDATE_REMOTE_FIELD),
filter(({ payload: { update = true } }) => update),
mergeMap(({ payload }) => {
const { orderId, fields } = payload;
const requiredFieldIds = [4, 12]; // 4 = Name, 12 = Client-lookup
const requestData = {
id: orderId,
customFields: fields
.map(field => {
return (!field.value && !requiredFieldIds.includes(field.id)) ||
field.value
? field
: null;
})
.filter(Boolean)
};
if (requestData.customFields.length > 0) {
return from(axios.post(`/customfields/${orderId}`, requestData)).pipe(
map(() => queueAlert("Draft Saved")),
catchError(err => {
const errorMessage =
err.response &&
err.response.data &&
err.response.data.validationResult
? err.response.data.validationResult[0]
: undefined;
return of(queueAlert(errorMessage));
})
);
}
return of();
})
);
On successfull response from server I am calling queueAlert action creator.
export const queueAlert = (
message,
position = {
vertical: "bottom",
horizontal: "center"
}
) => ({
type: QUEUE_ALERT,
payload: {
key: uniqueId(),
open: true,
message,
position
}
});
and here is my test case
describe("updateRemoteFieldEpic", () => {
const sandbox = sinon.createSandbox();
let scheduler;
beforeEach(() => {
scheduler = new TestScheduler((actual, expected) => {
expect(actual).toEqual(expected);
});
});
afterEach(() => {
sandbox.restore();
});
it("should return success message", () => {
scheduler.run(ts => {
const inputM = "--a--";
const outputM = "--b--";
const values = {
a: updateRemoteField({
orderId: 1,
fields: [{ value: "test string", id: 20 }],
update: true
}),
b: queueAlert("Draft Saved")
};
const source = ActionsObservable.from(ts.cold(inputM, values));
const actual = updateRemoteFieldEpic(source);
const axiosStub = sandbox
.stub(axios, "post")
.returns([]);
ts.expectObservable(actual).toBe(outputM, values);
ts.flush();
expect(axiosStub.called).toBe(true);
});
});
});
output stream in actual returns empty array
I tried to return from map observable of the action creator which crashed application because action expected object.
By stubbing axios.post(...) as [], you get from([]) in the epic - an empty observable that doesn't emit any values. That's why your mergeMap is never called. You can fix this by using a single-element array as stubbed value instead, e.g. [null] or [{}].
The below is an answer to a previous version of the question. I kept it for reference, and because I think the content is useful for those who attempt to mock promise-returning functions in epic tests.
I think your problem is the from(axios.post(...)) in your epic. Axios returns a promise, and the RxJS TestScheduler has no way of making that synchronous, so expectObservable will not work as intended.
The way I usually address this is to create a simple wrapper module that does Promise-to-Observable conversion. In your case, it could look like this:
// api.js
import axios from 'axios';
import { map } from 'rxjs/operators';
export function post(path, data) {
return from(axios.post(path, options));
}
Once you have this wrapper, you can mock the function to return a constant Observable, taking promises completely out of the picture. If you do this with Jest, you can mock the module directly:
import * as api from '../api.js';
jest.mock('../api.js');
// In the test:
api.post.mockReturnValue(of(/* the response */));
Otherwise, you can also use redux-observable's dependency injection mechanism to inject the API module. Your epic would then receive it as third argument:
export const updateRemoteFieldEpic = (action$, state, { api }) =>
action$.pipe(
ofType(UPDATE_REMOTE_FIELD),
filter(({ payload: { update = true } }) => update),
mergeMap(({ payload }) => {
// ...
return api.post(...).pipe(...);
})
);
In your test, you would then just passed a mocked api object.
I'm working in a project with react and redux, I'm enough new so I'm trying to understand better how to use redux-thunk and redux-promise together.
Below you can see my files, in my actions I created a fetch generic function apiFetch() in order to use every time I need to fetch. This function return a promise, that I'm going to resolve in loadBooks(), the code is working and the records are uploaded but when I check the log of the actions I see that the first action is undefined, after there is BOOKS_LOADING, LOAD_BOOKS, BOOKS_LOADING and LOAD_BOOKS_SUCCESS.
I've 2 questions about that:
1) Why is the first action undefined and I've LOAD_BOOKS instead than LOAD_BOOKS_START?
action # 22:54:37.403 undefined
core.js:112 prev state Object {activeBook: null, booksListing: Object}
core.js:116 action function (dispatch) {
var url = './src/data/payload.json';
dispatch(booksIsLoading(true));
return dispatch({
type: 'LOAD_BOOKS',
payload: new Promise(function (resolve) {
…
core.js:124 next state Object {activeBook: null, booksListing: Object}
action # 22:54:37.404 BOOKS_LOADING
action # 22:54:37.413 LOAD_BOOKS
action # 22:54:39.420 BOOKS_LOADING
action # 22:54:39.425 LOAD_BOOKS_SUCCESS
2) If for example the url for the fetch is wrong, I expected to see the action LOAD_BOOKS_ERROR, instead this is the result of the log:
action # 23:06:06.837 undefined action # 23:06:06.837 BOOKS_LOADING
action # 23:06:06.846 LOAD_BOOKS GET
http://localhost:8000/src/data/payldoad.json 404 (Not Found) error
apiFetch Error: request failed at index.js:66 error
TypeError: Cannot read property 'json' of undefined at index.js:90
If I don't use apiFetch(), but normal fetch function, all is working correctly, also the part of the error, with the exception that anyway LOAD_BOOKS is not LOAD_BOOKS_START.
Thank you in advance for any help!
configureStore.js
import { createStore, applyMiddleware, compose, preloadedState } from 'redux';
import reducers from './configureReducer';
import configureMiddleware from './configureMiddleware';
const middleware = configureMiddleware();
const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
const store = createStore(reducers, preloadedState, composeEnhancers(applyMiddleware(...middleware)));
export default store;
actions/index.js
import fetch from 'isomorphic-fetch';
export const booksIsLoading = (bool) => {
return {
type: 'BOOKS_LOADING',
booksLoading: bool,
};
};
const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
export const apiFetch = (url) => {
const getPromise = () => (
fetch(url, {
method: 'GET',
})
.then((response) => {
if (response.status !== 200) {
throw Error('request failed');
}
return response;
})
.catch((err) => {
console.log('error apiFetch', err);
// dispatch(fetchBooksError(true));
})
);
return getPromise();
};
export const loadBooks = () => (dispatch) => {
const url = './src/data/payload.json';
dispatch(booksIsLoading(true));
return dispatch({
type: 'LOAD_BOOKS',
payload: new Promise((resolve) => {
delay(2000).then(() => {
apiFetch(`${url}`)
// fetch(`${url}`, {
// method: 'GET',
// })
.then((response) => {
resolve(response.json());
dispatch(booksIsLoading(false));
}).catch((err) => {
console.log('error', err);
});
});
}),
});
};
constants/application.js
export const LOAD_BOOKS = 'LOAD_BOOKS';
reducers/reducer_book.js
import initialState from '../model.js';
import * as types from '../constants/application';
export default function (state = initialState, action) {
switch (action.type) {
case `${types.LOAD_BOOKS}_SUCCESS`: {
console.log('reducer', action.payload);
const data = action.payload.data.items;
const items = Object.values(data);
if (items.length > 0) {
return {
...state,
books: Object.values(data),
booksFetched: true,
booksError: false,
};
}
return state;
}
case `${types.LOAD_BOOKS}_ERROR`: {
return {
...state,
booksError: true,
};
}
case 'BOOKS_LOADING':
return {
...state,
booksLoading: action.booksLoading,
};
default:
return state;
}
}
In which order did you specify middlewares?
Following usage makes action go undefined:
'applyMiddleware(reduxPromiseMiddleware(), reduxThunk)'
Please change the order to: ( thunk first! )
'applyMiddleware(reduxThunk, reduxPromiseMiddleware())'