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

React Testing Library/Jest:渲染父组件时子组件测试失败问题

测试子组件点击与父组件状态交互失败的问题解决建议

我在测试子组件点击事件与父组件状态更新的交互逻辑:点击子组件中类名为open-comparison-btn的元素后,子组件调用props.setModalShowing(true)更新父组件的modalShowing状态,触发父组件渲染条件渲染的comparison-modal模态框。单独测试父、子组件时都能正常找到预期DOM元素,但通过渲染父组件测试子组件点击功能时失败,测试中甚至找不到子组件的HTML结构,可该功能在本地浏览器中运行正常。

预期行为

  • 用户点击类名为open-comparison-btn的元素
  • 子组件调用props.setModalShowing并传入true
  • 父组件modalShowing状态更新为true
  • 父组件重新渲染并显示类名为comparison-modal的条件JSX

父组件代码

import React, { useState, useEffect } from 'react';
import ProductCard from './ProductCard.jsx';

const RelatedProducts = (props) => {
  const [position, setPosition] = useState(0);
  const componentName = 'RelatedProducts';
  const [modalShowing, setModalShowing] = useState(false);
  const [currentProduct, setCurrentProduct] = useState({});
  const [comparisonProduct, setComparisonProduct] = useState({});

  useEffect(() => {
    setCurrentProduct(props.currentProduct);
  }, [props.currentProduct]);

  const getFeatures = () => {
    return [...currentProduct.features, ...comparisonProduct.features]
      .filter((v, i, a)=>a.findIndex(v2=>(v.feature === v2.feature && v.value === v2.value)) === i);
  };

  return (
    <>
      <div className='related-products-container' role="listbox" aria-label="related products" style={{marginLeft: `-${position}px`}}>
        {props.relatedProducts ?
          props.relatedProducts.map((product) => {
            return <ProductCard
              modalShowing={modalShowing}
              setModalShowing={setModalShowing}
              setComparisonProduct={setComparisonProduct}
              key={product.id}
              product={product}
              generateStars={props.generateStars}
              isFetching={props.isFetching}
              setIsFetching={props.setIsFetching}
              parentComponent={componentName}
              yourOutfit={props.yourOutfit}
              addToOutfit={props.addToOutfit}
            />;
          })
          : null
        }

      </div>
      <div className='fade-top'>
        { position > 0 ?
          <div className="arrow-container-left" role="button" aria-label="scroll left" onClick={() => { setPosition(position - 250); }}>
            <div className="arrow-left"></div>
          </div>
          : null
        }
        { props && props.relatedProducts && position <= (props.relatedProducts.length - 4) * 250 ?
          <div className="arrow-container-right" role="button" aria-label="scroll right" onClick={() => { setPosition(position + 250); }}>
            <div className="arrow-right"></div>
          </div>
          : null
        }
      </div>
      {modalShowing ?
        <div className='comparison-modal' role='dialog' aria-label='comparison window'>
          <div className='modal-top'>COMPARING</div>
          <div className='modal-product-names'>
            <div className='product-1'>{currentProduct.name}</div>
            <div className='product-2'>{comparisonProduct.name}</div>
          </div>
          <table className='modal-table'>
            <tbody>
              {getFeatures().map((feature, index) => {
                return (
                  <tr key={`${feature}-${index}`}>
                    <td className='left-check'>{currentProduct.features.filter(item => item.feature === feature.feature && item.value === feature.value).length > 0 ? '✓' : null}</td>
                    <td>{feature.value} {feature.feature}</td>
                    <td className='right-check'>{comparisonProduct.features.filter(item => item.feature === feature.feature && item.value === feature.value).length > 0 ? '✓' : null}</td>
                  </tr>
                );
              })}
            </tbody>
          </table>
          <div className="close-btn" onClick={() => { setModalShowing(false); }}></div>
        </div>
        : null}
    </>
  );
};

export default RelatedProducts;

子组件代码

import React, { useState, useEffect } from 'react';
import ratingsAPI from '../../API/Ratings.js';
import { useNavigate } from 'react-router-dom';

