开发者问题收集

在 React 组件中测试异步代码(useEffect + fetch)

2021-04-08
1499

我正在尝试弄清楚如何使用 useEffect 进行 API 调用来获取数据,从而测试更新状态的组件。在进一步讨论之前,我认为有几件事很重要,那就是我正在使用的文件/包。

首先,我有一个名为 App.tsx 的主要组件,在 App.tsx 内部,在 useEffect 内部,我对外部 API 进行获取调用以获取 Queen 的歌曲数组。我还使用 .map 渲染出一个 <Song /> 组件来遍历每首歌曲,并使用 .filter 根据文本输入在 UI 上过滤歌曲。我正在使用自定义钩子。这是我为该组件及其自定义钩子编写的代码。

// 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}
}

接下来是我的 App.test.tsx 文件。我使用 react-testing-libraryjest-dom/extend-expect 进行测试覆盖。这是我的测试文件代码。我一直在观看有关此事的 YouTube 教程,也阅读了大量文章,但我仍然无法弄清楚。

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

此代码给我以下错误消息

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.

老实说,我完全迷失了,我不知道下一步该怎么做。我通常的解决问题的能力没有帮助,所以我想我会向 SO 寻求帮助。感谢您阅读所有这些内容并感谢您提供的任何帮助。

编辑:我删除了代码片段中的大部分 CSS 代码,以使其更具可读性,这就是为什么 screen.debug() 日志包含一些 CSS 而代码不包含的原因。

编辑:我更改了 useEffect 方法以使用 async/await,现在我的测试可以正常工作,但输出仍然与以前相同。这是更新后的 useEffect 和代码输出。

// 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.

我希望测试显示 HTML useEffect 运行并且状态已更新之后,加载文本应该会消失。

2个回答

通过将初始值从 null 更改为空白数组,我能够使用更新后的 DOM 获得通过测试。

我还将生成的测试代码更改为以下内容。

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

这仍然不会像真正的 DOM 那样显示所有值,但它确实显示了 data-testid="grid" 所在的 Grid 组件,这足以证明代码至少在更新之前已被成功访问。我仍然希望有人能弄清楚如何使用 setSongs 测试具有更新状态值的代码(尽管在将其设置为 API 数据后状态没有更新)。

brff19
2021-04-08

我最终也弄清楚了如何在加载数据的情况下异步测试此代码。

秘诀是首先将 data-testid="identifier" 放置在您希望测试等待的节点上,然后在测试文件中运行 await waitFor(() => findAllByTestId("grid")); 。对于我的代码,最终测试如下所示。

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

使用 screen.debug 显示网格列表中的所有数据,其中 <Song> 组件如下所示:

<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.

更新后的组件如下所示...

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>
  );
}
brff19
2021-04-10