在 React 组件中测试异步代码(useEffect + fetch)
我正在尝试弄清楚如何使用 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-library
和
jest-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 运行并且状态已更新之后,加载文本应该会消失。
通过将初始值从 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 数据后状态没有更新)。
我最终也弄清楚了如何在加载数据的情况下异步测试此代码。
秘诀是首先将
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>
);
}