brff19
brff19

Reputation: 834

Testing Asynchronous Code (useEffect + fetch) In React Components

I'm trying to figure out how to test components that update state using useEffect to make an API call to get data. There are several things I think are important to know before I can talk anymore, and that is the files/packages I'm using.

First, I have a main component called App.tsx, inside App.tsx, inside of useEffect, I make a fetch call to an external API to fetch an array of songs by Queen. I also render out a <Song /> component using .map to iterate over each song and .filter to filter songs on UI based on text input. I'm using a custom hook. Here is the code I have for that component and its custom hook.

// App.tsx

type ISong = {
  id: number;
  title: string;
  lyrics: string;
  album: string;
};

export default function App() {
  const { songs, songError } = useSongs();
  const { formData, handleFilterSongs } = useForm();

  return (
    <Paper>
      <h1>Queen Songs</h1>
      <FilterSongs handleFilterSongs={handleFilterSongs} />
      <section>
        {songError ? (
          <p>Error loading songs...</p>
        ) : !songs ? (
          <>
            <p data-testid="loadingText">Loading...</p>
            <Loader />
          </>
        ) : (
          <Grid container>
            {songs
              .filter(
                (song: ISong) =>
                  song.title
                    .toLowerCase()
                    .includes(formData.filter.toLowerCase()) ||
                  song.album
                    .toLowerCase()
                    .includes(formData.filter.toLowerCase()) ||
                  song.lyrics
                    .toLowerCase()
                    .split(" ")
                    .join(" ")
                    .includes(formData.filter.toLowerCase())
              )
              .map((song: ISong) => (
                <Grid key={song.id} item>
                  <Song song={song} />
                </Grid>
              ))}
          </Grid>
        )}
      </section>
    </Paper>
  );
}

// useSongs.tsx

type ISongs = {
    id: number;
    title: string;
    lyrics: string;
    album: string;
  }[];
  
  type IError = {
    message: string;
  };

export default function useSongs() {
    const [songs, setSongs] = useState<ISongs | null>(null);
    const [songError, setSongError] = useState<IError | null>(null);

    useEffect(() => {
      fetch("https://queen-songs.herokuapp.com/songs")
        .then(res => res.json())
        .then(songs => setSongs(songs))
        .catch(err => setSongError(err));
      }, []);
    
      return {songs, songError}
}

Next up is my App.test.tsx file. I am using react-testing-library and jest-dom/extend-expect for my testing coverage. Here is my testing file code. I've been watching a youtube tutorial on the matter and I've read a bunch of articles, but I still can't figure this out.

// App.test.tsx

import * as rctl from "@testing-library/react";
import "@testing-library/jest-dom/extend-expect";
import App from "./App";

// @ts-ignore
global.fetch = jest.fn(() =>
  Promise.resolve({
    json: () =>
      Promise.resolve({
        value: [{title: "title1", album: "album1", lyrics: "asdf", id: 1}, {title: "title2", album: "album2", lyrics: "zxcv", id: 2}, etc...],
      }),
  })
);

describe.only("The App component should", () => {
  it("load songs from an API call after initial render", async () => {
    await rctl.act(async () => {
      await rctl.render(<App />).debug();
      rctl.screen.debug();
    });
  });
});

