React Testing Library:如何Mock DOM Ref属性避免被覆盖?
我正在测试一个带有overflow:hidden容器及子元素的React组件,该组件通过Effect Hook计算可见元素数量。尝试Mock容器ref的clientWidth值来测试功能,但Mock的useRef虽被调用,首次渲染后Mock值就被覆盖,测试失败,错误信息如下:
Unable to find an element with the text: 3 elements are visible!. This could be because the text is broken up by multiple elements. In this case, you can provide a function for your text matcher to make your matcher more flexible.
Ignored nodes: comments, script, styleNaN elements are visible!
直接MockReact.useRef返回自定义对象的方式无效,因为React在组件渲染完成后,会将真实的DOM元素赋值给ref.current,覆盖预先Mock的对象。此时Effect中读取的是真实DOM的clientWidth(测试环境中通常为0或无法正确获取),导致计算出NaN。
方法一:Mock HTMLElement的clientWidth属性
由于clientWidth是DOM元素的只读属性,可通过重写HTMLElement.prototype.clientWidth的getter方法,让不同元素返回指定宽度值:
describe('OverflowingComponent', () => { it('显示3个可见元素(容器宽度150px)', () => { // 重写clientWidth的getter,区分容器和子元素 Object.defineProperty(HTMLElement.prototype, 'clientWidth', { get() { // 通过样式判断是否为容器元素 if (this.style.display === 'flex') { return 150; } // 子元素宽度固定为50px return 50; }, configurable: true, // 允许测试后清理Mock }); render(<OverflowingComponent />); expect(screen.getByText('3 elements are visible!')).toBeInTheDocument(); // 测试后清理Mock,避免影响其他用例 delete HTMLElement.prototype.clientWidth; }); });
方法二:重构组件,通过props传入宽度值(推荐)
修改组件,让容器宽度和子元素宽度可通过props传入,测试时无需Mock DOM,直接传参即可,大幅提升可测试性:
重构后的组件代码
function OverflowingComponent({ containerWidth = null, childWidth = 50 }: { containerWidth?: number | null; childWidth?: number; }) { const divRef = React.useRef<HTMLDivElement>(null); const [visibleChildren, setVisibleChildren] = React.useState(0); React.useEffect(() => { if (divRef.current) { // 优先使用props传入的宽度,否则读取DOM值 const width = containerWidth ?? divRef.current.clientWidth; const childElWidth = childWidth ?? divRef.current.children[0].clientWidth; if (width && childElWidth) { const maxVisibleChildren = Math.round(width / childElWidth); setVisibleChildren( divRef.current.children.length < maxVisibleChildren ? divRef.current.children.length : maxVisibleChildren ); } } }, [containerWidth, childWidth]); return ( <> <label>{visibleChildren} elements are visible!</label> <div ref={divRef} style={{ display: 'flex', overflow: 'hidden' }}> <div style={{ width: 50, flexShrink: 0 }}>foo</div> <div style={{ width: 50, flexShrink: 0 }}>bar</div> <div style={{ width: 50, flexShrink: 0 }}>boo</div> <div style={{ width: 50, flexShrink: 0 }}>far</div> </div> </> ); }
对应的测试代码
describe('OverflowingComponent', () => { it('显示3个可见元素(容器宽度150px)', () => { render(<OverflowingComponent containerWidth={150} childWidth={50} />); expect(screen.getByText('3 elements are visible!')).toBeInTheDocument(); }); });
方法三:手动触发Effect并修改ref.current
若无法重构组件,可在渲染后手动修改ref的current属性,再重新执行Effect(此方法较为繁琐,仅作备选):
import { render, screen, act } from '@testing-library/react'; import React from 'react'; describe('OverflowingComponent', () => { it('显示3个可见元素(容器宽度150px)', () => { // 保存原始的useEffect const originalUseEffect = React.useEffect; let effectCallback: (() => void) | undefined; // Mock useEffect,捕获回调函数 React.useEffect = jest.fn((callback) => { effectCallback = callback; return originalUseEffect(callback, []); }); render(<OverflowingComponent />); // 构造Mock的ref对象 const mockRef = { current: { clientWidth: 150, children: Array(4).fill({ clientWidth: 50 }), }, }; // 获取容器元素并替换其ref.current const container = screen.getByLabelText(/elements are visible!/).nextElementSibling as HTMLDivElement; (container as any)._internalInstance.ref.current = mockRef.current; // 手动执行Effect回调 if (effectCallback) { act(() => { effectCallback(); }); } expect(screen.getByText('3 elements are visible!')).toBeInTheDocument(); // 恢复原始的useEffect React.useEffect = originalUseEffect; }); });
内容的提问来源于stack exchange,提问作者ZippyMoody

