Reputation: 834
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
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
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