Search code examples
reactjsunit-testingreact-hooksreact-reduxjestjs

How to add mock for GetBooksAction in React BooksContainer unit test using Jest?


I am new to React and Jest and am struggling with almost everything so far. I am trying to follow along with tutorial I have found.

This is simple React frontend application for book store. So far I have created a simple layout component, then inside BookContainer component, in which BookList component with list of books fetched is presented. Then each book has a single BookListItem component.

Then I have simple BookService with getAllBooks for fetching books from a Rest Api on backend side. Additionally I also have a simple BookReducer, BookSelector and BookAction, which all handle saving to and fetching from a Redux store.

I am using redux, react-hooks, redux toolkit, jest and javascript.

Everything works ok when I run it in a web browser, the books are fetched, saved into store, and then presented inside the BookContainer component.

Now I am trying to add a simple unit test for this BookContainer component and I am asking for help with it.

I would like this unit test to check if the BookList component is rendered (haveBeenCalledWith), the list of books which I pass into the render method.

And also I would like to mock for this the BookAction, to return the list of books which I am passing into render. And this is exactly what I struggle with right now.

Here is my BookContainer component:

import React, { useEffect } from 'react';
import { Box } from '@mui/material';
import { useDispatch, useSelector } from 'react-redux';
import getBooksAction from '../../modules/book/BookAction';
import BookFilter from './BookFilter';
import styles from './BookStyles.module.css';
import { getBooksSelector } from '../../modules/book/BookSelector';
import BookList from './BookList';

const BookContainer = () => {

const dispatch = useDispatch();

useEffect(() => {
    dispatch(getBooksAction());
}, [dispatch]);

const booksResponse = useSelector(getBooksSelector);

if (booksResponse && booksResponse.books) {

    return (
        <Box className={styles.bookContainer}>
            <BookFilter />

            <Box className={styles.bookList}>
                
                <BookList books={booksResponse.books} />
            </Box>
        </Box>
    );
}

return <BookList books={[]} />;
}

export default BookContainer;

Here is my BookList component:

import { Box } from '@mui/material';
import Proptypes from 'prop-types';
import React from 'react';
import styles from './BookStyles.module.css';
import BookListItem from './BookListItem';

const propTypes = {

books: Proptypes.arrayOf(
    Proptypes.shape({
        id: Proptypes.number.isRequired,
        title: Proptypes.string.isRequired,
        description: Proptypes.string.isRequired,
        author: Proptypes.string.isRequired,
        releaseYear: Proptypes.number.isRequired,
    })
).isRequired,
};

const BookList = ({books}) => {

return (
    <Box className={styles.bookList} ml={5}>
        {books.map((book) => {
            return (
                <BookListItem book={book} key={book.id} />
            );
        })}
    </Box>
);
}

BookList.propTypes = propTypes;
export default BookList;

Here is my BookAction:

import getBooksService from "./BookService";

const getBooksAction = () => async (dispatch) => {

try {
    // const books = await getBooksService();
    // dispatch({
    //     type: 'BOOKS_RESPONSE',
    //     payload: books.data
    // });

    return getBooksService().then(res => {
        dispatch({
            type: 'BOOKS_RESPONSE',
            payload: res.data
        });
    });
}
catch(error) {
    console.log(error);
}
};

export default getBooksAction;

Here is my BookContainer.test.jsx:

import React from "react";
import { renderWithRedux } from '../../../helpers/test_helpers/TestSetupProvider';
import BookContainer from "../BookContainer";
import BookList from "../BookList";
import getBooksAction from "../../../modules/book/BookAction";
import { bookContainerStateWithData } from '../../../helpers/test_helpers/TestDataProvider';

// Mocking component
jest.mock("../BookList", () => jest.fn());
jest.mock("../../../modules/book/BookAction", () => ({
    getBooksAction: jest.fn(),
}));

describe("BookContainer", () => {

it("should render without error", () => {
const books = bookContainerStateWithData.initialState.bookReducer.books;

// Mocking component
BookList.mockImplementation(() => <div>mock booklist comp</div>);

// Mocking actions

getBooksAction.mockImplementation(() => (dispatch) => {
  dispatch({
    type: "BOOKS_RESPONSE",
    payload: books,
  });
});


renderWithRedux(<BookContainer />, {});

// Asserting BookList was called (was correctly mocked) in BookContainer
expect(BookList).toHaveBeenLastCalledWith({ books }, {});

});

});

Here is my TestDataProvider for bookContainerStateWithData used in test:

const getBooksActionData = [
    {
        id: 1,
        title: 'test title',
        description: 'test description',
        author: 'test author',
        releaseYear: 1951
    }
];

const getBooksReducerData = {
    books: getBooksActionData
};

const bookContainerStateWithData = {
    initialState: {
        bookReducer: {
            ...getBooksReducerData
        }
    }
};

