jaredpalmer / formik

Build forms in React, without the tears 😭
https://formik.org
Apache License 2.0
33.91k stars 2.79k forks source link

Multistep Wizard - Testing with react-testing-library #1886

Open stacy-kim opened 4 years ago

stacy-kim commented 4 years ago

I am trying to test the Multistep Wizard example that has provided by Formik. I have been running into issues where the tests will timeout, specifically when dealing with Wizard's handleSubmit. All of these issues specifically relate to testing the Wizard -- when I actually use the Wizard, it works perfectly fine.

These are the testing issues I am having

  1. A multi-page wizard will not go to the next page (it does hit the "next" function from Wizard). When I run the tests on my computer, it seems like it breaks around when submitForm is supposed to be called. I'm not sure if there is a loop happening, or if there is an issue with the Formik's handlers being async. I have tried following the suggestions in Issue #937 but none of them have worked.
  it("should go to the next page of the Wizard", async done => {
    const firstName = result.getByPlaceholderText("First Name");
    fireEvent.change(firstName, {
      target: { persist: () => {}, name: "firstName", value: "Joe" }
    });

    const lastName = result.getByPlaceholderText("Last Name");
    fireEvent.change(lastName, {
      target: { persist: () => {}, name: "lastName", value: "Schmoe" }
    });

    await wait(() => {
      fireEvent.submit(result.getByTestId("formikwizardtest"));
      expect(result.container).toContain("Favorite Color");
      done();
    });
  });
  1. A single-page wizard (or a multi-step wizard on the "last page") times out and the test fails -- it thinks that onSubmit is called 0 times. However, this is bizarre because if you look at the console logs, inside onSubmit after sleep prints.

    it("should properly submit", async done => {
    const firstName = result.getByPlaceholderText("First Name");
    fireEvent.change(firstName, {
      target: { persist: () => {}, name: "firstName", value: "Joe" }
    });
    
    const lastName = result.getByPlaceholderText("Last Name");
    fireEvent.change(lastName, {
      target: { persist: () => {}, name: "lastName", value: "Schmoe" }
    });
    
    await wait(() => {
      fireEvent.submit(result.getByTestId("formikwizardtest"));
      expect(onSubmit).toHaveBeenCalledTimes(1);
      done();
    });
    });
  2. Another issue I have run into is that form errors are not properly being rendered when testing. So, for example, if I were to fill in the field for "firstName" and click the button, it does not show the errors. Maybe this is related to Issue #1580 or Issue #486?
  it("should render errors", async done => {
    const firstName = result.getByPlaceholderText("First Name");
    fireEvent.change(firstName, {
      target: { persist: () => {}, name: "firstName", value: "Joe" }
    });

    await wait(() => {
      fireEvent.submit(result.getByTestId("formikwizardtest"));
      expect(result.container).toContain("Required");
      done();
    });
  });

Does anyone have any insights on how to test the Formik Multistep Wizard?

Here is a link to a codesandbox that I made. The Wizard and App logic are pretty much identical to the one provided by Formik. The only differences are that I (1) removed Debug, (2) added console logs, and (3) moved out onSubmit so I could mock it.

clemente-xyz commented 4 years ago

Hi! Any update on this issue?

johnrom commented 4 years ago

I don't really have the ability to get into this, but it does look like all of the events here are async. Do these tests work without the wizard? It looks like there are some critical misunderstandings of async code in the examples provided.

// if I'm not mistaken, async functions don't need `done()`, 
// because it's implied by the keyword async that when the function returns it is done
it("should render errors", async done => {
    const firstName = result.getByPlaceholderText("First Name");
    fireEvent.change(firstName, {
      target: { persist: () => {}, name: "firstName", value: "Joe" }
    });

    // doesn't wait() wait for you to return something of value?
    await wait(() => {
      fireEvent.submit(result.getByTestId("formikwizardtest"));
      expect(result.container).toContain("Required");
      // shouldn't this be automatically resolved by the async await keywords?
      done();
    });
  });

Refactoring the above (and I'm just guessing from reading the documentation, I've never actually used the react test library), I'd guess you're looking for something more like:

it("should render errors", async () => {
    const firstName = result.getByPlaceholderText("First Name");

    // fire a change
    fireEvent.change(firstName, {
      target: { persist: () => {}, name: "firstName", value: "Joe" }
    });

    // wait until the field above has the value Joe, which happens asynchronously
    await wait(() => result.getByDisplayValue("Joe"));

    // submit the form
    fireEvent.submit(result.getByTestId("formikwizardtest"));

    // wait until the form contains an element with text "Required"
    await wait(() => result.getByText("Required"));

    // now that all awaits are fulfilled, a simple expect. 
    // No need to call `done()` because this is an async function 
    // -- when it returns it calls done() automatically
    expect(result.container).toContain("Required");
});

