Goal I'm developing an React app (created with create-react-app). Currently I'm trying to test my component using react-testing library and Jest to ensure it shows the two elements below on visiting the dashboard of my app: a) loading spinner and then, once the data is fetched and stored in Redux b) fetched list of posts
Problem When I run my test, the elements above just never appear regardless of timeout value. I would like to get advice on my implementation to find out the culprit.
Error Message
● PostList › should render a post list data after loading
Timed out in waitFor.
157 | it('should render a post list data after loading', async () => {
158 | renderWithProviders(<PostList />);
> 159 | const loadingEl = await waitFor(() => screen.findByTestId('main loader'));
| ^
160 | expect(loadingEl).toBeInTheDocument();
161 | const postEl = await waitFor(() => screen.findByText('test'));
162 | expect(postEl).toBeInTheDocument();
at waitForWrapper (node_modules/@testing-library/dom/dist/wait-for.js:166:27)
at Object.<anonymous> (src/container/post-list.test.tsx:159:36)
Ignored nodes: comments, script, style
<body>
<div>
<div
class="h-full mx-auto flex flex-row"
>
<aside
class="fixed w-full bottom-0 left-0 right-0 md:top-0 md:w-16 lg:w-64 bg-gray-800 text-white z-10"
>
<nav
class="flex py-3 lg:py-10 px-3 flex-row md:flex-col justify-between w-full h-full"
>
<div>
<div
class="h-[92px] text-center hidden md:block"
>
<img
alt="growcally logo"
class="hidden lg:inline-block"
src="img/text-logo-white.png"
/>
</div>
<ul
class="flex flex-row md:flex-col gap-8 mx-auto grow"
>
<li>
<a
class="pl-0 lg:pl-3 py-2 rounded-3xl hover:bg-gray-300 ease-in duration-300 flex gap-4"
href="/dashboard"
>
<button
class="w-full flex gap-4"
tabindex="0"
type="button"
>
<svg
aria-hidden="true"
class="h-6 w-6 mx-auto lg:!mx-0"
fill="none"
stroke="currentColor"
stroke-width="1.5"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
<p
class="hidden lg:block"
>
Home
</p>
</button>
</a>
</li>
<li>
<button
class="py-2 lg:pl-3 rounded-3xl hover:bg-gray-300 ease-in duration-300 flex gap-4 w-full flex gap-4"
tabindex="0"
type="button"
>
<svg
aria-hidden="true"
class="h-6 w-6 mx-auto lg:!mx-0"
fill="none"
stroke="currentColor"
stroke-width="1.5"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 9v6m3-3H9m12 0a9 9 0 11-18 0 9 9 0 0118 0z"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
<p
class="hidden lg:block"
>
Create
</p>
</button>
</li>
<li>
<a
class="pl-0 lg:pl-3 py-2 rounded-3xl hover:bg-gray-300 ease-in duration-300 flex gap-4"
href="/likes"
>
<button
class="w-full flex gap-4"
tabindex="0"
type="button"
>
<svg
class="h-6 w-6 mx-auto lg:!mx-0"
fill="white"
height="30"
viewBox="0 0 30 30"
width="26"
xmlns="http://www.w3.org/2000/svg"
>
<g
fill="white"
stroke="none"
stroke-width="1"
transform="translate(0.000000,32.000000) scale(0.100000,-0.100000)"
>
<path
d="M2 273 c2 -5 16 -33 30 -63 29 -62 51 -85 91 -95 23 -6 27 -12 27
-41 0 -19 5 -34 10 -34 6 0 10 9 10 20 0 15 8 22 29 26 32 7 53 27 94 91 26
41 26 42 7 48 -28 9 -74 -3 -104 -26 l-25 -20 -16 31 c-21 41 -71 70 -118 70
-21 0 -37 -3 -35 -7z m114 -47 c36 -36 49 -96 20 -96 -33 0 -96 79 -96 121 0
21 45 6 76 -25z m148 -59 c-31 -57 -94 -89 -94 -48 0 38 56 89 102 90 14 1 13
-5 -8 -42z"
/>
</g>
</svg>
<p
class="hidden lg:block"
>
Likes
</p>
</button>
</a>
</li>
</ul>
</div>
<div>
<button
class="py-2 lg:pl-3 rounded-3xl hover:bg-gray-300 ease-in duration-300 flex gap-4 w-full flex gap-4"
tabindex="0"
type="button"
>
<svg
aria-hidden="true"
class="h-6 w-6 mx-auto lg:!mx-0"
fill="none"
stroke="currentColor"
stroke-width="1.5"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
<p
class="hidden lg:block"
>
More
</p>
</button>
</div>
</nav>
</aside>
<div
class="sm:ml-1 md:!ml-16 lg:!ml-64 pt-10 px-2 md:px-12 lg:px-36 w-full"
>
<div
class="flex justify-center items-center h-screen"
>
<div
class="text-center"
>
<h3
class="text-3xl font-bold mb-4"
>
No posts yet
</h3>
<p
class="text-lg mb-6"
>
Create a new post to get started.
</p>
<button
class="w-full bg-primary-500 text-white inline-block p-2 rounded-lg hover:bg-opacity-75"
tabindex="0"
type="button"
>
Create Post
</button>
</div>
</div>
</div>
</div>
</div>
</body>
Test code
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { renderWithProviders } from '../util/test';
import { PostItem, PostList } from './post-list';
import { screen, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import { DashboardPage } from '../pages/dashboard-page';
import config from '../config';
import { User } from '../interfaces/user';
import { Post } from '../interfaces/post';
import { formatDistanceToNow } from 'date-fns';
const mockUser: User = {
id: '123',
status: 'active',
givenName: 'Carrot',
familyName: 'Legend',
email: 'carrotlegend@example.com',
bio: 'Lorem ipsum dolor sit amet.',
profileImage: {
id: '12345',
fileName: 'profile.jpg',
size: 1024,
mimetype: 'image/jpeg',
fileKey: 'abcde12345',
fileUrl: 'https://example.com/profile.jpg'
},
sub: 'xyz123',
posts: ['post1', 'post2'],
likedPosts: ['post3', 'post4'],
account: {
id: '456',
ownerId: '123'
},
comments: []
};
const mockPostItem1: Post = {
id: '123',
author: mockUser,
caption: 'test',
createdAt: '2023-06-19T15:30:00Z',
files: [
{
id: '123',
fileName: 'Vegetable garden',
size: '60b170c0-7673-46c4-a71d-861845ae9097',
mimetype: 'image/jpeg',
alt: 'Vegetable garden',
portraitFileKey:
'b269db9743e39238bb1babce7e70fb0db134f45dc222efac56e324016b764xxx',
squareFileKey:
'adked8886bd2f14f824914e5e87387136471a4aaf630f1073bcdg7cba',
portraitFileUrl: '/image/mock-post-image.png?'
}
],
totalLikes: 5,
comments: [],
totalComments: 7,
updatedAt: '2023-06-19T15:30:00Z'
};
const mockPostItem2: Post = {
id: '456',
author: mockUser,
caption: 'test2',
createdAt: '2023-06-20T15:30:00Z',
files: [
{
id: '234',
fileName: 'Muddy day',
size: '60b170c0-7673-46c4-a71d-861845ae9097',
mimetype: 'image/jpeg',
alt: 'Children in mud',
portraitFileKey:
'b269db9743e39238bb1babce7e70fb0db134f45dc222efac56e324016b764zzz',
squareFileKey: 'adked8886bd2f14f824914e5e87387136471a4aaf630f1073bcdgopd',
portraitFileUrl: '/image/mock-post-image.png?'
}
],
totalLikes: 5,
comments: [
{
id: '456',
content: 'You did a great job!',
updatedAt: '2023-06-19T15:30:00Z',
author: mockUser
}
],
totalComments: 7,
updatedAt: '2023-06-20T15:30:00Z'
};
const baseURL = config.apiUrl;
const server = setupServer(
rest.get(`${baseURL}/post/get-posts`, (req, res, ctx) => {
return res(ctx.json([mockPostItem1, mockPostItem2]));
})
);
beforeAll(() => server.listen());
afterEach(() => {
server.resetHandlers();
});
afterAll(() => server.close());
describe('PostList', () => {
it('should render a post list data after loading', async () => {
renderWithProviders(<PostList />);
const loadingEl = await waitFor(() => screen.findByTestId('main loader'));
expect(loadingEl).toBeInTheDocument();
const postEl = await waitFor(() => screen.findByText('test'));
expect(postEl).toBeInTheDocument();
}, 10000);
});
PostList component
import {
ChatBubbleOvalLeftIcon,
PaperAirplaneIcon
} from '@heroicons/react/24/outline';
import React from 'react';
import { Button } from '../components/button';
import { TextArea } from '../components/textarea';
import { Comment, MediaFile } from '../interfaces/post';
import { pluralize } from '../util/string';
import { useAppDispatch, useAppSelector } from '../hooks/hooks';
import formatDistanceToNow from 'date-fns/formatDistanceToNow';
import { useNavigate } from 'react-router-dom';
import { LOG_IN_PATH } from '../routes/routes';
import {
createComment,
getAllPosts,
likePost,
unlikePost
} from '../api/media.service';
import { setCurrentPost, updatePosts } from '../slices/posts-slice';
import { ModalType } from '../interfaces/modal-type';
import { resetModal, showModal } from '../slices/modals-slice';
import { User } from '../interfaces/user';
import { LeafFill } from '../icons/leaf-fill';
import { LeafNoFillBlack } from '../icons/leaf-no-fill-black';
import { MainLoader } from '../components/main-loader';
import { Thumbnail } from '../components/thumbnail';
export const PostList: React.FC = () => {
const dispatch = useAppDispatch();
const { posts } = useAppSelector((state) => state.posts);
const navigate = useNavigate();
const [isLoading, setIsLoading] = React.useState<boolean>(false);
const { user } = useAppSelector((state) => state.auth);
React.useEffect(() => {
let isMounted = true;
const fetchData = async () => {
try {
setIsLoading(true);
if (user) {
const { data, alertMessage, isSuccess } = await getAllPosts(user.id);
if (!isSuccess) {
if (alertMessage === 'Unauthorised') {
navigate(LOG_IN_PATH);
}
alert(alertMessage);
setIsLoading(false);
return;
}
if (isMounted) {
dispatch(updatePosts(data));
}
} else {
navigate(LOG_IN_PATH);
}
setIsLoading(false);
} catch (error) {
console.log(error);
alert('We failed to get information of posts.');
}
};
fetchData();
return () => {
isMounted = false;
};
}, []);
if (isLoading) {
return <MainLoader />;
}
if (!posts.length) {
return <NoPostsMessage />;
}
const renderPostItems = posts?.map((i) => {
return (
<PostItem
key={i.id}
id={i.id}
files={i.files}
author={i.author}
caption={i.caption}
createdAt={i.createdAt}
totalLikes={i.totalLikes}
comments={i.comments}
/>
);
});
return (
<>
<div data-testid="posts-list">{renderPostItems}</div>
</>
);
};
export const PostItem: React.FC<PostItemProps> = ({
id,
author,
caption,
createdAt,
files,
totalLikes,
comments
}) => {
return (
<article className="mb-3 mx-auto pb-5 border-b border-solid border-[#262626] flex flex-col w-full gap-[6px] md:w-[430px]">
<div className="flex gap-4 items-center">
<Thumbnail
src={ThumbnailSrc}
onClick={handleClickAuthor}
className="cursor-pointer hover:opacity-70"
alt={`Profile of ${authorName}`}
/>
<div className="flex">
<p>{authorName}</p>
{'・'}
<p>{formattedDate} ago</p>
</div>
</div>
{mediaUrl && (
<img
className="rounded w-[400px] md:w-[430px] h-[768px] cursor-pointer mx-auto"
alt={primaryFile.alt || primaryFile.fileName}
src={mediaUrl}
onClick={handleOpenPostDetail}
/>
)}
<div className="flex flex-col gap-2">
<div className="flex gap-2 items-center">
{isAuthor ? (
<></>
) : (
<Button
type="button"
onClick={handleClickLike}
isLoading={isLoading}
className="cursor-pointer hover:opacity-70"
aria-label={hasLiked ? 'liked' : 'like'}
>
{hasLiked ? <LeafFill /> : <LeafNoFillBlack />}
</Button>
)}
<Button
type="button"
className="cursor-pointer hover:opacity-70"
onClick={handleOpenPostDetail}
>
<ChatBubbleOvalLeftIcon className="h-6 w-6" />
</Button>
</div>
{totalLikes ? (
<p>
{totalLikes} {pluralize(totalLikes, 'like', 'likes')}
</p>
) : (
<></>
)}
<p>{caption}</p>
{comments?.length ? (
<Button
type="button"
testId="view comments"
onClick={handleOpenPostDetail}
>
View {comments.length} {pluralize(comments.length, 'comment')}
</Button>
) : (
<></>
)}
<form className="flex gap-4" onSubmit={handleSubmitComment}>
<TextArea
name="comment"
placeholder="Add a comment"
aria-label="Add a comment"
className="max-h-10 p-1 text-sm"
value={comment}
onChange={(e) => setComment(e.target.value)}
/>
<Button
type="submit"
testId="submit button"
isLoading={isLoading}
disabled={!comment}
>
<PaperAirplaneIcon className="h-6 w-6 text-black -rotate-45 hover:opacity-70" />
</Button>
</form>
</div>
</article>
);
};
const NoPostsMessage: React.FC = () => {
const dispatch = useAppDispatch();
return (
<div className="flex justify-center items-center h-screen">
<div className="text-center">
<h3 className="text-3xl font-bold mb-4">No posts yet</h3>
<p className="text-lg mb-6">Create a new post to get started.</p>
<Button
type="button"
isPrimary
onClick={() =>
dispatch(showModal({ modalType: ModalType.CreatePost }))
}
>
Create Post
</Button>
</div>
</div>
);
};
MainLoader component
export const MainLoader = () => {
return (
<div className="w-full h-full flex items-center" data-testid="main loader">
<PropagateLoader
size={30}
cssOverride={override}
aria-label="Loading Spinner"
/>
</div>
);
};
What I tried:
- Added timeout to asynchronously retrieved elements and each test ==> still see the same error
- Stored env variable used in api call locally ==> I'm storing env variable in Doppler and don't use env file. I thought it could be the problem but it seems not.
- Change package versions ==> Changed the versions of axios and msw as interception error due to the compatibility has been reported. But I still see the same error.
- Investigate
renderWithProvider
==> This is a custom render function that wraps passed components with provider. There seems no problem with the function meaning most probably this problem has to do with interception of api request. - Check other API call ==> Other tests that involves API call are also having the same issue.
Side notes
- App is running properly.
renderWithProvider()
is a function that wraps component passed as an argument with redux provider. It's working properly in other tests.