Search code examples
jestjses6-promisereact-testing-library

Best way to test rollback after promise rejection?


I often have situations where the behavior I'm trying to achieve is like this:

  1. User takes action
  2. Website updates UI optimistically
  3. Website fires update to server
  4. Website awaits server response
  5. If the update fails, website rolls back UI change

I've often found myself adding multiple timeouts to my tests to try to both assert that the optimistic update was made and then gets rolled back after rejection. So something like this:

it('rolls back optimistic update', async () => {
  jest.mocked(doUpdate).mockReturnValue(new Promise((resolve, reject) => {
    setTimeout(reject, 1000);
  });

  render(<App />)

  await screen.findByText('not done')

  userEvent.click(screen.getByText('do it'))

  await screen.findByText('Done!')

  await screen.findByText('not done')
});

But this has some pretty big downsides:

  1. Setting up tests like this is fidgety and difficult.
  2. Using timeouts inside my tests results in tests that are ensured to be slower than they need to be.
  3. Any significant changes to the test environment or tools have a high chance of breaking these tests.
  4. Tests get even slower and more complicated once I need to test things like ensuring that subsequent user actions don't clobber previous actions.
  5. If the promise happens to resolve after the test ends, I often end up with React complaining about updates not being wrapped in act.

How can I test this type of behavior in a way that is as efficient as possible and robust against test environment changes?


Solution

  • I now use this helper function in situations like this, where I want to control the precise order of events and promises are involved:

    export default function loadControlledPromise(mock: any) {
        let resolve: (value?: unknown) => void = () => {
            throw new Error('called resolve before definition');
        };
        let reject: (value?: unknown) => void = () => {
            throw new Error('called reject before definition');
        };
        const response = new Promise((res, rej) => {
            resolve = res;
            reject = rej;
        });
        jest.mocked(mock).mockReturnValue(response);
    
        return { resolve, reject };
    }
    

    Using this helper function, the example test becomes:

    it('rolls back optimistic update', async () => {
      const { reject } = loadControlledPromise(doUpdate);
    
      render(<App />)
    
      await screen.findByText('not done')
    
      userEvent.click(screen.getByText('do it'))
    
      await screen.findByText('Done!')
    
      reject();
    
      await screen.findByText('not done')
    });
    

    This ensures:

    • The test doesn't spend more time waiting than necessary.
    • Changes in the test environment are less likely to break the test.
    • More-complicated sequences of events can be tested without resulting in incomprehensible test code.
    • I can force promises to resolve before the test ends. even when I need to test conditions prior to the promise resolving, helping to avoid React complaining about updates happening outside of act.