const ProductCard = (props) => {
  const navigate = useNavigate();
  const [averageRating, setAverageRating] = useState();
  const stars = props.generateStars(averageRating, 'related');

  useEffect(() => {
    ratingsAPI.getReviewMetadata(props.product.id)
      .then((metadata) => {
        setAverageRating(getAverageRating(metadata.ratings));
        props.setIsFetching(false);
      });
  }, []);

  const routeChange = () => {
    const path = `/${props.product.id.toString()}`;
    navigate(path);
  };

  const displayComparison = (e) => {
    props.setComparisonProduct(props.product);
    props.setModalShowing(true);
    e.stopPropagation();
  };

  const getAverageRating = (ratings) => {
    var sum = 0;
    var count = 0;
    Object.keys(ratings).forEach(function(rating) {
      sum += rating * parseInt(ratings[rating]);
      count += parseInt(ratings[rating]);
    });
    return sum / count;
  };

  return (
    !props.isFetching ?
      <>
        <div className='product-card-container' onClick={() => routeChange(props.product.id)}>
          <img className='product-card-image' src={props.product.styles.results[0].photos[0].thumbnail_url}>
          </img>
          {props.parentComponent === 'RelatedProducts'
            ?
            <svg className="open-comparison-btn" role='button' aria-label='open comparison' width="20px" height="20px" viewBox="0 0 32 32" onClick={(e) => { displayComparison(e); }}>
              <path fill="White" stroke="black" strokeWidth="2px" d="M20.388,10.918L32,12.118l-8.735,7.749L25.914,31.4l-9.893-6.088L6.127,31.4l2.695-11.533L0,12.118
            l11.547-1.2L16.026,0.6L20.388,10.918z"/>
            </svg>
            :
            <div className="close-btn" onClick={() => { props.removeFromOutfit(props.product); }}></div>
          }

          <div className='product-card-description'>
            <div className='product-card-category'>{props.product.category}</div>
            <div className='product-card-name'>{props.product.name}</div>
            <div className='product-card-price'>${props.product.default_price}</div>
            <div className='product-card-stars'>{ stars }</div>
          </div>
        </div>
      </>
      : null
  );
};

export default ProductCard;

测试代码

it('tests that clicking the open-comparison-btn opens the modal window', async () => {
  render(<RelatedProducts
    addToOutfit={() => { return; }}
    yourOutfit={() => { return; }}
    relatedProducts={relatedProducts}
    generateStars={ generateStars }
    isFetching={() => { return false; }}
    setIsFetching={() => { return; }}
  />, {wrapper: Router});
  fireEvent(
    screen.getByRole('button', {name: 'open comparison'}),
    new MouseEvent('click', {
      bubbles: true,
      cancelable: true,
    }),
  );
  const modal = screen.getByRole('dialog', {name: 'comparison window'});
  expect(modal).toBeInTheDocument();
});

问题排查与解决建议

1. 修正isFetching属性传参

测试代码中isFetching={() => { return false; }}传入的是函数,但子组件判断的是!props.isFetching(期望布尔值),导致子组件因条件不满足返回null,无法渲染。直接传入布尔值:

isFetching={false}

2. 补充currentProduct属性

父组件依赖props.currentProduct初始化currentProduct状态,测试中未传入该属性会导致模态框渲染时currentProduct为空对象,触发currentProduct.features读取错误。需传入完整的测试数据:

currentProduct={{
  id: 1,
  name: "测试产品",
  features: [{feature: "材质", value: "棉"}]
  // 补充其他必要字段
}}

3. Mock异步API请求

子组件useEffect中调用了ratingsAPI.getReviewMetadata,未mock会导致请求挂起,setIsFetching(false)无法执行,影响子组件渲染。添加mock并等待异步操作完成:

// 测试文件顶部添加API mock
jest.mock('../../API/Ratings.js', () => ({
  getReviewMetadata: jest.fn().mockResolvedValue({
    ratings: {"5": "10", "4": "5"}
  })
}));

// 修改测试逻辑,等待异步渲染完成
it('tests that clicking the open-comparison-btn opens the modal window', async () => {
  render(<RelatedProducts
    addToOutfit={() => {}}
    yourOutfit={() => {}}
    relatedProducts={relatedProducts}
    generateStars={generateStars}
    isFetching={false}
    setIsFetching={() => {}}
    currentProduct={{
      id: 1,
      name: "当前产品",
      features: [{feature: "材质", value: "棉"}]
    }}
  />, {wrapper: Router});

  // 等待子组件异步请求完成并渲染
  await waitFor(() => screen.getByRole('button', {name: 'open comparison'}));

  fireEvent.click(screen.getByRole('button', {name: 'open comparison'}));

  // 等待模态框渲染完成
  await waitFor(() => {
    const modal = screen.getByRole('dialog', {name: 'comparison window'});
    expect(modal).toBeInTheDocument();
  });
});

4. 确保relatedProducts数据完整性

测试用的relatedProducts每个产品需包含styles.results[0].photos[0].thumbnail_url字段,避免子组件img标签报错导致渲染失败。

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.08.08 03:55:13