I'm new to React and Jest and struggling with almost everything so far. I'm trying to follow a tutorial I found.
This is a simple bookstore React front-end application. So far, I have created a simple layout component and then created a BookList component inside the BookContainer component that contains the fetched list of books. Then each book has a BookListItem component.
Then I have simple BookService and getAllBooks for getting books from Rest Api on backend. Additionally, I have a simple BookReducer, BookSelector and BookAction that all handle saving and getting from the Redux store.
I'm using redux, react-hooks, redux toolkit, jest and javascript.
When I run this in a web browser, everything works fine, the book is fetched, saved to the store, and then rendered in the BookContainer component.
Now I'm trying to add a simple unit test for this BookContainer component and looking for help.
I want this unit test to check if the BookList component has been rendered (haveBeenCalledWith), i.e. the list of books I passed into the render method.
I also want to mock a BookAction that returns the list of books I pass to render. This is exactly what I'm grappling with right now.
This 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;
This 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;
This 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;
This 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 }, {}); }); });
This is the TestDataProvider for bookContainerStateWithData that I use in my tests:
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 };
This is the renderWithRedux() helper method from TestSetupProvider that I use in my tests:
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> ) });
This is my ReduxStoreHelper which provides the createSoteWithMiddleware() used in the 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 I'm currently receiving:
BookContainer › should render without error TypeError: _BookAction.default.mockImplementation is not a function
This line in the BookContainer unit test:
getBooksAction.mockImplementation(() => (dispatch) => {
Thanks for any help or advice. I've been searching for similar problems and solutions but so far without success.
If I add __esModule: true
to the joke mock of getBooksAction like this:
jest.mock("../../../modules/book/BookAction", () => ({ __esModule: true, getBooksAction: jest.fn(), }));
Then the error message is different:
TypeError: Cannot read properties of undefined (reading 'mockImplementation')
If I change the getBooksAction key to default in joke simulation like this:
jest.mock("../../../modules/book/BookAction", () => ({ __esModule: true, default: jest.fn(), }));
Then there is no longer a type error, but an assertion error (a little closer):
- 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 now an empty books array is returned. So how do I change the simulation to dispatch the given array of books?
I think I have found the root cause of the problem. When a BookContainer is created and rendered, books are fetched multiple times in a row. The first two return empty books arrays. Starting from the third time, return the obtained books array. I know this by adding console logs to the BookContainer after useEffect:
const booksResponse = useSelector(getBooksSelector); console.log(booksResponse);
Should it be called many times in a row? Shouldn't it just be a single call to get the array of books correctly? What is the cause of this behavior, is there something wrong somewhere else in my code?
By the way, this is also the reason why I have this annoying IF statement in the BookContainer component. Although not in the tutorial, everything works as expected. The requests/operations seem to be doubled every time the BookContainer is rendered...
I used StrictMode in the index file. After removing it, the double requests disappeared and useEffect() in BookContainer is now only executed once. But the BookContainer's render method is still executed twice - the first time with the empty books array, and the second time with the fetched books array.
P粉9860280392024-02-27 00:23:34
The ultimate root cause was an incorrect mapping of response data between my backend and frontend.
My API response to the get book endpoint is this:
{ "books": [...] }
So basically it's not a json array, but a json object with an array inside. As good API response practices say, be more flexible.
However, on my frontend, the code I wrote basically incorrectly assumes that the api response is just a json array in a 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, };
Change it to:
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, }) };
Then further adapt this change in the BookList component:
const BookList = ({booksResponse}) => { return ( <Box className={styles.bookList} ml={5}> {booksResponse.books.map((book) => { return ( <BookListItem book={book} key={book.id} /> ); })} </Box> ); }
Finally also in the unit test:
expect(BookList).toHaveBeenLastCalledWith({ booksResponse: books }, {});
And the getBooksAction mock doesn't require any defaults or __esModule:
jest.mock("../../../modules/book/BookAction", () => ({ getBooksAction: jest.fn(), }));
Everything works as expected. :)