You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何用fireGestureHandler测试React Native评分组件的平移手势?

可滑动评分组件的手势测试问题及解决方法

问题背景

我开发了一个支持滑动选择的星级评分组件,核心逻辑依赖react-native-gesture-handler的Pan手势实现滑动评分。但在测试时,使用fireGestureHandler无法触发组件的平移手势逻辑,导致手势相关代码覆盖率低。

组件核心代码

import FontistiIcon from '@expo/vector-icons/Fontisto';
import React, { useCallback, useMemo } from 'react';
import { LayoutRectangle, View } from 'react-native';
import {
  Gesture,
  GestureDetector,
  TouchableOpacity,
} from 'react-native-gesture-handler';

import { useTheme } from '@theme';
import { roundToStep } from '@utils';

import { useStyle } from './numerical-rating.styles';
import { RatingProps } from './numerical-rating.type';

const NumericalRating: React.FC<RatingProps> = props => {
  const {
    scale = 5,
    testID = 'TestID__component-NumericalRating',
    starTestID = 'TestID__component-NumericalRating-star',
    isFractional = false,
    onChange,
    value,
  } = props;
  const styles = useStyle();
  const theme = useTheme();
  const [ratingContainerLayout, setRatingContainerLayout] =
    React.useState<LayoutRectangle | null>(null);

  const scaleRounded = Math.round(scale);
  const scaleList = Array.from(Array(scaleRounded).keys(), x => x + 1);

  const fractionalScaleList = scaleList.reduce<number[][]>(
    (acc, curr) => [...acc, [curr - 0.5, curr]],
    [],
  );

  const iconSize = theme.t.moderateScale(58);
  const iconWidth = Math.floor(iconSize / 2);

  const selectedRating = Math.max(0, Math.min(value, scale));
  const [draggedRating, setDraggedRating] = React.useState(selectedRating);

  const onRatingTap = (rating: number) => () => {
    onChange(rating);
    setDraggedRating(rating);
  };

  const getRatingByPosition = useCallback(
    (position: number) => {
      if (ratingContainerLayout?.width) {
        const ratingIconSize = ratingContainerLayout.width / scaleRounded;
        const calculatedRatingValue = position / ratingIconSize;
        return roundToStep(calculatedRatingValue, isFractional ? 0.5 : 1);
      }
      return 0;
    },
    [isFractional, ratingContainerLayout, scaleRounded],
  );

  const gesture = useMemo(
    () =>
      Gesture.Pan()
        .runOnJS(true)
        .onUpdate(e => {
          setDraggedRating(getRatingByPosition(e.x));
        })
        .onEnd(e => {
          onChange(getRatingByPosition(e.x));
        }),
    [getRatingByPosition, onChange],
  );

  const renderScale = () =>
    scaleList.map(rating => (
      <TouchableOpacity
        key={rating}
        onPress={onRatingTap(rating)}
        testID={starTestID}
        accessibilityState={{
          selected: draggedRating >= rating,
        }}
      >
        <View style={styles.scale}>
          <FontistiIcon
            name="star"
            size={iconSize}
            color={
              draggedRating >= rating
                ? theme.t.palette.accents.color2
                : theme.t.palette.accents.color4
            }
          />
        </View>
      </TouchableOpacity>
    ));

  const renderFractionalScale = () =>
    fractionalScaleList.map((ratingPair: number[], index) => (
      <View style={[styles.ratingPair, styles.scale]} key={index}>
        {ratingPair.map((rating: number) => (
          <View key={rating} style={rating % 1 === 0 ? styles.rightHalf : {}}>
            <TouchableOpacity
              onPress={onRatingTap(rating)}
              testID={starTestID}
              accessibilityState={{
                selected: draggedRating >= rating,
              }}
            >
              <FontistiIcon
                name="star-half"
                size={iconSize}
                style={{
                  width: iconWidth,
                }}
                color={
                  draggedRating >= rating
                    ? theme.t.palette.accents.color2
                    : theme.t.palette.accents.color4
                }
              />
            </TouchableOpacity>
          </View>
        ))}
      </View>
    ));

  const renderRating = () => {
    if (isFractional) {
      return renderFractionalScale();
    }
    return renderScale();
  };

  return (
    <GestureDetector gesture={gesture}>
      <View>
        <View
          style={styles.container}
          testID={testID}
          onLayout={e => setRatingContainerLayout(e.nativeEvent.layout)}
        >
          {renderRating()}
        </View>
      </View>
    </GestureDetector>
  );
};

