Home > Back-end >  React Native Jest - How to test Functional component with multiple hooks? Unable to stub Accessiblit
React Native Jest - How to test Functional component with multiple hooks? Unable to stub Accessiblit

Time:12-20

I'm trying to write unit tests for a functional component I've recently written. This component makes use of multiple hooks, including, useState, useEffect and useSelector. I'm finding it very difficult to write tests for said component since I've read that it's not good practice to alter the state but only test for outcomes.

Right now I'm stuck writing pretty simple unit tests that I just can't seem to get working. My goal for the first test is to stub AccessibilityInfo isScreenReaderEnabled to return true so that I can verify the existence of a component that should appear when we have screen reader enabled. I'm using sinon to stub AccessibilityInfo but when I mount my component the child component I'm looking for doesn't exist and the test fails. I don't understand why it's failing because I thought I had stubbed everything properly, but it looks like I'm doing something wrong.

I'll add both my component and test files below. Both have been stripped down to the most relevant code.

Home-Area Component:

const MAP_MARKER_LIMIT = 3;
const MAP_DELTA = 0.002;
const ACCESSIBILITY_MAP_DELTA = 0.0002;

type HomeAreaProps = {
  onDismiss: () => void;
  onBack: () => void;
  onCompleted: (region: Region) => void;
  getHomeFence: (deviceId: string) => void;
  setHomeFence: (deviceId: string, location: LatLng) => void;
  initialRegion: LatLng | undefined;
  deviceId: string;
};

