Use One Async Query Per Expected Delay
/ 3 min read
Last Updated:Remember, One Await Per Delay
It’s worth noting that one
await
call per test block is usually sufficient, as all async actions have been resolved by that point.
Say we are testing some client side filtering behaviour. We expect delays while the component updates in response to the filtering operation. We therefore know that we will have to use async utilities to assert the appearance or disappearance of elements.
But here’s the rub: we only need one async query for each expected delay. Once we’ve waited for an element to appear or vanish, the DOM’s in a post-delay state. From there, we can make any further checks without waiting again.
We do this.
it("should filter the list", async () => { // Assert the outcome of filtering. expect(await screen.findByText(firstEntry)).toBeVisible(); expect(screen.queryByText(secondEntry)).not.toBeInTheDocument();});
Or this.
it("should filter the list", async () => { // Assert the outcome of filtering. await waitForElementToBeRemoved(screen.queryByText(secondEntry)); expect(screen.getByText(firstEntry)).toBeVisible();});
Not this.
it("should filter the list", async () => { // Assert the outcome of filtering. expect(await screen.findByText(firstEntry)).toBeVisible(); await waitFor(() => { expect(screen.queryByText(secondEntry)).not.toBeInTheDocument(); });});
And not this.
it("should filter the list", async () => { // Assert the outcome of filtering. await waitForElementToBeRemoved(screen.queryByText(secondEntry)); expect(await screen.findByText(firstEntry)).toBeVisible();});
A More Real World Example
Here is a real world example of misusing the async queries.
it("should filter the list", async () => { const firstEntry = "Sam"; const secondEntry = "Mary";
render(<Component />);
await waitForSpinnerToDisappear();
// Assert pre-filter state expect(screen.getByText(firstEntry)).toBeVisible(); expect(screen.getByText(secondEntry)).toBeVisible();
// Open the filter dialog user.click(await screen.findByRole("button", { name: /filter/i })); expect(await screen.findByRole("heading", { name: /filter/i })).toBeInTheDocument();
// Select a filter user.click(await screen.findByRole("button", { name: /responsible user/i })); user.click(await screen.findByRole("menuitem", { name: firstEntry })); expect( await within(screen.getByLabelText(/responsible user/i)).findByText(firstEntry), ).toBeInTheDocument();
// Apply the filter user.click(await screen.findByRole("button", { name: /apply/i }));
// Check that only the first entry is displayed expect(await screen.findByText(firstEntry)).toBeInTheDocument(); await waitFor(() => expect(screen.queryByText(secondEntry)).not.toBeInTheDocument());});
And this is how I would refactor this test.
it("should filter the list", async () => { const firstEntry = "Sam"; const secondEntry = "Mary";
render(<Component />);
await waitForSpinnerToDisappear();
// Assert pre-filter state expect(screen.getByText(firstEntry)).toBeVisible(); expect(screen.getByText(secondEntry)).toBeVisible();
// Open the filter dialog
user.click(screen.getByRole("button", { name: /filter/i }));
expect(await screen.findByRole("heading", { name: /filter/i })).toBeInTheDocument();
// Set a filter
user.click(screen.getByRole("button", { name: /responsible user/i }));
user.click(await screen.findByRole("menuitem", { name: firstEntry })); expect( await within(screen.getByLabelText(/responsible user/i)).findByText(firstEntry), ).toBeInTheDocument();
// Apply the filter
user.click(screen.getByRole("button", { name: /apply/i }));
await waitForElementToBeRemoved(screen.getByRole("heading", { name: /filter/i }));
expect(screen.getByText(firstEntry)).toBeVisible(); expect(screen.queryByText(secondEntry)).not.toBeInTheDocument();});
An Important Consideration
NOT SURE IF THIS IS TRUE.
It is worth noting that if you’re relying on an async query to hold up the test so you can use regular queries, it doesn’t work that way. Async queries in an expectation won’t wait for the DOM to reach a specific, post delayed state.
This won’t work:
expect(await screen.findByText(firstEntry)).toBeVisible();expect(screen.queryByText(secondEntry)).not.toBeInTheDocument();
But this will:
const firstEntryPostFilter = await screen.findByText(firstEntry);
expect(firstEntryPostFilter).toBeVisible();expect(screen.queryByText(secondEntry)).not.toBeInTheDocument();
And this will:
await waitForElementToBeRemoved(screen.queryByText(secondEntry));
expect(firstEntry).toBeVisible();expect(screen.queryByText(secondEntry)).not.toBeInTheDocument();
Because the async queries work this way, this is why you’ll often see seemingly redundant, but essential, doubled up async queries.
expect(await screen.findByText(firstEntry)).toBeVisible();await waitFor(() => expect(screen.queryByText(secondEntry)).not.toBeInTheDocument());