Automated tests are wonderful. They can give you confidence that the code you write works. They can document the behavior of your application. And they can also help prevent regressions introduced when you update your applications.
However, sometimes a bug can slip into your tests and cause them to lie to you! You see a test pass, and without carefully checking, you can get a false sense of confidence, making you think that your code works when really, you’ve allowed a bug through.
A coworker and I discovered this recently while reviewing one of their tests. Let’s explore an example…
TL;DR
When using Testing Library’s waitFor function to test asynchronous code, remember that it returns a Promise
. So make sure to use await
or .then
.
Do this:
it('tests some asynchronous code', async () => {
// arrange
// act
await waitFor(() => {
//assert
});
});
Or this:
it('tests some asynchronous code', () => {
// arrange
waitFor(() => {
// act
})
.then(() => {
// assert
});
});
But NOT this:
it('tests some asynchronous code', () => {
// arrange
// act
waitFor(() => {
// You'll think that you asserted, but you probably didn't.
});
});
They mention this in the docs too…
The Example
I created a small demo application for illustration. Here’s the link to the repo.
This little React application takes some input text, reverses it, and then displays the reversed text to the user.
The reversal of the text is delayed by 200 ms. In the real application where we encountered this type of bug, the action that my coworker implemented invoked an API call. The application had to wait for this API call to finish to display the results. This example is contrived to mimic that behavior.
// This reversed result is delayed and returns a promise. This is to
// mimic the behavior we might encounter in an API call, such as with
//.fetch.
function delayedReverseString(theString: string): Promise<string> {
return new Promise((resolve) => {
setTimeout(() => {
const reversedString = reverseString(theString);
resolve(reversedString);
}, 250);
});
}
Before we try it out, let’s examine the test…
it('reverses entered text and displays reversed text to user', () => {
// arrange
render(<App />);
const input = screen.getByPlaceholderText('Enter text...');
const reverseButton = screen.getByText('Reverse!');
// act
fireEvent.change(input, { target: { value: 'hello' } });
fireEvent.click(reverseButton);
// assert
expect(screen.getByText('olleh')).toBeInTheDocument();
});
As you can see, our test:
Renders the application.
Gets the input element and the reverse button.
Enters the text “hello”, and clicks the button, just like a user would.
Asserts that the reversed text is displayed in the document.
When we run the test, we can see it fails…
Since we know the result is delayed, we’ll need to modify our test to wait for that result to display. So let’s use Testing Library’s waitFor function to do that!1
...
// assert
waitFor(() => {
expect(screen.getByText('olleh')).toBeInTheDocument();
});
...
After that modification, the test passes!
Awesome. Our test is passing. However, when we try out the application, something seems off…
After entering some text and clicking “Reverse!” It just shows the text we entered! It’s not magically reversed!
So what mistake did we make in our test?
Let’s add some logging statements to follow along with the execution of our test.
// assert
console.log("before waitFor");
waitFor(() => {
console.log("before assertion");
expect(screen.getByText('olleh')).toBeInTheDocument();
console.log("after assertion");
});
console.log("after waitFor");
Notice that we never see “after assertion
“. This is because our expect
statement is failing and throwing an error. It will never hit the line after our assertion.
We can prove this by adding a try-catch block.
// assert
console.log("before waitFor");
waitFor(() => {
console.log("before assertion");
try {
expect(screen.getByText('olleh')).toBeInTheDocument();
} catch (e) {
console.log("EXPECT FAILURE:", e);
}
console.log("after assertion");
});
console.log("after waitFor");
We can see that an error is thrown, but our test continues on its merry way, reporting success.
Why doesn’t our test fail?
Because waitFor
returns a Promise
.
The problem with our test is that we are not awaiting the returned promise to resolve before moving on. our test block doesn’t detect the error being thrown because in this case, waitFor
is effectively swallowing the error thrown by our expect statement.
Let’s see what happens when we introduce async
and await
.
it('reverses entered text and displays reversed text to user', async () => {
// arrange
render(<App />);
const input = screen.getByPlaceholderText('Enter text...');
const reverseButton = screen.getByText('Reverse!');
// act
fireEvent.change(input, { target: { value: 'hello' } });
fireEvent.click(reverseButton);
// assert
console.log("before waitFor");
await waitFor(() => {
console.log("before assertion");
expect(screen.getByText('olleh')).toBeInTheDocument();
console.log("after assertion");
});
console.log("after waitFor");
});
We finally have a failing test! Notice that “before assertion” gets logged about 21 times, and then reports a failure.
This is how waitFor
works. By default, it will run the callback once, then it repeats the passed callback every 50 milliseconds for 1000 milliseconds (1 second), or until the callback completes successfully, and resolves the promise. Once waitFor has timed out, it will reject the promise, and pass along the error that occurred. This unhandled error will then cause our test to fail.
Now that we have a valid test, let’s fix our code.
Let’s take a look at the reverseString
function.
function reverseString(theString: string): string {
return theString.split('').join('');
}
We forgot to reverse the string! Thankfully, that’s an easy fix.
function reverseString(theString: string): string {
return theString.split('').reverse().join('');
}
We can see that our test passes.
And, our application is behaving as expected.
Conclusion
Automated testing is a critical aspect of software development. Good tests can provide confidence that the code we ship behaves as intended. They can help us ensure that it continues to do so by guarding against regressions.
However, it's important to recognize that our tests can inadvertently mislead if not crafted well. It’s possible to create flaky, unreliable tests if we don’t understand our tools well.
Through an exploration of asynchronous testing using Testing Library's waitFor
function, we've uncovered a possible pitfall: the failure to await the returned Promise, leading to undetected errors and false positives in test results.
By ensuring proper utilization of waitFor
—either through async/await
or .then
syntax—we can accurately capture asynchronous behavior and detect errors effectively. We can have true confidence in our tests and our code.
We could also use findByText. However, in this example, waitFor
more clearly illustrates the point. And it more closely matches the flaky test we encountered in our production application.