const HomeArea = (props: HomeAreaProps) => {
  // reference to map view
  const mapRef = useRef<MapView | null>(null);

  // current app state
  let previousAppState = useRef(RNAppState.currentState).current;

  const initialRegion = {
    latitude: parseFloat((props.initialRegion?.latitude ?? 0).toFixed(6)),
    longitude: parseFloat((props.initialRegion?.longitude ?? 0).toFixed(6)),
    latitudeDelta: MAP_DELTA,
    longitudeDelta: MAP_DELTA,
  };

  // modified region of senior
  const [region, setRegion] = useState(initialRegion);

  // is accessibility screen reader enabled
  const [isScreenReaderEnabled, setIsScreenReaderEnabled] = useState(false);

  // state for floating modal
  const [showFloatingModal, setShowFloatingModal] = useState(false);

  // state for center the zone alert screen
  const [showAlertScreen, setShowAlertScreen] = useState(false);

  // state for center the zone error screen
  const [showErrorScreen, setShowErrorScreen] = useState(false);

  // To query error status after a request is made, default to false incase 
  // error cannot be queried from store
  const requestError = useSelector<AppState, boolean>((state) => {
    if (state.homeFence[props.deviceId]) {
      return state.homeZoneFence[props.deviceId].error;
    } else {
      return false;
    }
  });

  // To access device data from redux store, same as above if device data 
  // can't be queried then set to null
  const deviceData = useSelector<AppState, HomeDeviceData | null | undefined>(
    (state) => {
      if (state.homeFence[props.deviceId]) {
        return state.homeFence[props.deviceId].deviceData;
      } else {
        return null;
      }
    }
  );
  const [initialHomeData] = useState<HomeDeviceData | null | undefined>(
    deviceData
  );

  // didTap on [x] button
  const onDismiss = () => {
    setShowFloatingModal(true);
  };

  // didTap on 'save' button
  const onSave = () => {
    if (
      didHomeLocationMovePastLimit(
        region.latitude,
        region.longitude,
        MAP_MARKER_LIMIT
      )
    ) {
      setShowAlertScreen(true);
    } else {
      updateHomeFence();
    }
  };

  const onDismissFloatingModal = () => {
    setShowFloatingModal(false);
    props.getHomeFence(props.deviceId);
    props.onDismiss();
  };

  const onSaveFloatingModal = () => {
    setShowFloatingModal(false);

    if (
      didHomeLocationMovePastLimit(
        region.latitude,
        region.longitude,
        MAP_MARKER_LIMIT
      )
    ) {
      setShowFloatingModal(false);
      setShowAlertScreen(true);
    } else {
      updateHomeFence();
    }
  };

  const onDismissModal = () => {
    setShowFloatingModal(false);
  };

  // Center the Zone Alert Screen
  const onBackAlert = () => {
    // Go back to center the zone screen
    setShowAlertScreen(false);
  };

  const onNextAlert = () => {
    updateHomeFence();
    setShowAlertScreen(false);
  };

  // Center the Zone Error Screen
  const onBackError = () => {
    setShowErrorScreen(false);
  };

  const onNextError = () => {
    updateHomeFence();
  };

  const didHomeLocationMovePastLimit = (
    lat: number,
    lon: number,
    limit: number
  ) => {
    if (
      lat !== undefined &&
      lat !== null &&
      lon !== undefined &&
      lon !== null
    ) {
      const haversineDistance = haversineFormula(
        lat,
        lon,
        initialRegion.latitude,
        initialRegion.longitude,
        "M"
      );
      return haversineDistance > limit;
    }
    return false;
  };

  // didTap on 'reset' button
  const onReset = () => {
    // animate to initial region
    if (initialRegion && mapRef) {
      mapRef.current?.animateToRegion(initialRegion, 1000);
    }
  };

  // did update region by manually moving map
  const onRegionChange = (region: Region) => {
    setRegion({
      ...initialRegion,
      latitude: parseFloat(region.latitude.toFixed(6)),
      longitude: parseFloat(region.longitude.toFixed(6)),
    });
  };

  // didTap 'left' map control
  const onLeft = () => {
    let adjustedRegion: Region = {
      ...region,
      longitude: region.longitude - ACCESSIBILITY_MAP_DELTA,
    };
    // animate to adjusted region
    if (mapRef) {
      mapRef.current?.animateToRegion(adjustedRegion, 1000);
    }
  };

  // didTap 'right' map control
  const onRight = () => {
    let adjustedRegion: Region = {
      ...region,
      longitude: region.longitude   ACCESSIBILITY_MAP_DELTA,
    };
    // animate to adjusted region
    if (mapRef) {
      mapRef.current?.animateToRegion(adjustedRegion, 1000);
    }
  };

  // didTap 'up' map control
  const onUp = () => {
    let adjustedRegion: Region = {
      ...region,
      latitude: region.latitude   ACCESSIBILITY_MAP_DELTA,
    };
    // animate to adjusted region
    if (mapRef) {
      mapRef.current?.animateToRegion(adjustedRegion, 1000);
    }
  };

  // didTap 'down' map control
  const onDown = () => {
    let adjustedRegion: Region = {
      ...region,
      latitude: region.latitude - ACCESSIBILITY_MAP_DELTA,
    };
    // animate to adjusted region
    if (mapRef) {
      mapRef.current?.animateToRegion(adjustedRegion, 1000);
    }
  };

  const updateHomeFence = () => {
    const lat = region.latitude;
    const lon = region.longitude;

    const location: LatLng = {
      latitude: lat,
      longitude: lon,
    };
    props.setHomeFence(props.deviceId, location);
  };

  // gets accessibility status info
  const getAccessibilityStatus = () => {
    AccessibilityInfo.isScreenReaderEnabled()
      .then((isEnabled) => setIsScreenReaderEnabled(isEnabled))
      .catch((error) => console.log(error));
  };

  // listener for when the app changes app state
  const onAppStateChange = (nextAppState: AppStateStatus) => {
    if (nextAppState === "active" && previousAppState === "background") {
      // when we come to the foreground from the background we should 
      // check the accessibility status again
      getAccessibilityStatus();
    }
    previousAppState = nextAppState;
  };

  useEffect(() => {
    getAccessibilityStatus();

    RNAppState.addEventListener("change", onAppStateChange);

    return () => RNAppState.removeEventListener("change", onAppStateChange);
  }, []);

  useEffect(() => {
    // exit screen if real update has occurred, i.e. data changed on backend
    // AND if there is no request error
    if (initialHomeData !== deviceData && initialHomeData && deviceData) {
      if (!requestError) {
        props.onCompleted(region);
      }
    }
    setShowErrorScreen(requestError);
  }, [requestError, deviceData]);

  return (
    <DualPane>
      <TopPane>
        <View style={styles.mapContainer}>
          <MapView
            accessible={false}
            importantForAccessibility={"no-hide-descendants"}
            style={styles.mapView}
            provider={PROVIDER_GOOGLE}
            showsUserLocation={false}
            zoomControlEnabled={!isScreenReaderEnabled}
            pitchEnabled={false}
            zoomEnabled={!isScreenReaderEnabled}
            scrollEnabled={!isScreenReaderEnabled}
            rotateEnabled={!isScreenReaderEnabled}
            showsPointsOfInterest={false}
            initialRegion={initialRegion}
            ref={mapRef}
            onRegionChange={onRegionChange}
          />
          <ScrollingHand />
          {isScreenReaderEnabled && (
            <MapControls
              onLeft={onLeft}
              onRight={onRight}
              onUp={onUp}
              onDown={onDown}
            />
          )}
          {region && <PulsingMarker />}
          {JSON.stringify(region) !== JSON.stringify(initialRegion) && (
            <Button
              style={[btn, overrideButtonStyle]}
              label={i18n.t("homeZone.homeZoneArea.buttonTitle.reset")}
              icon={reset}
              onTap={onReset}
              accessibilityLabel={i18n.t(
                "homeZone.homeZoneArea.buttonTitle.reset"
              )}
            />
          )}
        </View>
      </TopPane>
      <OneButtonBottomPane
        onPress={onSave}
        buttonLabel={i18n.t("homeZone.homeZoneArea.buttonTitle.save")}
      >
        <View style={styles.bottomPaneContainer}>
          <BottomPaneText
            title={i18n.t("homeZone.homeZoneArea.title")}
            content={i18n.t("homeZone.homeZoneArea.description")}
          />
        </View>
      </OneButtonBottomPane>
      <TouchableOpacity
        style={styles.closeIconContainer}
        onPress={onDismiss}
        accessibilityLabel={i18n.t("homeZone.homeZoneArea.buttonTitle.close")}
        accessibilityRole={"button"}
      >
        <Image
          style={styles.cancelIcon}
          source={require("../../../assets/home-zone/close.png")}
        />
      </TouchableOpacity>
      <HomeFloatingModal
        showFloatingModal={showFloatingModal}
        onDismiss={onDismissModal}
        onDiscard={onDismissFloatingModal}
        onSave={onSaveFloatingModal}
      />
      <HomeAlert
        isVisible={showAlertScreen}
        modalTitle={i18n.t("home.feedbackCenter.title.confirmZoneCenter")}
        modalDescription={i18n.t(
          "home.feedbackCenter.description.confirmZoneCenter"
        )}
        onBackButtonTitle={i18n.t("home.feedback.buttonTitle.back")}
        onNextButtonTitle={i18n.t("home.feedback.buttonTitle.okay")}
        onBack={onBackAlert}
        onNext={onNextAlert}
      />
      <HomeAlert
        isVisible={showErrorScreen}
        sentimentType={SentimentType.alert}
        showWarningIcon={false}
        modalTitle={i18n.t("home.errorScreen.title")}
        modalDescription={i18n.t("home.errorScreen.description")}
        onBackButtonTitle={i18n.t("home.errorScreen.buttonTitle.cancel")}
        onNextButtonTitle={i18n.t("home.errorScreen.buttonTitle.tryAgain")}
        onBack={onBackError}
        onNext={onNextError}
      />
    </DualPane>
  );
};

