sanniassin / react-input-mask

Input masking component for React. Made with attention to UX.
MIT License
2.24k stars 255 forks source link

Does not work with react-testing-library #174

Open lukescott opened 5 years ago

lukescott commented 5 years ago

This fails:

test("react-input-mask and react-testing-library", () => {
    const props = {
        type: "text",
        mask: "(999) 999-9999",
        maskChar: null,
        onChange: event => updateValue(event.target.value),
        value: "",
    }
    const {container, rerender} = render(<InputMask {...props} />)
    const updateValue = jest.fn(value => {
        props.value = value
        rerender(<InputMask {...props} />)
    })
    const input = container.querySelector("input")
    const phoneValue = "(111) 222-3333"
    fireEvent.change(input!, {target: {value: phoneValue}})
    expect(updateValue).toBeCalledWith(phoneValue)
    expect(input).toHaveProperty("value", phoneValue)
})

With:

Expected mock function to have been called with:
      "(111) 222-3333"
    as argument 1, but it was called with
      "(".

If if I change the test to:

test("react-input-mask and react-testing-library", () => {
    const props = {
        type: "text",
        mask: "(999) 999-9999",
        maskChar: null,
        onChange: event => updateValue(event.target.value),
        value: "",
    }
    const {container, rerender} = render(<InputMask {...props} />)
    const updateValue = jest.fn(value => {
        props.value = value
        rerender(<InputMask {...props} />)
    })
    const input = container.querySelector("input")
    const phoneValue = "(111) 222-3333"
    input.value = phoneValue
    input.selectionStart = input.selectionEnd = phoneValue.length
    TestUtils.Simulate.change(input)
    // fireEvent.change(input!, {target: {value: phoneValue}})
    expect(updateValue).toBeCalledWith(phoneValue)
    expect(input).toHaveProperty("value", phoneValue)
})

It works. There doesn't seem to be a way to use fireEvent and change selectionStart.

