前端单测学习(4)—— react 组件方法&fireEvent

1,380 阅读6分钟

前言

上一篇中我们补充了一些对react component常见的一些能力做的单测,这一篇我们在上一篇的基础上继续查漏补缺,保证我们的单测能够覆盖到我们的场景

判断方法调用

在react component中,方法无疑是非常重要的一类,在日常的开发中,为了让组件能够提高复用性或者解耦,我们会基于某些阶段或者场景对外暴露一些生命周期hook方法或者提供callback之类的,我们的单测也是要保证这些方法能够正常的被调用到

修改组件

这里增加了一个onClickTitle参数,传入一个函数,当点击标题的时候把标题内容作为函数的入参返回

import "./index.css";
import {
  PropsWithChildren,
  ReactNode,
  CSSProperties,
  useCallback,
} from "react";

interface TodoHeaderProps {
  // 待办事项的标题
  title: string;
  // 最外层容器的样式
  containerStyle?: CSSProperties;
  // 是否结束
  isFinish?: boolean;
  // 图标的链接
  iconUrl?: string;
  // 额外的信息
  extraInfo?: ReactNode;
  // 点击标题的事件
  onClickTitle?: (title: string) => void;
}

export default function TodoHeader({
  title,
  containerStyle,
  iconUrl,
  isFinish = false,
  children,
  extraInfo,
  onClickTitle,
}: PropsWithChildren<TodoHeaderProps>) {
  // 点击标题的方法
  const clickTitleFn = useCallback(() => {
    onClickTitle?.(title);
  }, [onClickTitle, title]);

  return (
    <div className="report-header" style={containerStyle}>
      {iconUrl && <img src={iconUrl} alt="icon" />}
      <span
        className="title"
        data-testid="todo-header-title"
        style={{ background: isFinish ? "red" : "white" }}
        onClick={clickTitleFn}
      >
        {title}
      </span>
      <span className="extra">{extraInfo}</span>
      {children}
    </div>
  );
}

修改一下调用

const onClickTitle = useCallback((title: string) => {
    window.alert(title);
}, []);

<TodoHeader
  title="这是一个标题"
  containerStyle={{ border: "1px solid blue" }}
  isFinish={true}
  iconUrl={logo}
  onClickTitle={onClickTitle}
/>

看一下效果,符合预期

image.png

单测编写

这里我们需要用到几个知识点,一个是mock函数,通过jest.fn()我们创建了一个用于测试的函数,传入到组件中,第二个是toBeCalled这个api,通过这个我们可以来断言我们的方法是否有被调用到。第三个是fireEvent,这个是testing libray提供的一个模拟用户操作的一个功能,比如我们这里用到的一个click事件

it(`正确响应onClickTitle的事件`, () => {
    const mockClickFn = jest.fn();
    const title = "标题";
    const { getByText } = render(
      <TodoHeader title={title} onClickTitle={mockClickFn} />
    );
    fireEvent.click(getByText(title));
    expect(mockClickFn).toBeCalled();
});

我们运行一下这个用例看看效果pnpm test src/components/__tests__/todo-header.test.tsx

image.png

这里还可以介绍另外toBeCalledTimes这个api,可以断言被调用的次数,我们在上面的的用例的基础上加一个断言,断言调用了两次,当然这里实际上只会调用一次,这里会不通过

it(`正确响应onClickTitle的事件`, () => {
    const mockClickFn = jest.fn();
    const title = "标题";
    const { getByText } = render(
      <TodoHeader title={title} onClickTitle={mockClickFn} />
    );
    fireEvent.click(getByText(title));
    expect(mockClickFn).toBeCalled();
    expect(mockClickFn).toBeCalledTimes(2);
});

运行结果如下, image.png 我们把上面的toBeCalledTimes改为1次,重新运行一下,现在就可以通过了 image.png 再补充一个api,toBeCalledWith,这个可以断言一些调用的参数,我们在上面的case再加一个断言看看

