React Native Expo自定义Picker组件测试遇值为undefined问题排查
问题:React Native Expo测试自定义Picker组件时状态更新失败
测试文本输入组件正常,但自定义Select组件触发值变更后,组件值变为undefined,无法完成按钮启用的测试。
测试代码
import React from 'react'; import { render, fireEvent, act } from '@testing-library/react-native'; import BeneficiaryFormScreen from '../app/new-service/beneficiary'; import LoginScreen from '../app/login'; import '@react-navigation/native' import { renderRouter, screen } from 'expo-router/testing-library'; jest.mock('expo-router') function isDisabled(element: any): boolean { return !!element?.props.onStartShouldSetResponder?.testOnly_pressabilityConfig()?.disabled; } describe("Beneficiary form screen", () => { it("Should enable button upon filling service reason and beneficiary name fields", async () => { const { findByTestId } = render(<BeneficiaryFormScreen />) const submitButton = await findByTestId('submit-button') const serviceReason = await findByTestId("service-reason"); const beneficiaryName = await findByTestId("beneficiary-name"); // Simulate selecting an option await act(async () => { fireEvent(serviceReason, 'onValueChange', 'Instalada') fireEvent(beneficiaryName, 'onChangeText', 'Roberto Mello'); }) // Check if the button is disabled expect(isDisabled(submitButton)).toEqual(false); }) })
待测试组件
import { Alert, ScrollView, StyleSheet, Text, View } from "react-native"; import Button from "../../components/Button"; import Input from "../../components/Input"; import { useCallback, useMemo, useState } from "react"; import { useFocusEffect, useRouter } from "expo-router"; import { useServicesContext } from "../../contexts/ServiceContext"; import { Select } from "../../components/Select"; import { maintenanceReasonOptions, reasonsOptions, receptorSwapOptions } from "../../constants/options"; import { Textarea } from "../../components/Textarea"; import { useFormSchema } from "../../hooks/useFormSchemas"; export default function BeneficiaryFormScreen() { const router = useRouter() const { finishBeneficiaryForm, resetFormState, currentForm, currentService } = useServicesContext() const { beneficiarySpecs } = useFormSchema() const [name, setName] = useState<string>('') const [reason, setReason] = useState<any>(beneficiarySpecs.serviceReason.options[0]) const [observation, setObservation] = useState<string>('') const [receptorSwap, setReceptorSwap] = useState<any>(receptorSwapOptions[0]) const isReproved = useMemo(() => { return currentService?.remoteStatus === 'REPROVED' }, [currentService]) const disabled = useMemo(() => { if (isReproved) { return false } if (reason.value !== 'Instalada' || currentService?.type === 'Manutenção') { return !name || !observation } return !name }, [name, reason, observation]) const notInstalled = useMemo(() => { return reason.value !== 'Instalada' }, [reason]) const handleSubmit = async() => { if (notInstalled && !observation) { Alert.alert('Atenção', 'Preencha a observação para continuar.') return } if ( currentService?.type === 'Manutenção' && reason.value === 'Executado - Procedente' && receptorSwap === receptorSwapOptions[0] ) { Alert.alert('Atenção', 'Selecione uma opção referente à troca do receptor') return } if (currentForm?.beneficiary) { const form = JSON.parse(currentForm.beneficiary)?.data if (form.reason.value !== reason.value && !isReproved) { Alert.alert('Atenção.', 'Ao modificar o motivo do atendimento, você perderá os dados preenchidos anteriormente. Deseja continuar?', [ { text: 'Cancelar', style: 'cancel', }, { text: 'Continuar', onPress: async() => { try { await resetFormState() await finishBeneficiaryForm({ name, reason, observation, receptorSwap }) router.back() } catch (error) { Alert.alert('Erro', 'Ocorreu um erro ao tentar finalizar o formulário. Tente novamente.') } } } ]) return } } await finishBeneficiaryForm({ name, reason, observation, receptorSwap }) router.back() } useFocusEffect(useCallback(() => { if (currentForm?.beneficiary) { const data = JSON.parse(currentForm.beneficiary).data setName(data.name) setReason(data.reason) setObservation(data.observation) } }, [currentForm])) return ( <View style={styles.container}> <ScrollView style={{ flex: 1, backgroundColor: '#fff' }} > <View style={styles.itemsContainer}> <Text style={styles.title}> Preencha os dados do atendimento. </Text> <Text style={styles.subtitle}> Os campos com o status "Obrigatório" são necessários para concluir o cadastro. </Text> </View> <View style={{ paddingHorizontal: 16, paddingVertical: 32, backgroundColor: '#fff', flex: 1, height: '100%' }}> <Select options={beneficiarySpecs.serviceReason.options} selectedValue={reason} label="Motivo do atendimento" required={beneficiarySpecs.serviceReason.required} onValueChange={(item) => setReason(item)} testID="service-reason" /> {beneficiarySpecs.beneficiaryName.visible && ( <Input label="Nome do representante" placeholder="Nome do representante" required onChange={text => setName(text)} value={name} testID="beneficiary-name" /> )} <Textarea label="Observação" placeholder="Insira sua observação" optional={!notInstalled || currentService?.type !== 'Manutenção'} required={notInstalled || currentService?.type === 'Manutenção'} onChange={text => setObservation(text)} value={observation} /> {(currentService?.type === 'Manutenção' && reason.value === 'Executado - Procedente') && ( <Select options={receptorSwapOptions} selectedValue={receptorSwap} label="Troca do receptor" required onValueChange={(item) => setReceptorSwap(item)} /> )} </View> </ScrollView> <View style={styles.buttonsContainer}> <Button title="Finalizar formulário" onPress={handleSubmit} disabled={disabled} testID="submit-button" /> </View> </View> ) } const styles = StyleSheet.create({ container: { backgroundColor: '#f0f2f7', width: '100%', flex: 1, }, title: { fontSize: 20, color: "#101010", fontWeight: "500", marginBottom: 6, marginTop: 16, }, subtitle: { fontSize: 14, color: "#202020", fontWeight: "300", marginBottom: 32, }, itemsContainer: { width: '100%', paddingHorizontal: 16, paddingTop: 24, backgroundColor: '#f0f2f7', }, buttonsContainer: { backgroundColor: '#fff', paddingHorizontal: 16, paddingVertical: 10, borderTopColor: '#dedfe0', borderTopWidth: 2, position: 'fixed', bottom: 0, }, });
自定义Select组件
import { Picker as Element } from '@react-native-picker/picker'; import { StyleSheet, Text, View } from 'react-native'; export const Select = ({ options, selectedValue, onValueChange, style, label, required, optional, testID, ...props }: { options: any[], selectedValue: any, onValueChange: (value: any) => void, style?: any, label?: string, required?: boolean, optional?: boolean, testID?: string, }) => { return ( <View style={[styles.container, style]}> <View style={{ display: 'flex', flexDirection: 'row', backgroundColor: 'transparent' }}> <Text style={styles.label}>{label}</Text> {required && ( <Text style={{ color: 'red', marginLeft: 5, fontSize: 14 }}>*</Text> )} {optional && ( <Text style={{ color: '#404040', marginLeft: 5, fontSize: 13 }}>(opcional)</Text> )} </View> <View style={{ borderWidth: 1, borderColor: '#d5d5d5', borderRadius: 4, overflow: 'hidden', marginBottom: 16, }} > <Element selectedValue={selectedValue.value} onValueChange={(_, idx) => onValueChange(options[idx])} style={{ height: 48, width: '100%', backgroundColor: '#fff', paddingHorizontal: 16, paddingVertical: 8, }} testID={testID} {...props} > {options.map(item => ( <Element.Item style={{ color: '#202020', fontSize: 16, }} key={item.value} label={item.label} value={item.value} /> ))} </Element> </View> </View> ) } const styles = StyleSheet.create({ container: { width: '100%', marginBottom: 4, backgroundColor: 'transparent', }, label: { fontSize: 13, marginBottom: 5, color: '#030712', }, input: { backgroundColor: 'transparent', borderRadius: 5, borderWidth: 1, borderColor: '#ddd', paddingVertical: 10, paddingHorizontal: 16, fontSize: 16, } })
问题原因与修复方案
原因分析
- 事件参数类型不匹配:测试中触发
onValueChange时传入的是字符串'Instalada',但自定义Select组件的onValueChange期望接收完整的选项对象(通过options[idx]获取)。直接传字符串会导致reason状态被设置为字符串,后续访问reason.value会得到undefined,破坏disabled的计算逻辑。 - 上下文未模拟:测试未模拟
useServicesContext和useFormSchema的返回值,组件可能获取到undefined的配置,导致初始状态异常。
修复步骤
1. 模拟上下文钩子
在测试文件顶部添加上下文模拟,确保组件能获取到正确的初始配置:
// 模拟useFormSchema返回值 jest.mock('../../hooks/useFormSchemas', () => ({ useFormSchema: () => ({ beneficiarySpecs: { serviceReason: { options: [ { value: 'Outro', label: 'Outro' }, { value: 'Instalada', label: 'Instalada' } ], required: true }, beneficiaryName: { visible: true } } }) })); // 模拟useServicesContext返回值 jest.mock('../../contexts/ServiceContext', () => ({ useServicesContext: () => ({ finishBeneficiaryForm: jest.fn(), resetFormState: jest.fn(), currentForm: null, currentService: null }) }));
2. 修正事件触发逻辑
触发onValueChange时传入完整的选项对象,而非单个字符串:
describe("Beneficiary form screen", () => { it("Should enable button upon filling service reason and beneficiary name fields", async () => { const { findByTestId } = render(<BeneficiaryFormScreen />) const submitButton = await findByTestId('submit-button') const serviceReason = await findByTestId("service-reason"); const beneficiaryName = await findByTestId("beneficiary-name"); // 使用完整的选项对象触发事件 const targetOption = { value: 'Instalada', label: 'Instalada' }; await act(async () => { fireEvent(serviceReason, 'onValueChange', targetOption); fireEvent(beneficiaryName, 'onChangeText', 'Roberto Mello'); }) expect(isDisabled(submitButton)).toEqual(false); }) })
验证修复
修改后,reason状态会被设置为完整的选项对象,reason.value能正确获取到'Instalada',disabled的计算逻辑正常执行,按钮会被正确启用,测试即可通过。
内容的提问来源于stack exchange,提问作者Roberto
相关产品推荐
相关产品推荐