This code gives me the following error message


 FAIL  src/pages/App/App.test.tsx
  App
    × loads the songs on render (117 ms)

  ● App › loads the songs on render

    TypeError: Cannot read property 'then' of undefined

      17 |
      18 |     useEffect(() => {
    > 19 |       fetch("https://queen-songs.herokuapp.com/songs")
         |       ^
      20 |         .then(res => res.json())
      21 |         .then(songs => {
      22 |           setSongs(songs)

      at src/pages/App/useSongs.ts:19:7
      at invokePassiveEffectCreate (node_modules/react-dom/cjs/react-dom.development.js:23487:20)
      at HTMLUnknownElement.callCallback (node_modules/react-dom/cjs/react-dom.development.js:3945:14)     
      at HTMLUnknownElement.callTheUserObjectsOperation (node_modules/jsdom/lib/jsdom/living/generated/EventListener.js:26:30)
      at innerInvokeEventListeners (node_modules/jsdom/lib/jsdom/living/events/EventTarget-impl.js:338:25) 
      at invokeEventListeners (node_modules/jsdom/lib/jsdom/living/events/EventTarget-impl.js:274:3)       
      at HTMLUnknownElementImpl._dispatch (node_modules/jsdom/lib/jsdom/living/events/EventTarget-impl.js:221:9)
      at HTMLUnknownElementImpl.dispatchEvent (node_modules/jsdom/lib/jsdom/living/events/EventTarget-impl.js:94:17)
      at HTMLUnknownElement.dispatchEvent (node_modules/jsdom/lib/jsdom/living/generated/EventTarget.js:231:34)
      at Object.invokeGuardedCallbackDev (node_modules/react-dom/cjs/react-dom.development.js:3994:16)     
      at invokeGuardedCallback (node_modules/react-dom/cjs/react-dom.development.js:4056:31)
      at flushPassiveEffectsImpl (node_modules/react-dom/cjs/react-dom.development.js:23574:9)
      at unstable_runWithPriority (node_modules/scheduler/cjs/scheduler.development.js:468:12)
      at runWithPriority$1 (node_modules/react-dom/cjs/react-dom.development.js:11276:10)
      at flushPassiveEffects (node_modules/react-dom/cjs/react-dom.development.js:23447:14)
      at Object.<anonymous>.flushWork (node_modules/react-dom/cjs/react-dom-test-utils.development.js:992:10)
      at flushWorkAndMicroTasks (node_modules/react-dom/cjs/react-dom-test-utils.development.js:1001:5)    
      at node_modules/react-dom/cjs/react-dom-test-utils.development.js:1080:11

  console.log
    <body>
      <div>
        <div
          class="MuiPaper-root MuiPaper-elevation1 MuiPaper-rounded"
          style="text-align: center; overflow: hidden; min-height: 100vh;"
        >
          <h1>
            Queen Songs
          </h1>
          <div
            style="display: flex; flex-flow: column; justify-content: center; text-align: center;"
          >
            <input
              data-testid="input"
              id="filter"
              name="filter"
              placeholder="Search by title, album name, or lyrics here..."
              style="width: 18.75rem; height: 1.875rem; align-self: center; text-align: center; font-style: italic;"
              type="text"
            />
          </div>
          <section
            style="display: flex; flex-flow: row wrap; justify-content: center; align-items: center;"      
          >
            <p
              data-testid="loadingText"
            >
              Loading...
            </p>
            <div
              class="line-container"
            >
              <div
                class="line"
                data-testid="loader-line"
              />
            </div>
          </section>
        </div>
      </div>
    </body>

      at Object.debug (node_modules/@testing-library/react/dist/pure.js:107:13)

  console.log
    <body>
      <div>
        <div
          class="MuiPaper-root MuiPaper-elevation1 MuiPaper-rounded"
          style="text-align: center; overflow: hidden; min-height: 100vh;"
        >
          <h1>
            Queen Songs
          </h1>
          <div
            style="display: flex; flex-flow: column; justify-content: center; text-align: center;"
          >
            <input
              data-testid="input"
              id="filter"
              name="filter"
              placeholder="Search by title, album name, or lyrics here..."
              style="width: 18.75rem; height: 1.875rem; align-self: center; text-align: center; font-style: italic;"
              type="text"
            />
          </div>
          <section
            style="display: flex; flex-flow: row wrap; justify-content: center; align-items: center;"      
          >
            <p
              data-testid="loadingText"
            >
              Loading...
            </p>
            <div
              class="line-container"
            >
              <div
                class="line"
                data-testid="loader-line"
              />
            </div>
          </section>
        </div>
      </div>
    </body>

      at logDOM (node_modules/@testing-library/dom/dist/pretty-dom.js:82:13)

  console.error
    Error: Uncaught [TypeError: Cannot read property 'then' of undefined]
        at reportException (C:\Users\brian\Code\cra-queen-api-fe\node_modules\jsdom\lib\jsdom\living\helpers\runtime-script-errors.js:62:24)
        at innerInvokeEventListeners (C:\Users\brian\Code\cra-queen-api-fe\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:341:9)
        at invokeEventListeners (C:\Users\brian\Code\cra-queen-api-fe\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:274:3)
        at HTMLUnknownElementImpl._dispatch (C:\Users\brian\Code\cra-queen-api-fe\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:221:9)
        at HTMLUnknownElementImpl.dispatchEvent (C:\Users\brian\Code\cra-queen-api-fe\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:94:17)
        at HTMLUnknownElement.dispatchEvent (C:\Users\brian\Code\cra-queen-api-fe\node_modules\jsdom\lib\jsdom\living\generated\EventTarget.js:231:34)
        at Object.invokeGuardedCallbackDev (C:\Users\brian\Code\cra-queen-api-fe\node_modules\react-dom\cjs\react-dom.development.js:3994:16)
        at invokeGuardedCallback (C:\Users\brian\Code\cra-queen-api-fe\node_modules\react-dom\cjs\react-dom.development.js:4056:31)
        at flushPassiveEffectsImpl (C:\Users\brian\Code\cra-queen-api-fe\node_modules\react-dom\cjs\react-dom.development.js:23574:9)
        at unstable_runWithPriority (C:\Users\brian\Code\cra-queen-api-fe\node_modules\scheduler\cjs\scheduler.development.js:468:12) TypeError: Cannot read property 'then' of undefined
        at C:\Users\brian\Code\cra-queen-api-fe\src\pages\App\useSongs.ts:19:7
        at invokePassiveEffectCreate (C:\Users\brian\Code\cra-queen-api-fe\node_modules\react-dom\cjs\react-dom.development.js:23487:20)
        at HTMLUnknownElement.callCallback (C:\Users\brian\Code\cra-queen-api-fe\node_modules\react-dom\cjs\react-dom.development.js:3945:14)
        at HTMLUnknownElement.callTheUserObjectsOperation (C:\Users\brian\Code\cra-queen-api-fe\node_modules\jsdom\lib\jsdom\living\generated\EventListener.js:26:30)
        at innerInvokeEventListeners (C:\Users\brian\Code\cra-queen-api-fe\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:338:25)
        at invokeEventListeners (C:\Users\brian\Code\cra-queen-api-fe\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:274:3)
        at HTMLUnknownElementImpl._dispatch (C:\Users\brian\Code\cra-queen-api-fe\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:221:9)
        at HTMLUnknownElementImpl.dispatchEvent (C:\Users\brian\Code\cra-queen-api-fe\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:94:17)
        at HTMLUnknownElement.dispatchEvent (C:\Users\brian\Code\cra-queen-api-fe\node_modules\jsdom\lib\jsdom\living\generated\EventTarget.js:231:34)
        at Object.invokeGuardedCallbackDev (C:\Users\brian\Code\cra-queen-api-fe\node_modules\react-dom\cjs\react-dom.development.js:3994:16)
        at invokeGuardedCallback (C:\Users\brian\Code\cra-queen-api-fe\node_modules\react-dom\cjs\react-dom.development.js:4056:31)
        at flushPassiveEffectsImpl (C:\Users\brian\Code\cra-queen-api-fe\node_modules\react-dom\cjs\react-dom.development.js:23574:9)
        at unstable_runWithPriority (C:\Users\brian\Code\cra-queen-api-fe\node_modules\scheduler\cjs\scheduler.development.js:468:12)
        at runWithPriority$1 (C:\Users\brian\Code\cra-queen-api-fe\node_modules\react-dom\cjs\react-dom.development.js:11276:10)
        at flushPassiveEffects (C:\Users\brian\Code\cra-queen-api-fe\node_modules\react-dom\cjs\react-dom.development.js:23447:14)
        at Object.<anonymous>.flushWork (C:\Users\brian\Code\cra-queen-api-fe\node_modules\react-dom\cjs\react-dom-test-utils.development.js:992:10)
        at flushWorkAndMicroTasks (C:\Users\brian\Code\cra-queen-api-fe\node_modules\react-dom\cjs\react-dom-test-utils.development.js:1001:5)
        at C:\Users\brian\Code\cra-queen-api-fe\node_modules\react-dom\cjs\react-dom-test-utils.development.js:1080:11
        at processTicksAndRejections (node:internal/process/task_queues:94:5)

      at VirtualConsole.<anonymous> (node_modules/jsdom/lib/jsdom/virtual-console.js:29:45)
      at reportException (node_modules/jsdom/lib/jsdom/living/helpers/runtime-script-errors.js:66:28)      
      at innerInvokeEventListeners (node_modules/jsdom/lib/jsdom/living/events/EventTarget-impl.js:341:9)  
      at invokeEventListeners (node_modules/jsdom/lib/jsdom/living/events/EventTarget-impl.js:274:3)       
      at HTMLUnknownElementImpl._dispatch (node_modules/jsdom/lib/jsdom/living/events/EventTarget-impl.js:221:9)
      at HTMLUnknownElementImpl.dispatchEvent (node_modules/jsdom/lib/jsdom/living/events/EventTarget-impl.js:94:17)

  console.error
    The above error occurred in the <App> component:
    
        at App (C:\Users\brian\Code\cra-queen-api-fe\src\pages\App\App.tsx:18:32)
    
    Consider adding an error boundary to your tree to customize error handling behavior.
    Visit https://reactjs.org/link/error-boundaries to learn more about error boundaries.

      at logCapturedError (node_modules/react-dom/cjs/react-dom.development.js:20085:23)
      at update.callback (node_modules/react-dom/cjs/react-dom.development.js:20118:5)
      at callCallback (node_modules/react-dom/cjs/react-dom.development.js:12318:12)
      at commitUpdateQueue (node_modules/react-dom/cjs/react-dom.development.js:12339:9)
      at commitLifeCycles (node_modules/react-dom/cjs/react-dom.development.js:20736:11)
      at commitLayoutEffects (node_modules/react-dom/cjs/react-dom.development.js:23426:7)
      at HTMLUnknownElement.callCallback (node_modules/react-dom/cjs/react-dom.development.js:3945:14)     

Test Suites: 1 failed, 1 total
Tests:       1 failed, 1 total
Snapshots:   0 total
Time:        2.59 s, estimated 3 s
Ran all test suites related to changed files.

I honestly am completely lost here, and I have no idea what to do next. My usual problem-solving skills are not helping, so I figured I'd turn to SO for some help. Thank you for reading through all of this and for any help you may be able to provide.

Edit: I stripped the code of most of the CSS in the snippets to make it slightly more readable, so that is why the screen.debug() log includes some CSS and the code doesn't.

Edit: I changed the useEffect method to use async/await and now my tests work, but I still have the same output as before. Here is the updated useEffect and the code output.

// Updated useSongs.tsx

export default function useSongs() {
    const [songs, setSongs] = useState<ISongs | null>(null);
    const [songError, setSongError] = useState<IError | null>(null);

    useEffect(() => {
      (async() => {
        try {
          const fetchSongs = await fetch("https://queen-songs.herokuapp.com/songs");
          const data = await fetchSongs.json();
          setSongs(data);
        } catch (error) {
          setSongError(error);
        }
      })()
    }, []);
    
      return {songs, songError}
}

 // Updated testOutput

 PASS  src/pages/App/App.test.tsx
  App
    √ loads the songs on render (52 ms)

  console.log
    <body>
      <div>
        <div
          class="MuiPaper-root MuiPaper-elevation1 MuiPaper-rounded"
          style="text-align: center; overflow: hidden; min-height: 100vh;"
        >
          <h1>
            Queen Songs
          </h1>
          <section
            style="display: flex; flex-flow: row wrap; justify-content: center; align-items: center;"       
          >
            <p
              data-testid="loadingText"
            >
              Loading...
            </p>
            <div
              class="line-container"
            >
              <div
                class="line"
                data-testid="loader-line"
              />
            </div>
          </section>
        </div>
      </div>
    </body>

      at logDOM (node_modules/@testing-library/dom/dist/pretty-dom.js:82:13)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        0.878 s, estimated 1 s
Ran all test suites related to changed files.

I want the test to show the HTML after the useEffect has run and the state has updated, the loading text should be gone.

Upvotes: 0

Views: 1671

Answers (2)

brff19
brff19

Reputation: 834

I also finally figured out how to test this code asynchronously with the data loaded.

The secret is to first place data-testid="identifier" on the node that you want your test to wait for, and then in your test file run await waitFor(() => findAllByTestId("grid"));. The final test looks like such for my code.


describe("App", () => {
  it("renders without crashing", async () => {
    const { getByText, findAllByTestId, container } = render(<App />);
    await waitFor(() => findAllByTestId("grid"));
    expect(getByText("The Night Comes Down")).toBeInTheDocument();
  });
});

With the screen.debug showing all the data in the grid list where <Song> component lives like such:


<body>
        <div>
          <div
            class="MuiPaper-root MuiPaper-elevation5 MuiPaper-rounded"
            style="text-align: center; overflow: hidden; min-height: 100vh;"
          >
            <h1>
              Queen Songs
            </h1>
            <div
              style="display: flex; flex-flow: column; justify-content: center; text-align: center;"
            >
              <input
                data-testid="input"
                id="filter"
                name="filter"
                placeholder="Search by title, album name, or lyrics here..."
                style="width: 18.75rem; height: 1.875rem; align-self: center; text-align: center; font-style: italic;"
                type="text"
              />
            </div>
            <section
              style="display: flex; flex-flow: row wrap; justify-content: center; align-items: center;"
            >
              <div
                class="MuiGrid-root MuiGrid-container MuiGrid-spacing-xs-3 MuiGrid-align-items-xs-baseline MuiGrid-justify-xs-center"
                data-testid="grid"
                style="margin: 1.25rem;"
              >
                <section>
                  <div
                    class="MuiGrid-root MuiGrid-item"
                    data-testid="grid"
                    style=""
                  >
                    <div
                      class="MuiPaper-root MuiCard-root makeStyles-root-1 MuiPaper-elevation24 MuiPaper-rounded"
                    >
                      <a
                        aria-disabled="false"
                        class="MuiButtonBase-root MuiCardActionArea-root"
                        href="http://localhost/songs/1"
                        tabindex="0"
                      >
                        <img
                          alt="queen album cover"
                          class="MuiCardMedia-root MuiCardMedia-media MuiCardMedia-img"
                          src="/queen3.jpg"
                        />
                        <div
                          class="MuiCardContent-root"
                        >
                          <h2
                            class="MuiTypography-root MuiTypography-h5 MuiTypography-gutterBottom"
                            style="text-decoration: underline;"
                          >
                            Bohemian Rhapsody
                          </h2>
                          <p
                            class="MuiTypography-root MuiTypography-body2 MuiTypography-colorTextSecondary"
                            data-testid="lyrics"
                          />
                        </div>
                        <span
                          class="MuiCardActionArea-focusHighlight"
                        />
                      </a>
                      <div
                        class="MuiCardActions-root MuiCardActions-spacing"
                      >
                        <button
                          class="MuiButtonBase-root MuiButton-root MuiButton-contained brand-button MuiButton-containedPrimary MuiButton-disableElevation"
                          data-testid="button"
                          tabindex="0"
                          type="button"
                        >
                          <span
                            class="MuiButton-label"
                          >
                            Show More Lyrics
                          </span>
                          <span
                            class="MuiTouchRipple-root"
                          />
                        </button>
                      </div>
                    </div>
                  </div>
                </section>
                <section>
                  <div
                    class="MuiGrid-root MuiGrid-item"
                    data-testid="grid"
                    style=""
                  >
                    <div
                      class="MuiPaper-root MuiCard-root makeStyles-root-1 MuiPaper-elevation24 MuiPaper-rounded"
                    >
                      <a
                        aria-disabled="false"
                        class="MuiButtonBase-root MuiCardActionArea-root"
                        href="http://localhost/songs/2"
                        tabindex="0"
                      >
                        <img
                          alt="queen album cover"
                          class="MuiCardMedia-root MuiCardMedia-media MuiCardMedia-img"
                          src="/queen.webp"
                        />
                        <div
                          class="MuiCardContent-root"
                        >
                          <h2
                            class="MuiTypography-root MuiTypography-h5 MuiTypography-gutterBottom"
                            style="text-decoration: underline;"
                          >
                            Fat Bottomed Girls
                          </h2>
                          <p
                            class="MuiTypography-root MuiTypography-body2 MuiTypography-colorTextSecondary"
                            data-testid="lyrics"
                          />
                        </div>
                        <span
                          class="MuiCardActionArea-focusHighlight"
                        />
                      </a>
                      <div
                        class="MuiCardActions-root MuiCardActions-spacing"
                      >
                        <button
                          class="MuiButtonBase-root MuiButton-root MuiButton-contained brand-button MuiButton-containedPrimary MuiButton-disableElevation"
                          data-testid="button"
                          tabindex="0"
                          type="button"
                        >
                          <s...

      at logDOM (node_modules/@testing-library/dom/dist/pretty-dom.js:82:13)


Test Suites: 4 passed, 4 total
Tests:       4 passed, 4 total
Snapshots:   0 total
Time:        3.768 s
Ran all test suites related to changed files.

And the updated component looks like this...


type ISong = {
  id: number;
  title: string;
  lyrics: string;
  album: string;
};

export default function App() {
  const { songs, songError } = useSongs();
  const { formData, handleFilterSongs } = useForm();

  return (
    <Paper
      elevation={5}
      style={{ textAlign: "center", overflow: "hidden", minHeight: "100vh" }}
    >
      <h1>Queen Songs</h1>
      {songs ? <FilterSongs handleFilterSongs={handleFilterSongs} /> : null}
      <section
        style={{
          display: "flex",
          flexFlow: "row wrap",
          justifyContent: "center",
          alignItems: "center",
        }}
      >
        {songError.message.length ? (
          <p>Error loading songs...</p>
        ) : !songs ? (
          <>
            <p data-testid="loadingText">Loading...</p>
            <Loader />
          </>
        ) : (
          <Grid
            style={{ margin: "1.25rem" }}
            container
            spacing={3}
            justify="center"
            alignItems="baseline"
            data-testid="grid"
          >
            {songs
              .filter(
                (song: ISong) =>
                  song.title
                    .toLowerCase()
                    .includes(formData.filter.toLowerCase()) ||
                  song.album
                    .toLowerCase()
                    .includes(formData.filter.toLowerCase()) ||
                  song.lyrics
                    .toLowerCase()
                    .split(" ")
                    .join(" ")
                    .includes(formData.filter.toLowerCase())
              )
              .map((song: ISong) => (
                <section key={song.id}>
                  <Suspense fallback={<Loader />}>
                    <Grid data-testid="grid" item>
                      <Song song={song} />
                    </Grid>
                  </Suspense>
                </section>
              ))}
          </Grid>
        )}
      </section>
    </Paper>
  );
}

Upvotes: 0

brff19
brff19

Reputation: 834

I was able to get a passing test with the updated DOM by changing the initial value from null to a blank Array.

I also changed the resulting testing code to the following.


describe.only("App", () => {
  it("loads the songs on render", async () => {
    let container: any;
    await rctl.act(async () => {
      container = rctl.render(<App />);
      await rctl.waitFor(async () => {
        await waitFor(async () => {
          expect(await container.findByTestId("grid")).toBeInTheDocument();
        });
      });
    });
  });
});

This still doesn't display all of the values as the real DOM does, but it does show the Grid component in which the data-testid="grid" lives at which is evidence enough that the code is being accessed successfully prior to being updated at least. I'm still hoping someone can figure out how to test the code with the updated state values (despite the state not updating after setting it to the API data) with setSongs.

Upvotes: 1

Related Questions