expect(mockClickFn).toBeCalledWith(title);

重新运行下还是通过的

image.png

判断异步方法的调用

我们知道,在js中充满着各种异步,我们在此之前碰到过的各种单测基本上都没有涉及异步的东西,但是在实际项目中异步是必不可少的,所以这里我们也做一下异步这种情况的单测

修改组件

我们在原先的组件中增加多一个onInit的入参,这个入参是一个异步的函数,返回一个字符串,在组件中我们调用这个方法,然后通过setState的方法将返回的字符串设置为标题,达到异步更新title的效果

import "./index.css";
import {
  PropsWithChildren,
  ReactNode,
  CSSProperties,
  useCallback,
  useState,
  useEffect,
} from "react";

interface TodoHeaderProps {
  // 待办事项的标题
  title: string;
  // 最外层容器的样式
  containerStyle?: CSSProperties;
  // 是否结束
  isFinish?: boolean;
  // 图标的链接
  iconUrl?: string;
  // 额外的信息
  extraInfo?: ReactNode;
  // 点击标题的事件
  onClickTitle?: (title: string) => void;
  // 初始化的方法
  onInit?: () => Promise<string>;
}

export default function TodoHeader({
  title,
  containerStyle,
  iconUrl,
  isFinish = false,
  children,
  extraInfo,
  onClickTitle,
  onInit,
}: PropsWithChildren<TodoHeaderProps>) {
  const [currentTitle, setCurrentTitle] = useState<string>(title);
  // 点击标题的方法
  const clickTitleFn = useCallback(() => {
    onClickTitle?.(title);
  }, [onClickTitle, title]);

  useEffect(() => {
    if (onInit) {
      (async () => {
        const result = await onInit();
        setCurrentTitle(result);
      })();
    }
  }, [onInit]);

  return (
    <div className="report-header" style={containerStyle}>
      {iconUrl && <img src={iconUrl} alt="icon" />}
      <span
        className="title"
        data-testid="todo-header-title"
        style={{ background: isFinish ? "red" : "white" }}
        onClick={clickTitleFn}
      >
        {currentTitle}
      </span>
      <span className="extra">{extraInfo}</span>
      {children}
    </div>
  );
}

单测编写

首先我们先按我们之前的思路来写单测 ,mock一个异步函数,返回一个新的字符串,然后查询这个字符串是否存在于视图中

it(`正确响应onInit事件`, () => {
    const title = "标题";
    const newTitle = "新的标题";
    const mockInitFn = jest.fn(() => Promise.resolve(newTitle));
    const { queryByText } = render(
      <TodoHeader title={title} onInit={mockInitFn} />
    );
    const element = queryByText(newTitle);
    expect(element).not.toBeNull();
});

看下运行结果,果不其然已经报错了 image.png 这时候我们可以看到报错提示中已经非常明显的提示我们要怎么做,提示大概的意思就是因为我们内部有update state,然后这个update state实际上是异步的,我们做的断言是基于update之后的内容,所以解决方案我们就是需要用act包一下这个render方法 改一下上面的测试用例

it(`正确响应onInit事件`, async () => {
    const title = "标题";
    const newTitle = "新的标题";
    const mockInitFn = jest.fn(() => Promise.resolve(newTitle));
    await act(async () => {
      render(<TodoHeader title={title} onInit={mockInitFn} />);
    });
    const element = screen.queryByText(newTitle);
    expect(element).not.toBeNull();
});

看下运行的结果,这回是已经通过了 image.png 另外的解决方案,实际上我们在之前的用例中实际上也是有用过,就是用的waitFor这个api
我们同样基于上面的case改写一下,其实就是将act改为了waitFor,其他都不变

it(`正确响应onInit事件`, async () => {
    const title = "标题";
    const newTitle = "新的标题";
    const mockInitFn = jest.fn(() => Promise.resolve(newTitle));
    await waitFor(async () => {
      render(<TodoHeader title={title} onInit={mockInitFn} />);
    });
    const element = screen.queryByText(newTitle);
    expect(element).not.toBeNull();
});

