23
loading...
This website collects cookies to deliver better user experience
react-query
-alike hooks, but not the library itself, to make things more transparent:const getUser = async (id: string): Promise<string> =>
id[0].toUpperCase().concat(id.slice(1))
const useUserQuery = (id: string | null) => {
const [data, setData] = useState<string | null>(null)
useEffect(() => {
if (!id) {
setData(null)
return
}
getUser(id).then(setData)
}, [id])
return data
}
const UserView = ({ id }: { id: string | null }) => {
const data = useUserQuery(id)
if (data === null) return <div>Loading...</div>
return <>{data}</>
}
it('should render user info', async () => {
await render(<UserView id="bob" />)
expect(screen.getByText('Bob')).not.toBeNull()
})
getUser
, we will now wait for two consecutive requests and only then return the aggregated data:const getUser = async (id: string): Promise<string> => {
const user = await getUser(id)
const partner = await (user[0] === 'A'
? getUser('charlie')
: getUser('daisy'))
return `${user} and ${partner}`
}
it('should render user info', async () => {
await render(<UserView id="bob" />)
expect(screen.getByText('Alice and Charlie')).not.toBeNull()
})
render
is a synchronous function, but await
is designed to work with asynchronous ones. What's going on when render
is awaited? Well, MDN is very clear about it:If the value of the expression following the await operator is not a Promise, it's converted to a resolved Promise.
render
with await
, JavaScript implicitly wraps the result into a promise and waits for it to be settled. Meanwhile, we already have another pending promise scheduled in the fetch function. By the time implicit awaited promise is resolved, our fetch is resolved as well, as it was scheduled earlier. So we have the correct output on the screen.Warning: An update to UserAndPartnerView inside a test was not wrapped in act(...).
When testing, code that causes React state updates should be wrapped into act(...):
act(() => {
/* fire events that update state */
});
/* assert on the output */
This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act
at UserAndPartnerView (./common-testing-library-mistakes/src/a-await-sync-methods/UserAndPartnerView.tsx:3:38)
it('should render user info', async () => {
render(<UserView id="bob" />)
expect(await screen.findByText('Alice and Charlie')).not.toBeNull()
})
findByText
will wait for the given text to appear in the DOM.await
for syncronous functions, and render
in particular. Use the proper asyncronous utils instead:expect(await screen.findByText('some text')).not.toBe(null)
// or
await waitFor(() => {
expect(screen.getByText('some text')).not.toBe(null)
})
async/await
syntax is very convenient, it is very easy to write a call that returns a promise without an await
in front of it. UserView
component we created in a previous example:it('should render user info', async () => {
render(<UserView id="alice" />)
waitFor(() => {
expect(screen.getByText('Alice')).not.toBeNull()
})
})
it('should render user info', async () => {
render(<UserView id="bob" />)
waitFor(() => {
expect(screen.getByText('Alice')).not.toBeNull()
})
})
await
before asyncronous waitFor
call. Asyncronous method call will always return a promise, which will not be awaited on its own. Jest simply calls this line and finishes the test. No assertions fail, so the test is green. But if we add await
in front of waitFor
, the test will fail as expected:it('should render user info', async () => {
render(<UserView id="bob" />)
await waitFor(() => {
expect(screen.getByText('Alice')).not.toBeNull()
})
})
Unable to find an element with the text: Alice.
Ignored nodes: comments, <script />, <style />
<body>
<div>
Bob
</div>
</body>
waitFor
. It's an async RTL utility that accepts a callback and returns a promise. This promise is resolved as soon as the callback doesn't throw, or is rejected in a given timeout (one second by default). waitFor
will call the callback a few times, either on DOM changes or simply with an interval.waitFor
could lead to unexpected test behavior.const TransactionDetails = ({
description,
merchant,
}: {
description?: string | null
merchant?: string | null
}) => (
<ul>
{description && <li>Description: {description}</li>}
{merchant && <li>Merchant: {merchant}</li>}
</ul>
)
const Transactions = () => {
const [selectedTransactionId, setSelectedTransactionId] = useState<
string | null
>(null)
const transactions = useTransactionsQuery()
if (transactions === null) return <div>Loading...</div>
return (
<ul>
{transactions.map(tx => (
<li
key={tx.id}
onClick={() =>
setSelectedTransactionId(
selectedTransactionId === tx.id ? null : tx.id,
)
}
>
<div>Id: {tx.id}</div>
{selectedTransactionId === tx.id && (
<TransactionDetails description={tx.description} />
)}
</li>
))}
</ul>
)
}
it('should render transaction details', async () => {
render(<Transactions />)
await waitFor(() => {
fireEvent.click(screen.getByText('Id: one'))
expect(screen.getByText('Description: Coffee')).not.toBeNull()
})
})
screen.getByText('Id: one')
because it will throw due to missing "Id: one" text. To avoid it, we put all the code inside waitFor
which will retry on error. So we are waiting for the list entry to appear, clicking on it and asserting that description appears.TransactionDetails
. When nothing is selected, useTransactionDetailsQuery
returns null
, and the request is only triggered when an id is passed.const TransactionsWithDetails = () => {
// ...
const transactions = useTransactionsQuery()
const details = useTransactionDetailsQuery(selectedTransactionId)
// ...
<div>Id: {tx.id}</div>
{selectedTransactionId === tx.id && (
<TransactionDetails
description={tx.description}
merchant={details?.merchant}
/>
)}
// ...
}
it('should render transaction details', async () => {
render(<TransactionsWithDetails />)
await waitFor(() => {
fireEvent.click(screen.getByText('Id: one'))
expect(screen.getByText('Description: Coffee')).not.toBeNull()
expect(screen.getByText('Merchant: Mega Mall one')).not.toBeNull()
})
})
waitFor
is triggered multiple times because at least one of the assertions fails. Let's go through the sequence of calls, where each list entry represents the next waitFor
call:fireEvent.click(screen.getByText('Id: one'))
fails as transactions list is not yet fetched, and "Id: one" text is not at the screen.expect(screen.getByText('Merchant: Mega Mall one')).not.toBeNull()
fails as details are not yet fetched.fireEvent.click
triggered a DOM mutation, so waitFor
executes the callback once again. fireEvent.click
is triggered once again, closing the transaction description, and expect(screen.getByText('Description: Coffee')).not.toBeNull()
fails.fireEvent.click
caused another DOM mutation, we stuck in 2-3 loop. Transaction details are being opened and closed over and over again with no chance for the details request to complete and to render all the needed info.fireEvent.click
) out of waitFor
.it('should render transaction details', async () => {
render(<TransactionsWithDetails />)
const transaction = await screen.findByText('Id: one'))
fireEvent.click(transaction)
await waitFor(() => {
expect(screen.getByText('Description: Coffee')).not.toBeNull()
expect(screen.getByText('Merchant: Mega Mall one')).not.toBeNull()
})
})
waitFor
is non-deterministic and you cannot say for sure how many times it will be called, you should never run side-effects inside it. Instead, wait for certain elements to appear on the screen, and trigger side-effects synchronously.waitFor
and waitForElementToBeRemoved
await
s with asyncronous findBy...
and findAllBy...
waitFor
await render
, but works perfectly well for everything else.