export {
    bookContainerStateWithData
};

And here is my renderWithRedux() helper method from TestSetupProvider used in test:

import { createSoteWithMiddleware } from '../ReduxStoreHelper';
import React from 'react';
import { Provider } from 'react-redux';
import reducers from '../../modules';

const renderWithRedux = (
    ui, 
    {
        initialState,
        store = createSoteWithMiddleware(reducers, initialState)
    }
) => ({
    ...render(
        <Provider store={store}>{ui}</Provider>
    )
});

Here is my ReduxStoreHelper which provides createSoteWithMiddleware() used in TestSetupProvider:

import reduxThunk from 'redux-thunk';
import { legacy_createStore as createStore, applyMiddleware } from "redux";
import reducers from '../modules';

const createSoteWithMiddleware = applyMiddleware(reduxThunk)(createStore);

export {
    createSoteWithMiddleware
}

And the error message which I get currently:

BookContainer › should render without error

TypeError: _BookAction.default.mockImplementation is not a function

On this line in BookContainer unit test:

getBooksAction.mockImplementation(() => (dispatch) => {

Thank you for any help or suggestions. I was searching for similar problems and solutions, but so far not successfully.

Edit 1

If I add __esModule: true to the jest mock for getBooksAction, like so:

jest.mock("../../../modules/book/BookAction", () => ({
    __esModule: true,
    getBooksAction: jest.fn(),
}));

Then the error message is different:

TypeError: Cannot read properties of undefined (reading 'mockImplementation')

Edit2

If I change getBooksAction key to default in jest mock like so:

jest.mock("../../../modules/book/BookAction", () => ({
    __esModule: true,
    default: jest.fn(),
}));

Then there is no type error anymore, but assertion error instead (closer a bit):

- Expected
+ Received

  Object {
-   "books": Array [
-     Object {
-       "author": "test author",
-       "description": "test description",
-       "id": 1,
-       "releaseYear": 1951,
-       "title": "test title",
-     },
-   ],
+   "books": Array [],
  },
  {},

Number of calls: 1

So empty array of books is returned now. So how to change the mock to dispatch the given array of books ?

Edit 3

I guess I have found the root cause of the problem. When the BookContainer is being created and rendered, the fetching of books happens several times in a row. The first two return empty array of books. And starting from the third time, the fetched books array is returned. I know it by adding console log to BookContainer, just after useEffect:

const booksResponse = useSelector(getBooksSelector);
console.log(booksResponse);

Is it supposed to be called that many times in a row ? Shouldnt it be just one call with properly fetched books array. What can be the cause of this behavior, is it some error in my code somewhere else ?

By the way this is also the reason why I have this nasty IF statement in my BookContainer component. Although in the tutorial there is not and it all works as expected. It seems like the request / actions are doubled every time BookContainer is being rendered...

Edit 4

I was using StrictMode in my index file. After removing it, the doubled requests are gone, the useEffect() in BookContainer executes only once now. But still the render method of BookContainer executes twice - first time with empty books array, and second time with fetched books array.


Solution

  • It ended up to be wrong mapping of response data between my backend and frontend as a root cause of it.

    My api response for Get Books endpoint is this:

    {
        "books": [...]
    }
    

    So basicly it is not a json array, but json object with array inside it. As a good practices for api responses say, to be more flexibile.

    But, on my frontend side, the code which I've written, basicly wrongly assumed that api response is just a json array, in BookList:

    const propTypes = {
    
        books: Proptypes.arrayOf(
            Proptypes.shape({
                id: Proptypes.number.isRequired,
                title: Proptypes.string.isRequired,
                description: Proptypes.string.isRequired,
                author: Proptypes.string.isRequired,
                releaseYear: Proptypes.number.isRequired,
            })
        ).isRequired,
    };
    

    When changing it to this:

    const propTypes = {
    
      booksResponse: Proptypes.shape({
        books: Proptypes.arrayOf(
            Proptypes.shape({
                id: Proptypes.number.isRequired,
                title: Proptypes.string.isRequired,
                description: Proptypes.string.isRequired,
                author: Proptypes.string.isRequired,
                releaseYear: Proptypes.number.isRequired,
            })
        ).isRequired,
      })
    };
    

    And then further in BookList component, adapt to this change:

    const BookList = ({booksResponse}) => {
    
      return (
        <Box className={styles.bookList} ml={5}>
            {booksResponse.books.map((book) => {
                return (
                    <BookListItem book={book} key={book.id} />
                );
            })}
        </Box>
      );
    }
    

    And then finally in the unit test also:

    expect(BookList).toHaveBeenLastCalledWith({ booksResponse: books }, {});
    

    And getBooksAction mock no need for any default or __esModule:

    jest.mock("../../../modules/book/BookAction", () => ({
        getBooksAction: jest.fn(),
    }));
    

    Everything works as expected. :)