export default HomeArea;

Home-Area-Tests:

import "jsdom-global/register";
import React from "react";
import { AccessibilityInfo } from "react-native";
import HomeArea from "../../../src/home/components/home-area";
import HomeAlert from "../../../src/home/components/home-alert";
import MapControls from "../../../src/home/components/map-controls";
import { mount } from "enzyme";
import { Provider } from "react-redux";
import configureStore from "redux-mock-store";
import sinon from "sinon";

jest.useFakeTimers();

const mockStore = configureStore();
const initialState = {
  homeFence: {
    "c9035f03-b562-4670-86c6-748b56f02aef": {
      deviceData: {
        eTag: "964665368A4BD68CF86B525385BA507A3D7F5335",
        fences: [
          {
            pointsOfInterest: [
              {
                latitude: 32.8463898,
                longitude: -117.2776381,
                radius: 100,
                uncertainty: 0,
                poiSource: 2,
              },
            ],
            id: "5e1e0bc0-880d-4b0c-a0fa-268975f3046b",
            timeZoneId: "America/Los_Angeles",
            type: 7,
            name: "Children's Pool",
          },
          {
            pointsOfInterest: [
              {
                latitude: 32.9148887,
                longitude: -117.228307,
                radius: 100,
                uncertainty: 0,
                poiSource: 2,
              },
            ],
            id: "782d8fcd-242d-47c0-872b-f669e7ca81c7",
            timeZoneId: "America/Los_Angeles",
            type: 1,
            name: "Home",
          },
        ],
      },
      error: false,
    },
  },
};
const initialStateWithError = {
  homeFence: {
    "c9035f03-b562-4670-86c6-748b56f02aef": {
      deviceData: {
        eTag: "964665368A4BD68CF86B525385BA507A3D7F5335",
        fences: [],
      },
      error: true,
    },
  },
};
const store = mockStore(initialState);

