redux-toolkit icon indicating copy to clipboard operation
redux-toolkit copied to clipboard

Can't test my code with Mock Service Worker (typecannot read properties of null (reading 'url'))

Open arthedza opened this issue 3 years ago • 0 comments

I'm trying to test my react-native app, but the test always fails. The error is {status: 'FETCH_ERROR', error: 'typecannot read properties of null (reading 'url')'} (see in the code below). I imported whatwg-fetch at the very beginning of the jest.setup.js file and added it to the setupFilesAfterEnv array. What I'm doing wrong?

The test:

// Establish API mocking before all tests.
beforeAll(() =>
  server.listen({
    onUnhandledRequest: 'warn',
  })
);
// Reset any request handlers that we may add during the tests,
// so they don't affect other tests.
afterEach(() => server.resetHandlers());
// Clean up after the tests are finished.
afterAll(() => server.close());

// ...

it('should continue on button press', () => {
    const tree = renderWithProviders(<OTPModal {...props} />);
    const pinInput = tree.root.findByType(PinInput);

    act(() => {
      pinInput.props.onPinChange('1234');
    });

    const button = tree.root.findByType(Button);

    act(() => {
      button.props.onPress();
    });

    expect(props.route.params.onSuccess).toHaveBeenCalled();
  });

The component:

import React from 'react';
import { StyleSheet } from 'react-native';
import { useTranslation } from 'react-i18next';
import { RootStackScreenProps } from '@/Navigators/types';
import { AuthScreen } from '@/Components/Screen/AuthScreen';
import { AppScreen } from '@/Components/Screen/AppScreen';
import { PinInput } from '@/Components/Inputs/PinInput';
import { Typography } from '@/Components/Typography';
import { LinkQuestion } from '@/Components/LinkQuestion';
import { Button } from '@/Components/Buttons/Button';
import { Spacer } from '@/Components/Spacer';
import { styling } from '@/Theme/Styles/GlobalStyles';
import { COLORS } from '@/Theme/Colors';
import { scaling } from '@/Utils/scaling';
import { OtpType, ValidateOtpResponse } from '@/Types/CustomerService';
import { useOTP } from '@/Hooks/otp';

export type OtpModalProps = {
  otpType: OtpType;
  phoneNumber: string;
  loginId: string;
  isBeforeAuth: boolean;
  onSuccess: (response: ValidateOtpResponse) => void;
};

export const OTPModal = ({ route }: RootStackScreenProps<'OTPModal'>) => {
  const { otpType, phoneNumber, loginId, isBeforeAuth, onSuccess } = route.params;

  const { t } = useTranslation();
  const { otp, setOTP, handleContinue, resendCode, isTimerShowed, timeLeft } = useOTP({
    otpType,
    phoneNumber,
    loginId,
    onSuccess,
  });

  const renderBody = () => (
    <>
      <PinInput pin={otp} onPinChange={setOTP} autoFocus containerStyle={styles.container} />
      {otp.length === 4 ? (
        <Button title={t('globals.continue')} onPress={handleContinue} />
      ) : (
        <Spacer useScaling height={68} />
      )}

      {isTimerShowed ? (
        <Typography variant="regular" color={COLORS.NEUTRAL_500} style={styling.textCenter}>
          {`${timeLeft}s`}
        </Typography>
      ) : (
        <LinkQuestion
          textContent={t('otpVerification.linkQuestion.textContent')}
          linkText={t('otpVerification.linkQuestion.linkText')}
          onPress={resendCode}
        />
      )}
    </>
  );

  return isBeforeAuth ? (
    <AuthScreen
      header={{
        title: t('otpVerification.title'),
        text:
          otpType === OtpType.PASSWORD_RESET
            ? t('otpVerification.textResetPassword')
            : t('otpVerification.text', { phoneNumber }),
      }}
    >
      {renderBody()}
    </AuthScreen>
  ) : (
    <AppScreen
      header={{
        title: t('otpVerification.title'),
      }}
    >
      <Typography variant="regular" color={COLORS.NEUTRAL_500} style={styling.componentMargin}>
        {t('otpVerification.text', { phoneNumber })}
      </Typography>
      {renderBody()}
    </AppScreen>
  );
};

const styles = StyleSheet.create({
  container: {
    marginBottom: scaling.vs(42),
  },
});

