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
相关产品推荐
相关产品推荐