const props = {
  onDismiss: jest.fn(),
  onBack: jest.fn(),
  onCompleted: jest.fn(),
  getHomeZoneFence: jest.fn(),
  setHomeZoneFence: jest.fn(),
  initialRegion: { latitude: 47.6299, longitude: -122.3537 },
  deviceId: "c9035f03-b562-4670-86c6-748b56f02aef",
};

// https://github.com/react-native-maps/react-native-maps/issues/2918#issuecomment-510795210
jest.mock("react-native-maps", () => {
  const { View } = require("react-native");
  const MockMapView = (props: any) => {
    return <View>{props.children}</View>;
  };
  const MockMarker = (props: any) => {
    return <View>{props.children}</View>;
  };
  return {
    __esModule: true,
    default: MockMapView,
    Marker: MockMarker,
  };
});

describe("<HomeArea />", () => {
  describe("accessibility", () => {
    it("should return true and we should have map control present", async () => {
      sinon.stub(AccessibilityInfo, "isScreenReaderEnabled").callsFake(() => {
        return new Promise((res, _) => {
          res(true);
        });
      });
      const wrapper = mount(
        <Provider store={store}>
          <HomeArea {...props} />
        </Provider>
      );
      expect(wrapper).not.toBeUndefined(){jest.fn()} onRight={jest.fn()} onUp={jest.fn()} onDown={jest.fn()} />).instance()).not.toBeUndefined();

      expect(wrapper.find(MapControls).length).toEqual(1);
    });
  });

  describe("requestError modal", () => {
    it("should render requestErrorModal", async () => {
      const store = mockStore(initialStateWithError);
      const wrapper = mount(
        <Provider store={store}>
          <HomeArea {...props} />
        </Provider>
      );
      expect(wrapper).not.toBeUndefined();

      expect(
        wrapper.contains(
          <HomeAlert
            isVisible={false}
            modalTitle={""}
            modalDescription={""}
            onBackButtonTitle={""}
            onNextButtonTitle={""}
            onBack={jest.fn()}
            onNext={jest.fn()}
          />
        )
      ).toBe(true);
    });
  });
});

One thought I had was to stub getAccessibilityStatus in my component but haven't had any luck doing so. I've been reading online functional components are a bit of a "black box" and stubbing functions doesn't seem possible, is this true? I'm starting to wonder how I can successfully test my component if the multiple hooks and the fact that it's a functional component make it very difficult to do so.

Any help is greatly appreciated.

CodePudding user response:

It probably is because the promise is not resolving before you check that the component exists. You can read more about it here https://www.benmvp.com/blog/asynchronous-testing-with-enzyme-react-jest/

try it like this

const runAllPromises = () => new Promise(setImmediate)
...
  describe("accessibility", () => {
    it("should return true and we should have map control present", async () => {
      sinon.stub(AccessibilityInfo, "isScreenReaderEnabled").callsFake(() => {
        return new Promise((res, _) => {
          res(true);
        });
      });
      const wrapper = mount(
        <Provider store={store}>
          <HomeArea {...props} />
        </Provider>
      );
      await runAllPromises()
      // after waiting for all the promises to be exhausted
      // we can do our UI check
      component.update()
      expect(wrapper).not.toBeUndefined();
      expect(wrapper.find(MapControls).length).toEqual(1);
    });
  });
...
  • Related