Testing asynchronous code: Manual promise resolution
Tim Seckinger
Posted on October 20, 2022
Asynchronicity is one of the harder problems to deal with trying to write correct code, and also trying to test it. Yet it constantly comes up in frontend code, by the asynchronous nature of user interactions and other interfaces such as network connections. When the code has races or cascades in particular, I've seen lots of people struggle to write tests for it, and end up with overcomplicated or brittle tests. This post is about a testing pattern that few people appear to be aware of and that can aid in many situations when testing asynchronous production code.
A minimal asynchronous UI test
The simplest commonly occurring asynchronous UI test is one where the test mocks out an asynchronous network operation. The following is an example of this from React code tested using Jest and Testing Library:
it('shows a success message after form submission', async () => {
const handleSubmit = jest.fn().mockResolvedValue(undefined);
render(<Form onSubmit={handleSubmit} />);
await user.click(await screen.findByText(/save|submit|confirm/i));
expect(handleSubmit).toHaveBeenCalled();
expect(await screen.findByText(/success|done/i)).toBeVisible();
});
In this example, handleSubmit
, which in production code might e.g. perform a POST
network request, returns a promise that resolves immediately (in a test for the failure case it would reject immediately). The component under test will proceed to display the success message if working correctly, which is what we're testing and what we then assert on.
This is fairly straightforward and is, provided knowledge of the relevant testing APIs, more or less what most people will arrive at after a short while. The reason it is so simple is that resolving the promise immediately hides away the intermediate state where the form has been submitted, but the submission handling has not yet completed. This is okay because the test does not care about it. But what if, say, we wanted to check that while the submission is in progress, the button cannot be clicked again, which would trigger a second submission of the form?
The intermediate state
While I've often seen the intermediate states during network operations not receive any consideration at all in tests, they can actually be of critical importance to the proper functioning of applications. In our example, an impatient user clicking the submission button twice could cause the creation of duplicate database entries, trigger two push notifications, or run into an error for the second operation. Failing to prevent this could also invalidate assumptions we're making elsewhere in the code (such as that only up to one of these operations is pending at any time) and cause problems further down the line. Thus I recommend to always test the logic dealing with these intermediate states, pinning down a contract for how the code is supposed to behave during them.
Testing that the button is disabled during the intermediate state requires us to stop pretending that submission is immediate and sneak an expect(...).toBeDisabled()
assertion in during that intermediate state. This is where beginners often start to struggle. One common approach I've seen is to write a more realistic mock for handleSubmit
:
it('disables the button while submitting', async () => {
const handleSubmit = jest.fn().mockReturnValue(timer(100));
render(<Form onSubmit={handleSubmit} />);
await user.click(await screen.findByText(/save|submit|confirm/i));
expect(handleSubmit).toHaveBeenCalled();
expect(await screen.findByText(/save|submit|confirm/i)).toBeDisabled();
await timer(110);
expect(await screen.findByText(/success|done/i)).toBeVisible();
});
Making our fake network request take 100ms (as it might in production) allows us to capture the intermediate state immediately after the button click. Only after then waiting at least 100ms ourselves, we will see the terminal success state.
This is not an inelegant solution and is intuitive in that it closely resembles what actually occurs with a network-bound submission process. However, it has the disadvantage that our test now takes over 100ms to complete.
Some will jump to enable a fake timer solution provided by the testing framework in order to have these >100ms of time pass in ~0ms of real time, replacing await timer(110)
with await jest.advanceTimersByTime(110)
. This, while introducing a little mental overhead reading these tests, does the job.
Others will simply drastically reduce the mock and wait times, perhaps even down to 0ms. This, while introducing dependency on the ordering of the task queue and potentially making the code more confusing, also does the job.
So do we even need to look at another alternative? As we will see, both of these solutions do not help with the increasing confusion as we approach more complex asynchronous flows.
Cascades, Races, …
Consider a user operation that creates or modifies one entity and then another based on it. Some component, either directly or using a helper function, will be responsible for calling these two operations in sequence. How would we go about testing the intermediate states during these operations? (provided that they are actually interesting enough to be worth testing; for readability in this example we'll merely look for status texts in our assertions)
it('creates an employee and sets them as store manager', async () => {
const handleCreateEmployee = jest.fn().mockReturnValue(
timer(100).then(() => 'employee1337')
);
const handleSetStoreManager = jest.fn().mockReturnValue(timer(100));
render(<CreateStoreManager
storeId="store42"
onCreateEmployee={handleCreateEmployee}
onSetStoreManager={handleSetStoreManager}
/>);
await user.type(await screen.findByLabelText(/name/i), 'John Doe');
await user.click(await screen.findByText(/create/i));
expect(handleCreateEmployee).toHaveBeenCalledWith('John Doe');
expect(await screen.findByText(/creating employee/i)).toBeVisible();
await timer(110);
expect(handleSetStoreManager).toHaveBeenCalledWith('store42', 'employee1337');
expect(await screen.findByText(/setting store manager/i)).toBeVisible();
await timer(100);
expect(await screen.findByText(/success|done/i)).toBeVisible();
});
As you can see, we establish the timings for our promise resolutions at the top, and then later process the cascade step-by-step. This is already harder to read because await timer(100)
does not exactly make it apparent what operation is finishing at this step. As you may be able imagine, this problem gets worse with even more complex flows, such as races, where we simulate promises resolving in varying orders to observe how the code behaves.
Fake timers or shorter durations can make this test faster, but neither make it more readable. waitFor
constructs are applicable and can help in some cases, but only test "this should happen at some point", not "when this operation finishes, this should happen".
Manual promise resolution
My preferred approach to this problem is resolving promises manually in tests. I've seen few people use this approach, perhaps because most people are not used to calling the Promise
constructor directly in application code. Yet I run into situations where this pattern is useful just about every week while writing frontend code.
it('creates an employee and sets them as store manager', async () => {
let resolveCreateEmployee;
const handleCreateEmployee = jest.fn().mockReturnValue(
new Promise(resolve => { resolveCreateEmployee = resolve })
);
let resolveSetStoreManager;
const handleSetStoreManager = jest.fn().mockReturnValue(
new Promise(resolve => { resolveSetStoreManager = resolve })
);
render(<CreateStoreManager
storeId="store42"
onCreateEmployee={handleCreateEmployee}
onSetStoreManager={handleSetStoreManager}
/>);
await user.type(await screen.findByLabelText(/name/i), 'John Doe');
await user.click(await screen.findByText(/create/i));
expect(handleCreateEmployee).toHaveBeenCalledWith('John Doe');
expect(await screen.findByText(/creating employee/i)).toBeVisible();
// `act` is a React helper that ensures React is done processing all changes
act(() => resolveCreateEmployee('employee1337'));
expect(handleSetStoreManager).toHaveBeenCalledWith('store42', 'employee1337');
expect(await screen.findByText(/setting store manager/i)).toBeVisible();
act(resolveSetStoreManager);
expect(await screen.findByText(/success|done/i)).toBeVisible();
});
This approach has a high degree of control and clarity, even as more asynchronous operations get involved and they become weirdly interleaved. I've written fairly long test cases this way without them feeling hard to comprehend.
It is fast, because no macrotask timers are scheduled with a delay; instead only promise ticks are processed. Thus, fake timers are not needed. In general, using fake timers to test production code that does not use timers, the only code that does being the test code itself, is almost certain to be an overcomplicated testing strategy.
It is also way clearer than plainly reducing timer durations down to zero or close to zero, because each step mentions directly which operation is completing.
toHaveBeenCalled
assertions on the mocked network operations become superfluous (we keep the toHaveBeenCalledWith
to verify correct arguments). If resolving the promise leads to an observable state change in the component, it must have called our mock to obtain the promise anyway; there is no other way it could have noticed the promise resolution and changed its state.
Reference code sample
The code sample for the simpler test with only one intermediate (button disabled) state follows, showing a minimal realistic example of this pattern in action:
it('disables the button while submitting', async () => {
let resolveSubmit;
const handleSubmit = jest.fn().mockReturnValue(
new Promise(resolve => { resolveSubmit = resolve })
);
render(<Form onSubmit={handleSubmit} />);
await user.click(await screen.findByText(/save|submit|confirm/i));
expect(await screen.findByText(/save|submit|confirm/i)).toBeDisabled();
act(resolveSubmit);
expect(await screen.findByText(/success|done/i)).toBeVisible();
});
Posted on October 20, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
October 24, 2024