skip to content

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.

Best Practices for Writing Tests with React Testing Library

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.

FilterComponent.test.jsx
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.

FilterComponent.test.jsx
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:

FilterComponent.test.jsx
expect(await screen.findByText(firstEntry)).toBeVisible();
expect(screen.queryByText(secondEntry)).not.toBeInTheDocument();

But this will:

FilterComponent.test.jsx
const firstEntryPostFilter = await screen.findByText(firstEntry);
expect(firstEntryPostFilter).toBeVisible();
expect(screen.queryByText(secondEntry)).not.toBeInTheDocument();

And this will:

FilterComponent.test.jsx
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.

FilterComponent.test.jsx
expect(await screen.findByText(firstEntry)).toBeVisible();
await waitFor(() => expect(screen.queryByText(secondEntry)).not.toBeInTheDocument());