Stuart
Stuart

Reputation: 1612

How to ensure a react-testing-library test waits for a completed promise chain?

In my login form, a successful login initiates a promise chain that ends with the user being redirected to the home screen. In the test below, I hope to ensure my login works by capturing that final step.

I have log statements within the code that tell me every step in the promise chain is executed as I expect, but the assertion still fails. It's clear from my logging that the test completes before the promise chain executes.

I think this might be complicated by the behavior of Formik, which I'm using in my actual form. I have not been able to successfully query and wait for the spinner that is displayed while the login is under way.

I'm at a loss as to how to get this test to wait until the navigation occurs. What promise resolution could be triggering waitFor to complete?

import { act, render, screen, waitFor } from "@testing-library/react"
import userEvent from "@testing-library/user-event"

import React from "react"

import { AuthProvider } from "context/auth-context"
import { rest } from "msw"
import { setupServer } from "msw/node"
import { MemoryRouter as Router } from "react-router-dom"
import { LoginScreen } from "screens/login"
import { handlers } from "test/auth-handlers"
import { buildLoginForm } from "test/generate/auth"
import { deferred } from "test/test-utils"

const Wrapper = ({ children }) => (
  <Router>
    <AuthProvider>{children}</AuthProvider>
  </Router>
)
const serverURL = process.env.REACT_APP_SERVER_URL
const server = setupServer(...handlers)

const mockNav = jest.fn()
jest.mock("react-router-dom", () => ({
  ...jest.requireActual("react-router-dom"),
  useNavigate: () => mockNav,
}))

beforeAll(() => {
  server.listen()
})
afterAll(() => server.close())
afterEach(() => {
  server.resetHandlers()
  jest.clearAllMocks()
})

test("successful login", async () => {
  const { promise, resolve } = deferred()

  render(
    <Wrapper>
      <LoginScreen />
    </Wrapper>,
  )

  expect(screen.getByLabelText(/loading/i)).toBeInTheDocument()

  await act(() => {
    resolve()
    return promise
  })

  const { email, password } = buildLoginForm()

  userEvent.type(screen.getByRole("textbox", { name: /email/i }), email)
  userEvent.type(screen.getByLabelText(/password/i), password)
  userEvent.click(screen.getByRole("button"))

  await waitFor(expect(mockNav).toHaveBeenCalledWith("home"))
})

The login form:

function LoginForm({ onSubmit }) {
  const { isError, isLoading, error, run } = useAsync()

  function handleSubmit(values) {
    // any 400 or 500 is displayed to the user
    run(onSubmit(values)).catch(() => {})
  }

  return (
    <Formik
      initialValues={{ email: "", password: "" }}
      validationSchema={Yup.object({
        email: Yup.string().email("Invalid email address").required("A valid email is required"),
        password: Yup.string().required("Password is required"),
      })}
      onSubmit={(values) => handleSubmit(values)}
    >
      <Form>
        <FormGroup name="email" type="text" label="Email" />
        <FormGroup name="password" type="password" label="Password" />
        <IconSubmitButton loading={isLoading} color="green">
          <MdArrowForward style={{ marginTop: ".6rem" }} />
        </IconSubmitButton>
        {isError ? <ErrorDisplay error={error} /> : null}
      </Form>
    </Formik>
  )
}

Upvotes: 8

Views: 6179

Answers (1)

Estus Flask
Estus Flask

Reputation: 222840

waitFor is unaware of promises or other implementation details, it works by polling provided assertion in specified intervals until an assertion passes or a timeout occurs.

waitFor works similarly to toThrow in terms of error handling. There's no way how it could catch errors and evaluate an assertion multiple times when it's specified as an argument, expect is called once, throws an error and fails the test:

  await waitFor(expect(mockNav).toHaveBeenCalledWith("home"))

The only way waitFor can work is when it's provided with a function that can be wrapped with try..catch internally and executed multiple times. A correct way to do this is:

  await waitFor(() => expect(mockNav).toHaveBeenCalledWith("home"))

Upvotes: 7

Related Questions