React Native Jest - 如何测试具有多个挂钩的功能组件?无法存根 AccessiblityInfo 模块

React Native Jest - How to test Functional component with multiple hooks? Unable to stub AccessiblityInfo module

我正在尝试为我最近编写的功能组件编写单元测试。该组件使用多个挂钩,包括 useStateuseEffectuseSelector。我发现为所述组件编写测试非常困难,因为我读到改变状态但只测试结果不是好的做法。

现在我一直在编写非常简单的单元测试,但我似乎无法开始工作。我的第一个测试目标是将 AccessibilityInfo isScreenReaderEnabled 设为 return true,这样我就可以验证在启用屏幕 reader 时应该出现的组件是否存在。我正在使用 sinon 存根 AccessibilityInfo 但是当我安装我的组件时,我正在寻找的子组件不存在并且测试失败。我不明白为什么它会失败,因为我认为我已经正确地存根了所有内容,但看起来我做错了什么。

我将在下面添加我的组件和测试文件。两者都被精简为最相关的代码。

家庭区域组件:

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;

家庭区域测试:

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);
    });
  });
});

有人认为我曾经在我的组件中存根 getAccessibilityStatus 但没有任何运气这样做。我一直在阅读在线功能组件有点像“黑匣子”,存根功能似乎不可能,这是真的吗?我开始想知道如果多个挂钩和它是一个功能组件的事实使我很难成功地测试我的组件。

非常感谢任何帮助。

这可能是因为在您检查组件是否存在之前承诺没有解决。您可以在此处阅读更多相关信息 https://www.benmvp.com/blog/asynchronous-testing-with-enzyme-react-jest/

像这样尝试

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);
    });
  });
...