React Native Jest - 如何使用多个钩子测试功能组件?无法存根 AccessiblityInfo 模块

Posted

技术标签:

【中文标题】React Native Jest - 如何使用多个钩子测试功能组件?无法存根 AccessiblityInfo 模块【英文标题】:React Native Jest - How to test Functional component with multiple hooks? Unable to stub AccessiblityInfo module 【发布时间】:2022-01-16 01:37:15 【问题描述】:

我正在尝试为我最近编写的功能组件编写单元测试。该组件使用了多个钩子,包括useStateuseEffectuseSelector。我发现为所述组件编写测试非常困难,因为我已经读到更改状态而不是测试结果并不是一个好习惯。

现在我被困在编写非常简单的单元测试中,我似乎无法开始工作。我的第一个测试目标是存根 AccessibilityInfo isScreenReaderEnabled 以返回 true,以便我可以验证是否存在启用屏幕阅读器时应该出现的组件。我使用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,但这样做没有任何运气。我一直在阅读在线功能组件有点“黑匣子”并且存根功能似乎不可能,这是真的吗?我开始想知道,如果多个钩子以及它是一个功能性组件这一事实使得测试变得非常困难,我如何才能成功地测试我的组件。

非常感谢任何帮助。

【问题讨论】:

你的测试代码jest.fn() onRight=jest.fn()是什么?我猜是复制/粘贴错误,但只是想确定您是否在测试中尝试其他内容 很好,这是一个错字。应该是onLeft=jest.fn() 【参考方案1】:

这可能是因为在您检查组件是否存在之前,promise 没有解决。你可以在这里阅读更多关于它的信息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);
    );
  );
...

【讨论】:

这解决了我的问题!谢谢,这个问题困扰了我一个星期。

以上是关于React Native Jest - 如何使用多个钩子测试功能组件?无法存根 AccessiblityInfo 模块的主要内容,如果未能解决你的问题,请参考以下文章

如何使用 Jest 在 React Native 中测试警报

如何使用 Jest 在本机反应中模拟“react-native-config”库的配置数据?

React Native Expo App:如何让它运行 Jest 测试

如何在带有 Jest 的 react-native 中使用模拟的 fetch() 对 API 调用进行单元测试

React Native Jest - 如何使用多个钩子测试功能组件?无法存根 AccessiblityInfo 模块

意外的导入令牌 - 使用 Jest 测试 React Native