It would seem if selection were set to the length of the value inside of if (this.isInputAutofilled(...) {, similar to how if (beforePasteState) { does, it seems to work. I'm not sure what the consequences of that are though.

pjaws commented 4 years ago

I solved this issue myself by using @testing-library/user-event's type method, rather than fireEvent.change.

Smona commented 4 years ago

@pjaws, how did you solve this issue? userEvent.type isn't changing the value of the masked input for me.

pjaws commented 4 years ago

@Smona for this particular issue, userEvent.type worked for me; however, I had other issues with this lib that lead me to switch to react-text-mask, which solved all of them. Hope that helps.

kamwoz commented 4 years ago

Simplest solution is to mock whole library with simple input

jest.mock('react-input-mask', () => ({ value, onChange, id, autoFocus = false }) => (
  <input id={id} type="text" name="primary_contact_phone" value={value} onChange={event => onChange(event)} />
));
pjaws commented 4 years ago

That's not a good solution at all. This is not something you want to mock.

On Mon, Mar 9, 2020 at 1:19 AM Kamil Woźny notifications@github.com wrote:

Simplest solution is to mock whole library with simple input jest.mock('react-input-mask', () => ({ value, onChange, id, autoFocus = false }) => ( <input id={id} type="text" name="phone" value={value} onChange={event => onChange(event)} /> ));

— You are receiving this because you were mentioned. Reply to this email directly, view it on GitHub https://github.com/sanniassin/react-input-mask/issues/174?email_source=notifications&email_token=AMVRRISTW5Z6EJUS73FQ5YLRGSQ7XA5CNFSM4HMT6IH2YY3PNVWWK3TUL52HS4DFVREXG43VMVBW63LNMVXHJKTDN5WW2ZLOORPWSZGOEOGC7OY#issuecomment-596389819, or unsubscribe https://github.com/notifications/unsubscribe-auth/AMVRRIVFZB2FIGV2I4GLCR3RGSQ7XANCNFSM4HMT6IHQ .

lukescott commented 4 years ago

This might have been fixed in a recent update to jsdom: https://github.com/jsdom/jsdom/issues/2787. I saw this mentioned as part of https://github.com/testing-library/react-testing-library/issues/247.

BrunoQuaresma commented 4 years ago

I'm using create-react-app + @testing-library/user-event with the type() but doesn't work for me as well. I also updated react-scripts to be ^3.4.1.

pethersonmoreno commented 4 years ago

I found a problem in another library react-imask, solved it using change event:

    test('change on react-imask component', async () => {
      const { getByTestId } = render(<Step1 />);
      const inputRealState: any = getByTestId('realStateValue');

      expect(inputRealState.value).toBe('');
      await fireEvent.change(inputRealState, { target: { value: '3' } });
      expect(inputRealState.value).toBe('3');
    });

Maybe it can be useful to react-input-mask too.

dsmartins98 commented 4 years ago

I found a problem in another library react-imask, solved it using change event:

    test('change on react-imask component', async () => {
      const { getByTestId } = render(<Step1 />);
      const inputRealState: any = getByTestId('realStateValue');

      expect(inputRealState.value).toBe('');
      await fireEvent.change(inputRealState, { target: { value: '3' } });
      expect(inputRealState.value).toBe('3');
    });

Maybe it can be useful to react-input-mask too.

How did you insert [data-testid] into your InputMask tag? I'm trying to put [data-testid] and [inputProps = {{"data-testid": ...}}] but nothing works.

nicolaszein commented 4 years ago

Any update on this?

It seems that onChange is triggered however the input value is not updated.

I tried using fireEvent and also userEvent but no one of those works for me. :/

Can someone help with that?

iagopiimenta commented 4 years ago

My solution is to use another lib

nicolaszein commented 4 years ago

@iagopiimenta which one are you using?

LucasCalazans commented 4 years ago

Any updates on this issue?

For now, I followed the @lukescott solution

import TestUtils from 'react-dom/test-utils';

const changeInputMaskValue = (element, value) => {
  element.value = value;
  element.selectionStart = element.selectionEnd = value.length;
  TestUtils.Simulate.change(element);
};
mitchconquer commented 4 years ago

Something i've noticed is that if there are any "blur" events in the test the "change" event does not work but for tests that don't have any "blur" events, we are able to use the "change" event.

andremw commented 4 years ago

I had this problem too. Moved to https://nosir.github.io/cleave.js/ :/

iagopiimenta commented 4 years ago

Any update on this?

It seems that onChange is triggered however the input value is not updated.

I tried using fireEvent and also userEvent but no one of those works for me. :/

Can someone help with that?

@iagopiimenta which one are you using?

https://github.com/s-yadav/react-number-format

afucher commented 3 years ago

Any news?? A component that cannot be tested is something really sad

yordis commented 3 years ago

@afucher I am not sure what do you mean by "cannot be tested", here I leave you how to test it, or at least it works for me.

import userEvent from '@testing-library/user-event';
import TestUtils from 'react-dom/test-utils';

function changeInputMaskValue(element, value) {
  element.value = value;
  element.selectionStart = element.selectionEnd = value.length;
  TestUtils.Simulate.change(element);
};

it('example test', async () => {
  render(
    <MyComponent
      amount="100.00"
    />,
  );

  act(() => {
    // Both lines of codes are required
    userEvent.type(screen.getByLabelText('Amount'), '300');
    changeInputMaskValue(screen.getByLabelText('Amount'), '300');
  });

  act(() => {
    // Do not move the form submitting to the previous `act`, it must be in two
    // separate `act` calls.
    userEvent.click(screen.getByText('Next'));
  });

  // You must use `findByText`
  const error = await screen.findByText(/\$100.00 to redeem/);
  expect(error).toBeInTheDocument();
});
afucher commented 3 years ago

Why fireEvent doesn't work? Didn't try this way, will check

MIKOLAJW197 commented 3 years ago

My quick fix for that (using Sinon for testing):

Create function that will return native input component:

function FakePhoneInput(props): ReactElement {
  return (
    <label>
      Phone number
      <input type="tel" {...props} autoComplete="off"></input>
    </label>
  );
}

Later create a stub that will fake your original component (component which uses masked-input):

sandbox.stub(phoneInputComponent, 'default').callsFake((props) => FakePhoneInput(props));

In this case during tests you will create normal 'plain' input, instead of react-input-mask component.

mikhailsmyslov commented 3 years ago

In case if somebody also struggling from this issue, here is yet another hack, that may be useful:

const simulateInput = async (
  element?: HTMLInputElement | null,
  value = '',
  delay = 0
): Promise<void> => {
  if (!element) return
  element.click()
  const inner = async (rest = ''): Promise<void> => {
    if (!rest) return
    const { value: domValue } = element
    const caretPosition = domValue.search(/[A-Z_a-z]/) // regExp to match maskPlaceholder (which default is "_"), change according to your needs
    const newValue = domValue
      .slice(0, caretPosition)
      .concat(rest.charAt(0))
      .concat(domValue.slice(caretPosition))
    fireEvent.change(element, { target: { value: newValue } })
    await new Promise((resolve) => setTimeout(resolve, delay))
    return inner(rest.slice(1))
  }
  return inner(value)
}

const clearInput = (element?: HTMLInputElement | null): void => {
  if (!element) return
  fireEvent.change(element, { target: { value: '' } })
}

Usage inside test block:

...
  const input = screen.getByTestId('input')
  expect(input).toHaveValue('YYYY - mm - dd')

  await simulateInput(input, '2099abcdEFG-+=/\\*,. |') // only 1998-2000 years are allowed, so '99' should also be truncated
  expect(input).toHaveValue('20YY - mm - dd')

  await simulateInput(input, '000229')
  expect(input).toHaveValue('2000 - 02 - 29')

  clearInput(input)
  expect(input).toHaveValue('YYYY - mm - dd')
...

Seems that this issue related to how JSDOM maintains focus of the input (always places caret at the end of entered value), so, all chars entered with userEvent.type are placed outside the mask and therefofe truncated by internal logic of react-input-mask.

When using fireEvent.change, everything works as expected (thanks to internal logic of react-input-mask, which fills chars that match mask into placeholders), except the case with dynamic mask generation as shown in the example above with 1998-2000 years. If i call single fireEvent.change('2099'), the input value will be '2099 - mm - dd' instead of '20yy - mm - dd' which is wrong in mentioned case.

So, the hack purpose is to fire change events sequentially for a complete input value with only one char replacement at time (i.e. simulating typing symbols one by one), next typed symbol will replace the first maskPlaceholder char.

Promises can be omitted from simulateInput (as well as setTimeout call), and then it will be possible to use it without await keyword.

Hope, it'll help somebody.

P.S. The same idea can be used to simulate backspace removal one by one. react-input-mask version 3.0.0-alpha.2

hsaldanha commented 2 years ago

I found a problem in another library react-imask, solved it using change event:

    test('change on react-imask component', async () => {
      const { getByTestId } = render(<Step1 />);
      const inputRealState: any = getByTestId('realStateValue');

      expect(inputRealState.value).toBe('');
      await fireEvent.change(inputRealState, { target: { value: '3' } });
      expect(inputRealState.value).toBe('3');
    });

Maybe it can be useful to react-input-mask too.

This solved the problem for me.

w90 commented 2 years ago

Yep, this one seem to work, though a bit quirky:

interface IElement extends Element {
  value: string
  selectionStart: number
  selectionEnd: number
}

const changeInputMaskValue = (element: IElement, value: string | any[]) => {
  if (typeof value === 'string') {
    element.value = value
  }
  element.selectionStart = element.selectionEnd = value.length
  TestUtils.Simulate.change(element)
}

....
  test('Goes through the entire flow - happy path', async () => {
    const user = userEvent.setup()
    const inputPhoneNumber = screen.getByRole('textbox', { name: /phone number/i })

    await act(async () => {
      user.type(inputPhoneNumber, '+48790789789')
      changeInputMaskValue(inputPhoneNumber, '+48790789789')
    })
  })