DEV Community

mihomihouk
mihomihouk

Posted on

“Timed out” error in React/Redux app test: This is how I found my solution

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:

  1. First, loading spinner
  2. 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();
  });
});
Enter fullscreen mode Exit fullscreen mode

The overview of the testing steps is:

1.Render the component

 renderWithProviders(<PostList />);
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

3.Ensure post

const postEl = await waitFor(() => screen.findByText('test'));
 expect(postEl).toBeInTheDocument()
Enter fullscreen mode Exit fullscreen mode

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"
};
Enter fullscreen mode Exit fullscreen mode
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 } })
    );
  })
];
Enter fullscreen mode Exit fullscreen mode

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)

Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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();
  });
});
Enter fullscreen mode Exit fullscreen mode

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();

    });

});
Enter fullscreen mode Exit fullscreen mode

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;
    };
  }, []);
Enter fullscreen mode Exit fullscreen mode

This is because in my app:

  1. User logins and user information is immediately stored in Redux
  2. 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
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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]}));
  }),
Enter fullscreen mode Exit fullscreen mode

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]));
 })
Enter fullscreen mode Exit fullscreen mode

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)