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 【问题描述】:我正在尝试为我最近编写的功能组件编写单元测试。该组件使用了多个钩子,包括useState
、useEffect
和useSelector
。我发现为所述组件编写测试非常困难,因为我已经读到更改状态而不是测试结果并不是一个好习惯。
现在我被困在编写非常简单的单元测试中,我似乎无法开始工作。我的第一个测试目标是存根 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 调用进行单元测试