Introduction
Testing is an integral part of software development, ensuring that our applications function as intended and deliver a reliable user experience. But testing React applications, especially when dealing with asynchronous operations and state changes, can be a bit tricky. In this article, we'll dive into a challenge I recently encountered during React testing: handling "Timed out" errors when waiting for asynchronous elements to appear.
What I wanted to test
I'm developing a React app (created with create-react-app) with Redux. I was trying to test one of the components using react-testing-library and Jest to ensure it renders the two elements below on visiting the dashboard:
- First, loading spinner
- Second, fetched list of posts
My Initial test code
Here is the test I wrote.
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();
});
});
The overview of the testing steps is:
1.Render the component
renderWithProviders(<PostList />);
This renderWithProviders
is a reusable custom render function that wraps components with Redux providers. Please visit this link if you want to learn more about it.
2.Ensure loading spinner appears
const loadingEl = await waitFor(() => screen.findByTestId('main loader'));
expect(loadingEl).toBeInTheDocument();
I set a testId for the loading spinner, which appears immediately after asynchronous operation starts like so:
const override: CSSProperties = {
display: "block",
margin: "0 auto",
borderColor: "red"
};
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>
);
};
3.Ensure post
const postEl = await waitFor(() => screen.findByText('test'));
expect(postEl).toBeInTheDocument()
I use Mock service worker (msw) to intercept API call. This “test” is one value of mocked data. The mocked data returned instead of actual data via a handler when GET request is called.
export 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"
};
import { rest } from "msw";
import config from "../config";
import { mockPostItem1 } from "./posts";
import { mockUser } from "./user";
const baseURL = config.apiUrl;
export const handlers = [
rest.get(`${baseURL}/auth/${mockUser.id}`, (req, res, ctx) => {
return res(
ctx.status(200),
ctx.json({ data: { user: mockUser, posts: mockPostItem1 } })
);
})
];
So if the fetching is successful, the text “test” should appear in the DOM.
Error Message
When I ran the test, the elements (spinner and fetched data) never appeared. My console showed this error:
● 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)
What does it mean!?
The "Timed out in waitFor" error message typically occurs when using the waitFor
function. It indicates that the specified condition being waited for did not occur within the designated timeout period (in this case default timeout of 1,000 milliseconds).
And as you can see below, the DOM was showing the default UI that I prepared for the case when there’s no post list instead of a mocked post list:
<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>
So what basically the error tells us is that the expected data is not appearing within 1 second.
Narrowing scope
So I could know somehow the expected data is not rendered in the DOM within the timeout.
Yes, this is a very vague error...
So to start, I asked myself several questions:
- Is state change handled properly in this specific test?
- Is simply asynchronous transaction taking too much time?
- Is api URL in my handler set correctly?
- Are the versions of packages used in the test compatible to each other?
In answering the questions above, I then tried to:
1.Add timeout to asynchronous operations
One possibility of the cause of the error was the asynchronous operation was simply taking long time. If that’s the case, the error could be solved by extending timeout like so:
describe('PostList', () => {
it('should render a post list data after loading', async () => {
renderWithProviders(<PostList />);
const loadingEl = await waitFor(() => screen.findByTestId('main loader'), {timeout: 3000}); // Set a specific time here
expect(loadingEl).toBeInTheDocument();
const postEl = await waitFor(() => screen.findByText('test'));
expect(postEl).toBeInTheDocument();
});
});
In theory, by adding timeout, the test now waits a bit longer until the loading spinner appears.
2.Store env variable used in API call locally
Another possibility I thought of was API URL. In my app, I'm storing all env variables including API URL in Doppler and I was running test codes without using any env file. I thought somehow the env variables are not accessible when running tests, and thus test might have failed.
3.Change package versions
I also changed the versions of axios
and msw
as I’d seen some reports on interception errors due to the compatibility between the two packages.
4.Investigate renderWithProvider
Moreover, I revisited my code that handles state change in testing mode making sure there is no typo or misconfiguration.
5.Check other API call
Just to double check this is not the problem of state handling, I wrote another test for different components that also uses API call. It actually threw the same error.
After all these attempts, I still could not see any progress in my test!
The problem could be my set up of Redux, mws, or my testing code…
Yes…
I completely ran out of ideas!
Reaching out to communities
I’ve tried my best and still had no luck.
So this is the time to ask others for help!
1.Stack Overflow
I’ve first reached out to stack overflow. I prepared a document where I explained what I want to achieve, the error I’ve seen and what I tried to solve it.
Unfortunately I didn’t get any response from other members.
But this process actually helped me come up with other possibilities and organise my understanding about the problem.
2.Testing channel in Reactiflux Discord community
Out of desperation, I asked for help in a Discord community dedicated to React. I’d never posted anything in this community before but I’ve seen people helping each other very actively!
Surprisingly, a very kind person responded my post in a matter of one hour after I posted my issue, diving into my code, giving me really great tips!
And yes! With his insights, I could finally find solutions, which I’d like to share in the next section.
Culprit
So what was the problem?
First, let’s take a look at my final working test code.
describe("PostList", () => {
it("should render a post list data after loading", async () => {
const store = setupStore();
store.dispatch(setUser(mockUser));
store.dispatch(setIsAuthenticated(true));
renderWithProviders(<PostList />, {store});
await waitForElementToBeRemoved(() => screen.queryByTestId("main loader"));
const postEl = await screen.findByText("test");
expect(postEl).toBeInTheDocument();
});
});
As you may notice, I added a number of changes to this test.
1.Redux state change
One of the main issue was that the function that runs API call never ran. It runs only when there is a user but in the test, the user state was undefined.
React.useEffect(() => {
let isMounted = true;
const fetchData = async () => {
try {
setIsLoading(true);
if (user) {. //If user is undefined, the code below never runs!!!
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;
};
}, []);
This is because in my app:
- User logins and user information is immediately stored in Redux
- Using the user information, a list of posts including user’s posts is fetched
So the solution was easy. I just needed to ensure user state gets updated with mockedUser BEFORE running the test (= a list of posts gets fetched).
const store = setupStore();
store.dispatch(setUser(mockUser)); // Update user
store.dispatch(setIsAuthenticated(true)); // Update auth state
renderWithProviders(<PostList />, {store}); // Pass the state to redux provider
2.First wait until loading indicator to be REMOVED
In web applications, it’s a very common practice to render loading UI while fetching data. When testing asynchronous operations, we should ensure loading state is completely removed before searching for the elements we expected to be in the DOM.
Before:
const loadingEl = await waitFor(() => screen.findByTestId('main loader'));
expect(loadingEl).toBeInTheDocument();
const postEl = await waitFor(() => screen.findByText('test'));
expect(postEl).toBeInTheDocument();
In my previous code, I used findByTestId
to handle loading spinner. This might lead to an error as findByTestId
returns a promise that resolves when it finds matching id but there is no guarantee that an element with the id is removed from the DOM before the fetched post list appears.
After:
await waitForElementToBeRemoved(() => screen.queryByTestId("main loader"));
const postEl = await screen.findByText("test");
expect(postEl).toBeInTheDocument();
On the other hand, waitForElementToBeRemoved
explicitly waits for an element to be removed from the DOM. So waitForElementToBeRemoved
is more appropriate to test a loading UI and the final fetched data.
waitFor or findBy?
You may notice I also changed my code to use only findByTestId
and findByText
instead of using them with waitFor
to test asynchronous operations.
While it seems not a bad practice to use waitFor
in this scenario, findBy
itself just does the job and it improves the code readability.
3.Returned json object
After finding the two solutions, I could see the loading spinner rendered in the DOM.
It was progress.
But I still failed to see the fetched post list and here is the final mistake that I found in my test code:
rest.get(${baseURL}/post/get-posts, (req, res, ctx) => {
return res(ctx.json({data: [mockPostItem1, mockPostItem2]}));
}),
This is mocked API call I set up using msw.
Note that the object inside ctx.json( )
assumes fetched data belongs data property, which is slightly different from data structure dealt in my client code.
So I corrected the code like so:
rest.get(${baseURL}/post/get-posts, (req, res, ctx) => {
return res(ctx.json([mockPostItem1, mockPostItem2]));
})
After implementing the necessary changes and addressing the issues I encountered, I was happy to see my test finally pass without any errors.
I hope that by sharing my experience of trial and error and the solutions I found, this article can help others who might encounter similar challenges in testing react apps.
Top comments (0)