The useOTP hook:

import { useEffect, useState } from 'react';
import { GLOBALS } from '@/Constants/Globals';
import { OtpType, ValidateOtpResponse } from '@/Types/CustomerService';
import { DEVICE } from '@/Constants/Device';
import { useAppSelector } from './redux';
import { getInstitutionCD } from '@/Store/Profile/selectors';
import { useNavigation } from '@react-navigation/native';
import {
  useInitiateOtpMutation,
  useResendRegOtpMutation,
  useValidateOtpMutation,
  useResetPasswordInitiateMutation,
} from '@/Services/modules/customer';

type OtpConfig = {
  otpType: OtpType;
  phoneNumber: string;
  loginId: string;
  onSuccess: (response: ValidateOtpResponse) => void;
};

export const useOTP = ({ otpType, phoneNumber, loginId, onSuccess }: OtpConfig) => {
  const institutionCD = useAppSelector(getInstitutionCD);

  const navigation = useNavigation();

  const [initiateOTP] = useInitiateOtpMutation();
  const [validateOtp] = useValidateOtpMutation();
  const [resendRegOtp] = useResendRegOtpMutation();
  const [resetPasswordInitiate] = useResetPasswordInitiateMutation();

  const [otp, setOTP] = useState('');
  const [timeLeft, setTimeLeft] = useState(GLOBALS.OTP_TIMEOUT);
  const [isTimerShowed, setIsTimerShowed] = useState(true);

  useEffect(() => {
    if (!timeLeft) {
      setIsTimerShowed(false);
      return;
    }
    const intervalId = setInterval(() => {
      setTimeLeft(timeLeft - 1);
    }, 1000);

    return () => {
      clearInterval(intervalId);
    };
  }, [timeLeft]);

  useEffect(() => {
    // In these 2 cases OTP is generated on the screen before OTP
    if (otpType !== OtpType.REGISTRATION && otpType !== OtpType.PASSWORD_RESET) {
      initiateOTP({
        deviceId: DEVICE.UUID,
        institutionCD,
        phoneNumber,
        otpType,
      });
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  const resendCode = async () => {
    setOTP('');

    switch (otpType) {
      case OtpType.REGISTRATION:
        await resendRegOtp({ deviceId: DEVICE.UUID, institutionCD });
        break;

      case OtpType.PASSWORD_RESET:
        await resetPasswordInitiate({ deviceId: DEVICE.UUID, institutionCD, userId: loginId });
        break;

      default:
        await initiateOTP({
          deviceId: DEVICE.UUID,
          institutionCD,
          phoneNumber,
          otpType,
        });
        break;
    }

    setTimeLeft(GLOBALS.OTP_TIMEOUT);
    setIsTimerShowed(true);
  };

  const handleContinue = () => {
    validateOtp({
      otpType,
      otp,
      loginId,
      deviceId: DEVICE.UUID,
      institutionCD,
    })
      .unwrap()
      .then((response) => {
        navigation.canGoBack() && navigation.goBack();
        onSuccess?.(response);
      })
      .catch((e) => {
        console.log(e); // >> 'FETCH_ERROR': 'typecannot read properties of null (reading 'url')'
      });
  };

  return { otp, setOTP, handleContinue, resendCode, isTimerShowed, timeLeft };
};

The endpoint:

validateOtp: build.mutation<Types.ValidateOtpResponse, Types.ValidateOtpData>({
      query: (body) => ({
        url: ENDPOINTS.VALIDATE_OTP,
        method: 'POST',
        body,
      }),
    }),

The server:

import { setupServer } from 'msw/node';
import { handlers } from './handlers';

export const server = setupServer(...handlers);

The handler:

import { ENDPOINTS } from '@/Constants/Endpoints';
import { rest } from 'msw';
import { API_URL } from 'react-native-dotenv';

export const handlers = [
  rest.post(`${API_URL}${ENDPOINTS.VALIDATE_OTP}`, (req, res, ctx) => {
    return res(
      ctx.status(200),
      ctx.json({
        code: 0,
        description: 'Operation successful',
        data: '3aLQE6r3UO4VXTJCU10=',
      })
    );
  }),
];

arthedza avatar Aug 03 '22 16:08 arthedza