Jest test error: An update to form inside a test was not wrapped in act when testing local storage

272 views Asked by At

I am writing a test for checking if removeItem is called successfully on logout. The test passes but I get an error that "the LoginForm inside a test was not wrapped in act(...)".

Here is the LoginForm component code:

const LoginForm = () => {
  const [loggedIn, setLoggedIn] = useState(false);
  const {
    register,
    handleSubmit,
    reset,
    formState: { errors },
  } = useForm<LoginData>();
  const onSubmit: SubmitHandler<LoginData> = async (data) => {
    try {
      const response = await loginUser(data);
      if (response.status === 200) {
        localStorage.setItem("accessToken", response.data.accessToken);
        setLoggedIn(true);
        toast.success("login successful");
        reset();
      }
    } catch (error: any) {
      toast.error(error.response.data.error);
      reset();
    }
  };

  const logoutUser = () => {
    try {
      localStorage.removeItem("accessToken");
      setLoggedIn(false);
      toast.success("logout successful");
    } catch (error) {
      toast.error("Could not logout");
    }
  };

  return (
    <main>
      <ToastContainer />
      <form data-testid="login-form" onSubmit={handleSubmit(onSubmit)}>
        <h1>User Login:</h1>
        <div className="form__input">
          <input
            type="text"
            id="email"
            placeholder="Email Address"
            data-testid="email"
            {...register("email", { required: "Email address is required" })}
          />
          {errors.email && (
            <span className="errorMsg" role="alert">
              {errors.email.message}
            </span>
          )}
        </div>
        <div className="form__input">
          <input
            type="password"
            id="password"
            placeholder="Password"
            data-testid="password"
            {...register("password", { required: "Password is required" })}
          />
          {errors.password && (
            <span className="errorMsg" role="alert">
              {errors.password.message}
            </span>
          )}
        </div>
        {loggedIn === false ? (
          <button className="submitBtn" type="submit">
            Login
          </button>
        ) : (
          <button
            data-testid="logoutBtn"
            className="submitBtn"
            onClick={logoutUser}
          >
            Logout
          </button>
        )}
      </form>
    </main>
  );
};

I believe its a problem with async functions and have tried wrapping the expect statement in await waitFor(())=>{} but the problem persists.

Here is the test code:

  it("should remove accessToken from localStorage on logout", async () => {
    render(<Login />);
    const email = await screen.findByRole("textbox");
    const password = await screen.findByPlaceholderText("Password");
    const loginBtn = await screen.findByRole("button", {
      name: /login/i,
    });
    fireEvent.change(email, { target: { value: "email" } });
    fireEvent.change(password, { target: { value: "password" } });
    fireEvent.click(loginBtn);

    mockedAxios.post.mockImplementation(() =>
      Promise.resolve({
        status: 200,
        data: { accessToken: "eYagkaogk...", refreshToken: "eyAagga..." },
      })
    );

    const removeItem = jest.spyOn(Storage.prototype, "removeItem");
    const logoutBtn = await screen.findByRole("button", {
      name: /logout/i,
    });
    fireEvent.click(logoutBtn);

    await waitFor(() => {
      expect(removeItem).toHaveBeenCalledWith("accessToken");
    });
  });
1

There are 1 answers

2
adsy On

The reason this happens if that when you click the login button, it launches a promise that later comes back and sets a new react state. Since there is nothing in the test that waits until that happens, it means the test is potentially flaky since it may or may not be complete before your final assertion, which means any assertions aren't guaranteed to be happening on the same predictable component state.

Add an extra wait for this to finish.

    // ...

    fireEvent.click(loginBtn);

    mockedAxios.post.mockImplementation(() =>
      Promise.resolve({
        status: 200,
        data: { accessToken: "eYagkaogk...", refreshToken: "eyAagga..." },
      })
    );

    
    await waitFor(() => expect(mockedAxios.post).toHaveBeenCalledTimes(1))

    // ...

It may be necessary to be more precise than toHaveBeenCalledTimes and to also assert on the URL that was used. This is a bit brittle without this as this could capture other calls to axios and the problem would remain. You only care about capturing the login request.