Ray Chan
Ray Chan

Reputation: 1180

React Native test failed when using Formik and Yup as validation schema

I am trying to write some tests for a React Native component built using Formik. It's a simple form asking for username and password, and I want to use a validation schema built using Yup.

When I use the emulator and test the form manually, the form behaves as expected, error messages show up only when the input values are invalid.

However, when I try to write some automated tests with @testing-library/react-native, the behavior is not what I am expecting. The error messages show up in tests even if the provided values are valid. Below are the code:

// App.test.js
import React from 'react';
import { render, act, fireEvent } from '@testing-library/react-native';

import App from '../App';

it('does not show error messages when input values are valid', async () => {
  const {
    findByPlaceholderText,
    getByPlaceholderText,
    getByText,
    queryAllByText,
  } = render(<App />);

  const usernameInput = await findByPlaceholderText('Username');
  const passwordInput = getByPlaceholderText('Password');
  const submitButton = getByText('Submit');

  await act(async () => {
    fireEvent.changeText(usernameInput, 'testUser');
    fireEvent.changeText(passwordInput, 'password');
    fireEvent.press(submitButton);
  });

  expect(queryAllByText('This field is required')).toHaveLength(0);
});

// App.js
import React from 'react';
import { TextInput, Button, Text, View } from 'react-native';
import { Formik } from 'formik';
import * as Yup from 'yup';

const Schema = Yup.object().shape({
  username: Yup.string().required('This field is required'),
  password: Yup.string().required('This field is required'),
});

export default function App() {
  return (
    <View>
      <Formik
        initialValues={{ username: '', password: '' }}
        validationSchema={Schema}
        onSubmit={(values) => console.log(values)}>
        {({
          handleChange,
          handleBlur,
          handleSubmit,
          values,
          errors,
          touched,
          validateForm,
        }) => {
          return (
            <>
              <View>
                <TextInput
                  onChangeText={handleChange('username')}
                  onBlur={handleBlur('username')}
                  value={values.username}
                  placeholder="Username"
                />
                {errors.username && touched.username && (
                  <Text>{errors.username}</Text>
                )}
              </View>

              <View>
                <TextInput
                  onChangeText={handleChange('password')}
                  onBlur={handleBlur('password')}
                  value={values.password}
                  placeholder="Password"
                />
                {errors.password && touched.password && (
                  <Text>{errors.password}</Text>
                )}
              </View>

              <View>
                <Button
                  onPress={handleSubmit}
                  // If I explicitly call validateForm(), the test will pass
                  // onPress={async () => {
                  //   await validateForm();
                  //   handleSubmit();
                  // }}
                  title="Submit"
                />
              </View>
            </>
          );
        }}
      </Formik>
    </View>
  );
}

I am not sure whether I am writing the test correctly. I think Formik will automatically validate the form when the handleSubmit function is called.

Within the App.js, if I explicitly call the validateForm, the test will pass. However, it's not feeling right to change the implementation of the onPress handler just to cater for the test. Maybe I am missing some fundamental concepts around this issue. Any insights would be helpful, thank you.


Package versions:

"@testing-library/react-native": "^7.1.0",
"formik": "^2.2.6",
"react": "16.13.1",
"react-native": "0.63.4",
"yup": "^0.32.8"

Upvotes: 2

Views: 3095

Answers (1)

Ray Chan
Ray Chan

Reputation: 1180

Finally got the time to revisit this issue. Although I am not 100% sure what's going on under the hood yet, I think the result I found might benefit others so I will share it here.

This problem is intertwined with two sub-problems. This first one is related to the Promise used in React Native's module, the second one is related to the asynchronous nature of validation with Formik

Below is the code within App.test.js after modification, while keeping the App.js is unchanged,

// App.test.js
import React from 'react';
import { render, fireEvent, waitFor } from '@testing-library/react-native';

import App from '../App';

it('does not show error messages when input values are valid', async () => {
  const { getByPlaceholderText, getByText, queryAllByText } = render(<App />);

  const usernameInput = getByPlaceholderText('Username');
  const passwordInput = getByPlaceholderText('Password');
  const submitButton = getByText('Submit');

  await waitFor(() => {
    fireEvent.changeText(usernameInput, 'testUser');
  });

  await waitFor(() => {
    fireEvent.changeText(passwordInput, 'password');
  });

  fireEvent.press(submitButton);

  await waitFor(() => {
    expect(queryAllByText('This field is required')).toHaveLength(0);
  });
});

Normally, we do not need to use act to wrap the fireEvent as the fireEvent is already wrapped with act by default thanks to the testing-library. However, since Formik performs validation asynchronously after the text value is changed, and the validation function is not managed by React's callstack, so we need to manually wrap the fireEvent call with an act, or another convenient method: waitFor. In short, we need to wrap the fireEvent.changeText with a waitFor due to its asynchronicity.

However, changing the code to the above format does not solve all the problems. Although the test is passing, you will encounter warnings related to act. This is an known issue related to Promise as React Native's Jest preset overrides native Promise. (https://github.com/facebook/react-native/issues/29303)

If you comment out the line

global.Promise = jest.requireActual('promise');

in node_modules/react-native/jest/setup.js at around line 20, this issue will be solved. But directly modifying the files in node_modules is NOT recommended. A workaround would be setting up a jest preset to restore the native Promise like here (https://github.com/sbalay/without_await/commit/64a76486f31bdc41f5c240d28263285683755938)

Upvotes: 10

Related Questions