运行结果

image.png

fireEvent简单使用

在上面的case中我们已经使用过了fireEvent,这是testing library提供给我们模拟一些用户行为的api,常见比如各种点击,各种用户交互的事件等等,上面我们已经尝试过fireEvent.click这种情况了,现在我们补充多一种场景

修改组件

我们增加多一个Input,然后当这个Input修改的时候我们也update state修改这个title

import "./index.css";
import {
  PropsWithChildren,
  ReactNode,
  CSSProperties,
  useCallback,
  useState,
  useEffect,
} from "react";
import { Input } from "antd";

interface TodoHeaderProps {
  // 待办事项的标题
  title: string;
  // 最外层容器的样式
  containerStyle?: CSSProperties;
  // 是否结束
  isFinish?: boolean;
  // 图标的链接
  iconUrl?: string;
  // 额外的信息
  extraInfo?: ReactNode;
  // 点击标题的事件
  onClickTitle?: (title: string) => void;
  // 初始化的方法
  onInit?: () => Promise<string>;
}

export default function TodoHeader({
  title,
  containerStyle,
  iconUrl,
  isFinish = false,
  children,
  extraInfo,
  onClickTitle,
  onInit,
}: PropsWithChildren<TodoHeaderProps>) {
  const [currentTitle, setCurrentTitle] = useState<string>(title);
  // 点击标题的方法
  const clickTitleFn = useCallback(() => {
    onClickTitle?.(title);
  }, [onClickTitle, title]);

  useEffect(() => {
    if (onInit) {
      (async () => {
        const result = await onInit();
        setCurrentTitle(result);
      })();
    }
  }, [onInit]);

  return (
    <div className="report-header" style={containerStyle}>
      {iconUrl && <img src={iconUrl} alt="icon" />}
      <span
        className="title"
        data-testid="todo-header-title"
        style={{ background: isFinish ? "red" : "white" }}
        onClick={clickTitleFn}
      >
        {currentTitle}
      </span>
      <Input
        type="text"
        style={{ width: 300, display: "flex" }}
        value={currentTitle}
        onChange={e => setCurrentTitle(e.target.value)}
      />
      <span className="extra">{extraInfo}</span>
      {children}
    </div>
  );
}

image.png

单测编写

这里会用到上面讲到的act,获取到对应的Input之后,我们通过fireEvent.change来设置改变后的值,注意参数格式

it(`正确处理Input change 事件`, async () => {
    const title = "标题";
    const newTitle = "新的标题";
    const { container } = render(<TodoHeader title={title} />);
    const inputElement = container.querySelector("input");
    expect(inputElement).not.toBeNull();
    await act(async () => {
      fireEvent.change(inputElement!, { target: { value: newTitle } });
    });
    const element = screen.queryByText(newTitle);
    expect(element).not.toBeNull();
});

image.png

结尾

本文在上一篇blog的基础上补充了两种情况,并且针对场景的场景我们使用了fireEvent来模拟触发一些操作后调用的事件
本章节代码可参考:
github.com/liyixun/rea…
github.com/liyixun/rea…

传送门

前端单测学习(1)—— 单测入门之react单测项目初步
前端单测学习(2)—— react 组件单测初步
前端单测学习(3)—— react组件单测进阶
前端单测学习(4)—— react 组件方法&fireEvent
前端单测学习(5)—— 快照
前端单测学习(6)—— 定时器
前端单测学习(7)—— mock
前端单测学习(8)—— react hook
前端单测学习(9)—— 覆盖率报告
前端单测学习(10)—— 状态管理redux
前端单测学习(11)—— react hook 进阶
前端单测学习(12)—— 性能优化
前端单测学习(13)—— 自动化测试

代码仓库:github.com/liyixun/rea…