单元测试(Unit Testing)是一种软件测试方法,主要用于验证代码中最小可测试单元——通常是单个函数或方法——是否按照预期的功能进行工作。其目标是确保各个独立的代码模块在单独测试时能够正确运行,不依赖于其他模块。
使用过Vitest
和Jest
等写单元测试的小伙伴们对单元测试比较清楚,单元测试是否好写与代码的实现有很大的关系,但是除了自身代码实现问题之外,单元测试还存在一些问题会让小伙伴犯难
笔者在工作中经历一些单元测试中存在的坑,故总结在下面
前提
假设测试环境中已安装@testing-library/jest-dom
、@testing-library/react-hooks
、vitest
、jsdom
、@testing-library/react
和@testing-library/dom
等依赖
这是一个由Vitest
测试框架和对应的测试工具包的测试环境,用于下面的测试例子
DOM节点的clientHeight问题
在组件中获取DOM节点的的高度和宽度是常见的场景。比如在固定高度的虚拟组件中,需要获取容器的高度计算出现在视图中的列表项,请看下面的示例代码
const VirtualList = ({ itemHeight, itemCount, renderItem }) => {
const [scrollTop, setScrollTop] = useState(0);
const containerRef = useRef(null);
const [items, setItems] = useState([]);
const [startIndex, setStartIndex] = useState(0);
const [endIndex, setEndIndex] = useState(0);
const handleScroll = useCallback(() => {
if (containerRef.current) {
setScrollTop(containerRef.current.scrollTop);
}
}, []);
const totalHeight = itemHeight * itemCount;
useEffect(() => {
setStartIndex(Math.floor(scrollTop / itemHeight));
if (containerRef.current) {
setEndIndex(
Math.ceil((scrollTop + containerRef.current?.clientHeight) / itemHeight)
);
}
}, [itemHeight, scrollTop]);
useEffect(() => {
const items = [];
for (let i = startIndex; i <= endIndex; i++) {
items.push(renderItem(i));
}
setItems(items);
}, [startIndex, endIndex, renderItem]);
return (
<div
data-testid="virtual-list"
className="virtual-list"
ref={containerRef}
onScroll={handleScroll}
style={{ overflowY: "auto", height: "200px" }}
>
<div style={{ height: totalHeight, position: "relative" }}>
{items.map((item, index) => (
<div
key={startIndex + index}
style={{
position: "absolute",
top: (startIndex + index) * itemHeight,
width: "100%",
height: itemHeight,
}}
>
{item}
</div>
))}
</div>
</div>
);
};
export default VirtualList;
示例代码实现了一个固定高度的虚拟列表组件,其目的是优化渲染大量列表项时的性能,避免一次性渲染所有项,从而减少浏览器的内存和计算压力。它通过计算并只渲染可视区域内的项目来提高渲染效率。
注意看,笔者使用下列代码获取容器的高度
containerRef.current?.clientHeight
这就是在组件运行时,通过useRef获取组件中元素的DOM,读取DOM对象的clientHeight属性。
请看下面VirtualList
组件的单元测试代码
test("render items without crashing", () => {
render(
<VirtualList itemHeight={50} itemCount={100} renderItem={renderItem} />
);
expect(screen.getByText("Item 0")).toBeInTheDocument();
expect(screen.getByText("Item 1")).toBeInTheDocument();
expect(screen.getByText("Item 2")).toBeInTheDocument();
});
运行单元测试的结果如下所示
导致上述错误的根本原因是jsdom
内部没有实现clientHeight
的属性,组件在单元测试运行时无法获取正确值
containerRef.current?.clientHeight // 在单元测试运行过程中结果一直是 0
组件内部直接出错,最终导致单元测试无法正常通过。当小伙伴们遇到此类情况时,第一时间想到是jsdom
的问题,不是自己的单元测试写的不对
如何解决此类错误呢,笔者在这里给出一种解决方案,也欢迎小伙伴们分享其他解决方案👏。在测试文件头部,对需要用到的属性mock一下,本示例中需要使用 clientHeight,那么可以这样写
Object.defineProperty(globalThis.HTMLElement.prototype, "clientHeight", {
configurable: true,
get() {
// You can return any value you want, like 100, 200, etc.
return 400;
},
});
每次访问 clientHeight
时:无论 HTMLElement
元素实际的高度是多少,都会返回 400
。再运行单元测试,containerRef.current?.clientHeight
的值是400,可以保证组件运行正常。单元测试正常通过
DOM节点的样式问题
在写单元测试时,也会验证CSS样式是否符合预期。如果组件中写的行内样式,那么可以直接获取
const Container = styled.div`
height: 200px;
width: 200px;
overflow-y: auto;
position: relative;
border: 1px solid black;
`;
export function Example() {
return (
<Container
data-testid="container"
style={{ width: "100px", height: "100px" }}
/>
);
}
对应的单元测试如下
describe("Example", () => {
it("width of container is '200px'", () => {
render(<ListExample />);
const el = screen.getByTestId("container");
expect(el.style.width).toEqual("200px");
});
});
如果是样式写在css文件中,那么无法验证样式。根本原因是而 jsdom
本身不支持解析和应用 CSS 样式,因此单纯的 import './index.css'
是无法让样式生效的。
@testing-library/react-hooks
测试某些自定义Hook
对于业务中的自定义Hooks,在写单元测试的时候,大部分情况下我们使用@testing-library/react-hooks
这个工具库。但是有些自定义Hook使用@testing-library/react-hooks
比较难
请看下面的useClickAway
方法。源码如下
// useClickAway.js
function useClickAway(cb) {
const ref = React.useRef(null);
const refCb = React.useRef(cb);
React.useLayoutEffect(() => {
refCb.current = cb;
});
React.useEffect(() => {
const handler = (e) => {
const element = ref.current;
if (element && !element.contains(e.target)) {
refCb.current(e);
}
};
document.addEventListener("mousedown", handler);
document.addEventListener("touchstart", handler);
return () => {
document.removeEventListener("mousedown", handler);
document.removeEventListener("touchstart", handler);
};
}, []);
return ref;
}
这段代码定义了一个名为 useClickAway
的 React 自定义 Hook,功能是检测点击事件是否发生在指定的元素外部,并在外部点击时触发回调函数。
useClickAway
hook 返回 ref
,这是一个 React ref
对象,允许用户将它绑定到任何需要检测点击外部的 DOM 元素上。
综上所述,使用useClickAway
时,需要将返回值绑定到指定的DOM元素上。相比于useCounter
这种简单的自定义hooks,单元测试只需要执行useCounter
,使用提供的方法,然后验证状态是否符合预期。但是useClickAway
返回的结果的有ref
,内部实现也有ref
相关逻辑
笔者第一次写useClickAway
的单元测试时,以为无法使用@testing-library/react-hooks
测试。因为我在文档里看到下面的这段话
后来笔者在空闲时间仔细研究这个问题,发现只用@testing-library/react-hooks
可以写出完整的useClickAway
单元测试。示例如下
describe("useClickAway", () => {
test("should call callback when clicking outside the element", () => {
const callback = vi.fn();
const { result } = renderHook(() => useClickAway(callback));
const element = document.createElement("div");
result.current.current = element;
document.body.appendChild(element);
fireEvent.mouseDown(document.body);
expect(callback).toHaveBeenCalled();
});
});
创建一个新的 div
元素,我们将其作为目标元素来进行测试。result.current
是 useClickAway
返回的对象。current
属性通常是一个 ref
对象,因此这里将 element
赋值给 current.current
,这表示我们通过 Hook 设置了一个 DOM 元素 div
,并将其关联到 useClickAway
。