export default NumericalRating;

测试代码(无法触发手势)

it('should select the correct star on swipe', () => {
  const onChange = jest.fn();
  render(<NumericalRating value={2} onChange={onChange} />);

  const starContainer = screen.getByTestId(
    'TestID__component-NumericalRating',
  );

  fireGestureHandler<PanGesture>(starContainer, [
    { x: 5, y: 15 },
    { x: 6, y: 16 },
    { x: 7, y: 17 },
  ]);

  expect(onChange).toHaveBeenCalledWith(1);
});

解决方法

1. 模拟容器布局宽度

组件中getRatingByPosition依赖ratingContainerLayout的宽度计算评分,测试时容器的onLayout事件不会自动触发,导致ratingContainerLayout为null,直接返回0。需要手动模拟布局信息:

// 触发onLayout事件,设置容器宽度(对应5个星星的总宽度)
fireEvent(starContainer, 'layout', {
  nativeEvent: {
    layout: { width: 290, height: 58, x: 0, y: 0 }
  }
});

2. 正确构造手势事件参数

fireGestureHandler需要指定手势的阶段(BEGIN/UPDATE/END),并且事件参数需要符合Pan手势的结构。修改测试代码如下:

import { PanGesture } from 'react-native-gesture-handler';

it('should select the correct star on swipe', () => {
  const onChange = jest.fn();
  render(<NumericalRating value={2} onChange={onChange} />);

  const starContainer = screen.getByTestId('TestID__component-NumericalRating');
  
  // 先触发布局事件,设置容器宽度
  fireEvent(starContainer, 'layout', {
    nativeEvent: { layout: { width: 290, height: 58, x: 0, y: 0 } }
  });

  // 构造完整的手势事件流程:BEGIN -> UPDATE -> END
  fireGestureHandler<PanGesture>(starContainer, [
    { state: 'BEGAN', x: 0, y: 0 },
    { state: 'ACTIVE', x: 58, y: 0 }, // 滑动到第一个星星的位置(290/5=58)
    { state: 'END', x: 58, y: 0 }
  ]);

  expect(onChange).toHaveBeenCalledWith(1);
});

3. 配置GestureHandler测试环境

在测试文件顶部添加以下配置,确保react-native-gesture-handler的测试工具正常工作:

import 'react-native-gesture-handler/jestSetup';

jest.mock('react-native-gesture-handler', () => {
  const ActualGestureHandler = jest.requireActual('react-native-gesture-handler');
  return {
    ...ActualGestureHandler,
    GestureDetector: jest.fn(({ children }) => children),
    Gesture: {
      Pan: jest.fn(() => ({
        runOnJS: jest.fn().mockReturnThis(),
        onUpdate: jest.fn().mockReturnThis(),
        onEnd: jest.fn().mockReturnThis(),
      })),
    },
  };
});

4. 优化组件容错处理

为避免ratingContainerLayout为null时逻辑失效,可添加默认布局值:

// 在组件中初始化默认布局
const [ratingContainerLayout, setRatingContainerLayout] = React.useState<LayoutRectangle>({
  width: theme.t.moderateScale(58) * 5,
  height: theme.t.moderateScale(58),
  x: 0,
  y: 0
});

提升覆盖率的额外技巧

  • 单独测试getRatingByPosition函数:传入不同的position和width参数,验证返回评分的正确性
  • 分别测试手势的onUpdate和onEnd阶段逻辑
  • 测试 fractional 和 non-fractional 两种模式下的滑动行为

内容的提问来源于stack exchange,提问作者shet_tayyy

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.09 23:31:12