I'd like to reiterate that I haven't used react-testing-library so I might be mistaken about some of the above. I also use Promise currently and having been able to async/await my code, so I might have some assumption of how it works based on c#. The above is intended as a way to show a technique as opposed to technically correct code.

I wouldn't consider the wizard example completely up-to-date, especially since it doesn't use hooks or function components. It's more of an example of how you can implement your own Wizard.

clemente-xyz commented 4 years ago

Hi @johnrom !

Thanks for your reply, but the problem stills happening :S

Seems that react-testing-library don't recognize fireEvents on multistep Formik-based forms. In my case, I have a component which renders different inputs and buttons based on which step of the process you are (its the same Formik form, but changes the inputs and buttons based on step state). The test suite let me pass from the 1st step to the 2nd, but when trying to change the 2nd input value (fireEvent.change(secondInput, {target: {value: 'blablabla'}}), nothing happens on jest DOM [the input doesn't change]). I'm stuck in the 2nd form because of that.

My suite is like:

// this test passes perfectly
it('Calls action when 1st form btn is clicked', async () => {
      const { getByText, getByLabelText } = render(...My component...);

      const firstInput = getByLabelText('1st input label');
      const firstButton = getByText('1st button text');

      fireEvent.change(firstInput, { target: { value: 'blablabla' } });

      await wait(() => {
        fireEvent.click(firstButton);
      });

      wait(() => {
        expect(testingCallbackFuncStep1).toBe(1);
      });
});
// here I got the problem
it('Calls action when 2nd form btn is clicked', async () => {
      const { getByText, getByLabelText } = render(...My component...);

      const _firstInput = getByLabelText('1st input label');
      const _firstButton = getByText('1st button text');

      fireEvent.change(_firstInput, { target: { value: 'blablabla' } });

      await wait(() => {
        fireEvent.click(_firstButton);
      });

      await waitForElement(() => [getByLabelText('2nd input label'), getByText('2nd button text')]);

      // from here to the end of the suite, nothing is triggered
      fireEvent.change(getByLabelText('2nd input label'), { target: { value: 'blablabla 2' } });

      await wait(() => {
        fireEvent.click(getByText('2nd button text'));
      });

      wait(() => {
        expect(testingCallbackFuncStep2).toBe(1);
      });
});

The test breaks on expect(testingCallbackFuncStep2).toBe(1); because that func is never called (because it is not triggered when the 2nd step input has no value).

Any suggestion?

johnrom commented 4 years ago

It doesn't look like you're returning an element from:

    await wait(() => {
        // I don't think there's a need to wait for a click, so just remove this `wait`
        fireEvent.click(_firstButton);
    });

I'd try logging to console between each set because it's hard to tell where the execution might be stopping.

gabeamaleoni commented 5 months ago

I got the Formik multi-step wizard to work like so @stacy-kim :

describe('Order Page test suite', () => {
  test('Allows user to step through form', async () => {
    await act(async () => {
      render(
          <OrdersPage />
      );
    });

    //  Click the Create Order button
    await waitFor(async () => {
      const createButton = await screen.getByText('Create Customer Order');

      // click the button
      await fireEvent.click(createButton);

      // expect the create modal to be in the document
      expect(await screen.getByTestId('order-create-form')).toBeInTheDocument();
    });

    // Form Step 1:
    await waitFor(async () => {
      // find the next button
      const nextButton = await screen.getByTestId('next-button');

      // click the button
      fireEvent.submit(nextButton);
    });

    // Form Step 2:
    await waitFor(async () => {
      // find the description input
      const descriptionInput = await screen.getByLabelText(/^Order description/i);

      // Type in a description
      await fireEvent.change(descriptionInput, { target: { value: 'test description' } });

      // find the next button
      const nextButton = await screen.getByTestId('next-button');

      // click the button
      fireEvent.submit(nextButton);
    });

    // Form Step 3:
    await waitFor(async () => {
      const lineItemsTable = await screen.getByText('Line Items');
      expect(lineItemsTable).toBeInTheDocument();

      // Find the div element by its test id
      const costDivElement = await screen.getByTestId('lineItems.0.cost');

      // Find the input element within the div
      const constInputElement = costDivElement.querySelector('input');

      // make sure the value is product's currentNetCost
      expect(constInputElement).toHaveValue(2500);

      const priceDivElement = await screen.getByTestId('lineItems.0.price');

      // Find the input element within the div
      const priceInputElement = priceDivElement.querySelector('input');

      // make sure the value is product's currentNetPrice
      ... More assertions
    });
  });
});

The key is to use multiple waitFor blocks at the same level as well as fireEvent.submit(nextButton);