单元测试的几大坑,你知道几个?

151 阅读5分钟

单元测试(Unit Testing)是一种软件测试方法,主要用于验证代码中最小可测试单元——通常是单个函数或方法——是否按照预期的功能进行工作。其目标是确保各个独立的代码模块在单独测试时能够正确运行,不依赖于其他模块。

使用过VitestJest等写单元测试的小伙伴们对单元测试比较清楚,单元测试是否好写与代码的实现有很大的关系,但是除了自身代码实现问题之外,单元测试还存在一些问题会让小伙伴犯难

笔者在工作中经历一些单元测试中存在的坑,故总结在下面

前提

假设测试环境中已安装@testing-library/jest-dom@testing-library/react-hooksvitestjsdom@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();
});

运行单元测试的结果如下所示

image.png

导致上述错误的根本原因是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,可以保证组件运行正常。单元测试正常通过

image.png

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测试。因为我在文档里看到下面的这段话

image.png

后来笔者在空闲时间仔细研究这个问题,发现只用@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.currentuseClickAway 返回的对象。current 属性通常是一个 ref 对象,因此这里将 element 赋值给 current.current,这表示我们通过 Hook 设置了一个 DOM 元素 div,并将其关联到 useClickAway